legends810 commited on
Commit
e039313
·
verified ·
1 Parent(s): 0013f47

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. app/components/@settings/core/AvatarDropdown.tsx +158 -0
  2. app/components/@settings/core/ControlPanel.tsx +555 -0
  3. app/components/@settings/core/constants.ts +88 -0
  4. app/components/@settings/core/types.ts +114 -0
  5. app/components/@settings/index.ts +14 -0
  6. app/components/@settings/shared/components/DraggableTabList.tsx +163 -0
  7. app/components/@settings/shared/components/TabManagement.tsx +380 -0
  8. app/components/@settings/shared/components/TabTile.tsx +135 -0
  9. app/components/@settings/tabs/connections/ConnectionsTab.tsx +28 -0
  10. app/components/@settings/tabs/connections/GithubConnection.tsx +557 -0
  11. app/components/@settings/tabs/connections/NetlifyConnection.tsx +263 -0
  12. app/components/@settings/tabs/connections/components/ConnectionForm.tsx +180 -0
  13. app/components/@settings/tabs/connections/components/CreateBranchDialog.tsx +150 -0
  14. app/components/@settings/tabs/connections/components/PushToGitHubDialog.tsx +528 -0
  15. app/components/@settings/tabs/connections/components/RepositorySelectionDialog.tsx +693 -0
  16. app/components/@settings/tabs/connections/types/GitHub.ts +95 -0
  17. app/components/@settings/tabs/data/DataTab.tsx +452 -0
  18. app/components/@settings/tabs/debug/DebugTab.tsx +2045 -0
  19. app/components/@settings/tabs/event-logs/EventLogsTab.tsx +1013 -0
  20. app/components/@settings/tabs/features/FeaturesTab.tsx +295 -0
  21. app/components/@settings/tabs/notifications/NotificationsTab.tsx +300 -0
  22. app/components/@settings/tabs/profile/ProfileTab.tsx +181 -0
  23. app/components/@settings/tabs/providers/cloud/CloudProvidersTab.tsx +305 -0
  24. app/components/@settings/tabs/providers/local/LocalProvidersTab.tsx +777 -0
  25. app/components/@settings/tabs/providers/local/OllamaModelInstaller.tsx +603 -0
  26. app/components/@settings/tabs/providers/service-status/ServiceStatusTab.tsx +135 -0
  27. app/components/@settings/tabs/providers/service-status/base-provider.ts +121 -0
  28. app/components/@settings/tabs/providers/service-status/provider-factory.ts +154 -0
  29. app/components/@settings/tabs/providers/service-status/providers/amazon-bedrock.ts +76 -0
  30. app/components/@settings/tabs/providers/service-status/providers/anthropic.ts +80 -0
  31. app/components/@settings/tabs/providers/service-status/providers/cohere.ts +91 -0
  32. app/components/@settings/tabs/providers/service-status/providers/deepseek.ts +40 -0
  33. app/components/@settings/tabs/providers/service-status/providers/google.ts +77 -0
  34. app/components/@settings/tabs/providers/service-status/providers/groq.ts +72 -0
  35. app/components/@settings/tabs/providers/service-status/providers/huggingface.ts +98 -0
  36. app/components/@settings/tabs/providers/service-status/providers/hyperbolic.ts +40 -0
  37. app/components/@settings/tabs/providers/service-status/providers/mistral.ts +76 -0
  38. app/components/@settings/tabs/providers/service-status/providers/openai.ts +99 -0
  39. app/components/@settings/tabs/providers/service-status/providers/openrouter.ts +91 -0
  40. app/components/@settings/tabs/providers/service-status/providers/perplexity.ts +91 -0
  41. app/components/@settings/tabs/providers/service-status/providers/together.ts +91 -0
  42. app/components/@settings/tabs/providers/service-status/providers/xai.ts +40 -0
  43. app/components/@settings/tabs/providers/service-status/types.ts +55 -0
  44. app/components/@settings/tabs/providers/status/ServiceStatusTab.tsx +886 -0
  45. app/components/@settings/tabs/settings/SettingsTab.tsx +215 -0
  46. app/components/@settings/tabs/task-manager/TaskManagerTab.tsx +1265 -0
  47. app/components/@settings/tabs/update/UpdateTab.tsx +628 -0
  48. app/components/@settings/utils/animations.ts +41 -0
  49. app/components/@settings/utils/tab-helpers.ts +89 -0
  50. app/components/chat/APIKeyManager.tsx +195 -0
app/components/@settings/core/AvatarDropdown.tsx ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
2
+ import { motion } from 'framer-motion';
3
+ import { useStore } from '@nanostores/react';
4
+ import { classNames } from '~/utils/classNames';
5
+ import { profileStore } from '~/lib/stores/profile';
6
+ import type { TabType, Profile } from './types';
7
+
8
+ const BetaLabel = () => (
9
+ <span className="px-1.5 py-0.5 rounded-full bg-purple-500/10 dark:bg-purple-500/20 text-[10px] font-medium text-purple-600 dark:text-purple-400 ml-2">
10
+ BETA
11
+ </span>
12
+ );
13
+
14
+ interface AvatarDropdownProps {
15
+ onSelectTab: (tab: TabType) => void;
16
+ }
17
+
18
+ export const AvatarDropdown = ({ onSelectTab }: AvatarDropdownProps) => {
19
+ const profile = useStore(profileStore) as Profile;
20
+
21
+ return (
22
+ <DropdownMenu.Root>
23
+ <DropdownMenu.Trigger asChild>
24
+ <motion.button
25
+ className="w-10 h-10 rounded-full bg-transparent flex items-center justify-center focus:outline-none"
26
+ whileHover={{ scale: 1.02 }}
27
+ whileTap={{ scale: 0.98 }}
28
+ >
29
+ {profile?.avatar ? (
30
+ <img
31
+ src={profile.avatar}
32
+ alt={profile?.username || 'Profile'}
33
+ className="w-full h-full rounded-full object-cover"
34
+ loading="eager"
35
+ decoding="sync"
36
+ />
37
+ ) : (
38
+ <div className="w-full h-full rounded-full flex items-center justify-center bg-white dark:bg-gray-800 text-gray-400 dark:text-gray-500">
39
+ <div className="i-ph:question w-6 h-6" />
40
+ </div>
41
+ )}
42
+ </motion.button>
43
+ </DropdownMenu.Trigger>
44
+
45
+ <DropdownMenu.Portal>
46
+ <DropdownMenu.Content
47
+ className={classNames(
48
+ 'min-w-[240px] z-[250]',
49
+ 'bg-white dark:bg-[#141414]',
50
+ 'rounded-lg shadow-lg',
51
+ 'border border-gray-200/50 dark:border-gray-800/50',
52
+ 'animate-in fade-in-0 zoom-in-95',
53
+ 'py-1',
54
+ )}
55
+ sideOffset={5}
56
+ align="end"
57
+ >
58
+ <div
59
+ className={classNames(
60
+ 'px-4 py-3 flex items-center gap-3',
61
+ 'border-b border-gray-200/50 dark:border-gray-800/50',
62
+ )}
63
+ >
64
+ <div className="w-10 h-10 rounded-full overflow-hidden flex-shrink-0 bg-white dark:bg-gray-800 shadow-sm">
65
+ {profile?.avatar ? (
66
+ <img
67
+ src={profile.avatar}
68
+ alt={profile?.username || 'Profile'}
69
+ className={classNames('w-full h-full', 'object-cover', 'transform-gpu', 'image-rendering-crisp')}
70
+ loading="eager"
71
+ decoding="sync"
72
+ />
73
+ ) : (
74
+ <div className="w-full h-full flex items-center justify-center text-gray-400 dark:text-gray-500 font-medium text-lg">
75
+ <span className="relative -top-0.5">?</span>
76
+ </div>
77
+ )}
78
+ </div>
79
+ <div className="flex-1 min-w-0">
80
+ <div className="font-medium text-sm text-gray-900 dark:text-white truncate">
81
+ {profile?.username || 'Guest User'}
82
+ </div>
83
+ {profile?.bio && <div className="text-xs text-gray-500 dark:text-gray-400 truncate">{profile.bio}</div>}
84
+ </div>
85
+ </div>
86
+
87
+ <DropdownMenu.Item
88
+ className={classNames(
89
+ 'flex items-center gap-2 px-4 py-2.5',
90
+ 'text-sm text-gray-700 dark:text-gray-200',
91
+ 'hover:bg-purple-50 dark:hover:bg-purple-500/10',
92
+ 'hover:text-purple-500 dark:hover:text-purple-400',
93
+ 'cursor-pointer transition-all duration-200',
94
+ 'outline-none',
95
+ 'group',
96
+ )}
97
+ onClick={() => onSelectTab('profile')}
98
+ >
99
+ <div className="i-ph:user-circle w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
100
+ Edit Profile
101
+ </DropdownMenu.Item>
102
+
103
+ <DropdownMenu.Item
104
+ className={classNames(
105
+ 'flex items-center gap-2 px-4 py-2.5',
106
+ 'text-sm text-gray-700 dark:text-gray-200',
107
+ 'hover:bg-purple-50 dark:hover:bg-purple-500/10',
108
+ 'hover:text-purple-500 dark:hover:text-purple-400',
109
+ 'cursor-pointer transition-all duration-200',
110
+ 'outline-none',
111
+ 'group',
112
+ )}
113
+ onClick={() => onSelectTab('settings')}
114
+ >
115
+ <div className="i-ph:gear-six w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
116
+ Settings
117
+ </DropdownMenu.Item>
118
+
119
+ <div className="my-1 border-t border-gray-200/50 dark:border-gray-800/50" />
120
+
121
+ <DropdownMenu.Item
122
+ className={classNames(
123
+ 'flex items-center gap-2 px-4 py-2.5',
124
+ 'text-sm text-gray-700 dark:text-gray-200',
125
+ 'hover:bg-purple-50 dark:hover:bg-purple-500/10',
126
+ 'hover:text-purple-500 dark:hover:text-purple-400',
127
+ 'cursor-pointer transition-all duration-200',
128
+ 'outline-none',
129
+ 'group',
130
+ )}
131
+ onClick={() => onSelectTab('task-manager')}
132
+ >
133
+ <div className="i-ph:activity w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
134
+ Task Manager
135
+ <BetaLabel />
136
+ </DropdownMenu.Item>
137
+
138
+ <DropdownMenu.Item
139
+ className={classNames(
140
+ 'flex items-center gap-2 px-4 py-2.5',
141
+ 'text-sm text-gray-700 dark:text-gray-200',
142
+ 'hover:bg-purple-50 dark:hover:bg-purple-500/10',
143
+ 'hover:text-purple-500 dark:hover:text-purple-400',
144
+ 'cursor-pointer transition-all duration-200',
145
+ 'outline-none',
146
+ 'group',
147
+ )}
148
+ onClick={() => onSelectTab('service-status')}
149
+ >
150
+ <div className="i-ph:heartbeat w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
151
+ Service Status
152
+ <BetaLabel />
153
+ </DropdownMenu.Item>
154
+ </DropdownMenu.Content>
155
+ </DropdownMenu.Portal>
156
+ </DropdownMenu.Root>
157
+ );
158
+ };
app/components/@settings/core/ControlPanel.tsx ADDED
@@ -0,0 +1,555 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useMemo } from 'react';
2
+ import { motion, AnimatePresence } from 'framer-motion';
3
+ import { useStore } from '@nanostores/react';
4
+ import { Switch } from '@radix-ui/react-switch';
5
+ import * as RadixDialog from '@radix-ui/react-dialog';
6
+ import { classNames } from '~/utils/classNames';
7
+ import { TabManagement } from '~/components/@settings/shared/components/TabManagement';
8
+ import { TabTile } from '~/components/@settings/shared/components/TabTile';
9
+ import { useUpdateCheck } from '~/lib/hooks/useUpdateCheck';
10
+ import { useFeatures } from '~/lib/hooks/useFeatures';
11
+ import { useNotifications } from '~/lib/hooks/useNotifications';
12
+ import { useConnectionStatus } from '~/lib/hooks/useConnectionStatus';
13
+ import { useDebugStatus } from '~/lib/hooks/useDebugStatus';
14
+ import {
15
+ tabConfigurationStore,
16
+ developerModeStore,
17
+ setDeveloperMode,
18
+ resetTabConfiguration,
19
+ } from '~/lib/stores/settings';
20
+ import { profileStore } from '~/lib/stores/profile';
21
+ import type { TabType, TabVisibilityConfig, Profile } from './types';
22
+ import { TAB_LABELS, DEFAULT_TAB_CONFIG } from './constants';
23
+ import { DialogTitle } from '~/components/ui/Dialog';
24
+ import { AvatarDropdown } from './AvatarDropdown';
25
+ import BackgroundRays from '~/components/ui/BackgroundRays';
26
+
27
+ // Import all tab components
28
+ import ProfileTab from '~/components/@settings/tabs/profile/ProfileTab';
29
+ import SettingsTab from '~/components/@settings/tabs/settings/SettingsTab';
30
+ import NotificationsTab from '~/components/@settings/tabs/notifications/NotificationsTab';
31
+ import FeaturesTab from '~/components/@settings/tabs/features/FeaturesTab';
32
+ import DataTab from '~/components/@settings/tabs/data/DataTab';
33
+ import DebugTab from '~/components/@settings/tabs/debug/DebugTab';
34
+ import { EventLogsTab } from '~/components/@settings/tabs/event-logs/EventLogsTab';
35
+ import UpdateTab from '~/components/@settings/tabs/update/UpdateTab';
36
+ import ConnectionsTab from '~/components/@settings/tabs/connections/ConnectionsTab';
37
+ import CloudProvidersTab from '~/components/@settings/tabs/providers/cloud/CloudProvidersTab';
38
+ import ServiceStatusTab from '~/components/@settings/tabs/providers/status/ServiceStatusTab';
39
+ import LocalProvidersTab from '~/components/@settings/tabs/providers/local/LocalProvidersTab';
40
+ import TaskManagerTab from '~/components/@settings/tabs/task-manager/TaskManagerTab';
41
+
42
+ interface ControlPanelProps {
43
+ open: boolean;
44
+ onClose: () => void;
45
+ }
46
+
47
+ interface TabWithDevType extends TabVisibilityConfig {
48
+ isExtraDevTab?: boolean;
49
+ }
50
+
51
+ interface ExtendedTabConfig extends TabVisibilityConfig {
52
+ isExtraDevTab?: boolean;
53
+ }
54
+
55
+ interface BaseTabConfig {
56
+ id: TabType;
57
+ visible: boolean;
58
+ window: 'user' | 'developer';
59
+ order: number;
60
+ }
61
+
62
+ interface AnimatedSwitchProps {
63
+ checked: boolean;
64
+ onCheckedChange: (checked: boolean) => void;
65
+ id: string;
66
+ label: string;
67
+ }
68
+
69
+ const TAB_DESCRIPTIONS: Record<TabType, string> = {
70
+ profile: 'Manage your profile and account settings',
71
+ settings: 'Configure application preferences',
72
+ notifications: 'View and manage your notifications',
73
+ features: 'Explore new and upcoming features',
74
+ data: 'Manage your data and storage',
75
+ 'cloud-providers': 'Configure cloud AI providers and models',
76
+ 'local-providers': 'Configure local AI providers and models',
77
+ 'service-status': 'Monitor cloud LLM service status',
78
+ connection: 'Check connection status and settings',
79
+ debug: 'Debug tools and system information',
80
+ 'event-logs': 'View system events and logs',
81
+ update: 'Check for updates and release notes',
82
+ 'task-manager': 'Monitor system resources and processes',
83
+ 'tab-management': 'Configure visible tabs and their order',
84
+ };
85
+
86
+ // Beta status for experimental features
87
+ const BETA_TABS = new Set<TabType>(['task-manager', 'service-status', 'update', 'local-providers']);
88
+
89
+ const BetaLabel = () => (
90
+ <div className="absolute top-2 right-2 px-1.5 py-0.5 rounded-full bg-purple-500/10 dark:bg-purple-500/20">
91
+ <span className="text-[10px] font-medium text-purple-600 dark:text-purple-400">BETA</span>
92
+ </div>
93
+ );
94
+
95
+ const AnimatedSwitch = ({ checked, onCheckedChange, id, label }: AnimatedSwitchProps) => {
96
+ return (
97
+ <div className="flex items-center gap-2">
98
+ <Switch
99
+ id={id}
100
+ checked={checked}
101
+ onCheckedChange={onCheckedChange}
102
+ className={classNames(
103
+ 'relative inline-flex h-6 w-11 items-center rounded-full',
104
+ 'transition-all duration-300 ease-[cubic-bezier(0.87,_0,_0.13,_1)]',
105
+ 'bg-gray-200 dark:bg-gray-700',
106
+ 'data-[state=checked]:bg-purple-500',
107
+ 'focus:outline-none focus:ring-2 focus:ring-purple-500/20',
108
+ 'cursor-pointer',
109
+ 'group',
110
+ )}
111
+ >
112
+ <motion.span
113
+ className={classNames(
114
+ 'absolute left-[2px] top-[2px]',
115
+ 'inline-block h-5 w-5 rounded-full',
116
+ 'bg-white shadow-lg',
117
+ 'transition-shadow duration-300',
118
+ 'group-hover:shadow-md group-active:shadow-sm',
119
+ 'group-hover:scale-95 group-active:scale-90',
120
+ )}
121
+ initial={false}
122
+ transition={{
123
+ type: 'spring',
124
+ stiffness: 500,
125
+ damping: 30,
126
+ duration: 0.2,
127
+ }}
128
+ animate={{
129
+ x: checked ? '1.25rem' : '0rem',
130
+ }}
131
+ >
132
+ <motion.div
133
+ className="absolute inset-0 rounded-full bg-white"
134
+ initial={false}
135
+ animate={{
136
+ scale: checked ? 1 : 0.8,
137
+ }}
138
+ transition={{ duration: 0.2 }}
139
+ />
140
+ </motion.span>
141
+ <span className="sr-only">Toggle {label}</span>
142
+ </Switch>
143
+ <div className="flex items-center gap-2">
144
+ <label
145
+ htmlFor={id}
146
+ className="text-sm text-gray-500 dark:text-gray-400 select-none cursor-pointer whitespace-nowrap w-[88px]"
147
+ >
148
+ {label}
149
+ </label>
150
+ </div>
151
+ </div>
152
+ );
153
+ };
154
+
155
+ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
156
+ // State
157
+ const [activeTab, setActiveTab] = useState<TabType | null>(null);
158
+ const [loadingTab, setLoadingTab] = useState<TabType | null>(null);
159
+ const [showTabManagement, setShowTabManagement] = useState(false);
160
+
161
+ // Store values
162
+ const tabConfiguration = useStore(tabConfigurationStore);
163
+ const developerMode = useStore(developerModeStore);
164
+ const profile = useStore(profileStore) as Profile;
165
+
166
+ // Status hooks
167
+ const { hasUpdate, currentVersion, acknowledgeUpdate } = useUpdateCheck();
168
+ const { hasNewFeatures, unviewedFeatures, acknowledgeAllFeatures } = useFeatures();
169
+ const { hasUnreadNotifications, unreadNotifications, markAllAsRead } = useNotifications();
170
+ const { hasConnectionIssues, currentIssue, acknowledgeIssue } = useConnectionStatus();
171
+ const { hasActiveWarnings, activeIssues, acknowledgeAllIssues } = useDebugStatus();
172
+
173
+ // Memoize the base tab configurations to avoid recalculation
174
+ const baseTabConfig = useMemo(() => {
175
+ return new Map(DEFAULT_TAB_CONFIG.map((tab) => [tab.id, tab]));
176
+ }, []);
177
+
178
+ // Add visibleTabs logic using useMemo with optimized calculations
179
+ const visibleTabs = useMemo(() => {
180
+ if (!tabConfiguration?.userTabs || !Array.isArray(tabConfiguration.userTabs)) {
181
+ console.warn('Invalid tab configuration, resetting to defaults');
182
+ resetTabConfiguration();
183
+
184
+ return [];
185
+ }
186
+
187
+ const notificationsDisabled = profile?.preferences?.notifications === false;
188
+
189
+ // In developer mode, show ALL tabs without restrictions
190
+ if (developerMode) {
191
+ const seenTabs = new Set<TabType>();
192
+ const devTabs: ExtendedTabConfig[] = [];
193
+
194
+ // Process tabs in order of priority: developer, user, default
195
+ const processTab = (tab: BaseTabConfig) => {
196
+ if (!seenTabs.has(tab.id)) {
197
+ seenTabs.add(tab.id);
198
+ devTabs.push({
199
+ id: tab.id,
200
+ visible: true,
201
+ window: 'developer',
202
+ order: tab.order || devTabs.length,
203
+ });
204
+ }
205
+ };
206
+
207
+ // Process tabs in priority order
208
+ tabConfiguration.developerTabs?.forEach((tab) => processTab(tab as BaseTabConfig));
209
+ tabConfiguration.userTabs.forEach((tab) => processTab(tab as BaseTabConfig));
210
+ DEFAULT_TAB_CONFIG.forEach((tab) => processTab(tab as BaseTabConfig));
211
+
212
+ // Add Tab Management tile
213
+ devTabs.push({
214
+ id: 'tab-management' as TabType,
215
+ visible: true,
216
+ window: 'developer',
217
+ order: devTabs.length,
218
+ isExtraDevTab: true,
219
+ });
220
+
221
+ return devTabs.sort((a, b) => a.order - b.order);
222
+ }
223
+
224
+ // Optimize user mode tab filtering
225
+ return tabConfiguration.userTabs
226
+ .filter((tab) => {
227
+ if (!tab?.id) {
228
+ return false;
229
+ }
230
+
231
+ if (tab.id === 'notifications' && notificationsDisabled) {
232
+ return false;
233
+ }
234
+
235
+ return tab.visible && tab.window === 'user';
236
+ })
237
+ .sort((a, b) => a.order - b.order);
238
+ }, [tabConfiguration, developerMode, profile?.preferences?.notifications, baseTabConfig]);
239
+
240
+ // Optimize animation performance with layout animations
241
+ const gridLayoutVariants = {
242
+ hidden: { opacity: 0 },
243
+ visible: {
244
+ opacity: 1,
245
+ transition: {
246
+ staggerChildren: 0.05,
247
+ delayChildren: 0.1,
248
+ },
249
+ },
250
+ };
251
+
252
+ const itemVariants = {
253
+ hidden: { opacity: 0, scale: 0.8 },
254
+ visible: {
255
+ opacity: 1,
256
+ scale: 1,
257
+ transition: {
258
+ type: 'spring',
259
+ stiffness: 200,
260
+ damping: 20,
261
+ mass: 0.6,
262
+ },
263
+ },
264
+ };
265
+
266
+ // Reset to default view when modal opens/closes
267
+ useEffect(() => {
268
+ if (!open) {
269
+ // Reset when closing
270
+ setActiveTab(null);
271
+ setLoadingTab(null);
272
+ setShowTabManagement(false);
273
+ } else {
274
+ // When opening, set to null to show the main view
275
+ setActiveTab(null);
276
+ }
277
+ }, [open]);
278
+
279
+ // Handle closing
280
+ const handleClose = () => {
281
+ setActiveTab(null);
282
+ setLoadingTab(null);
283
+ setShowTabManagement(false);
284
+ onClose();
285
+ };
286
+
287
+ // Handlers
288
+ const handleBack = () => {
289
+ if (showTabManagement) {
290
+ setShowTabManagement(false);
291
+ } else if (activeTab) {
292
+ setActiveTab(null);
293
+ }
294
+ };
295
+
296
+ const handleDeveloperModeChange = (checked: boolean) => {
297
+ console.log('Developer mode changed:', checked);
298
+ setDeveloperMode(checked);
299
+ };
300
+
301
+ // Add effect to log developer mode changes
302
+ useEffect(() => {
303
+ console.log('Current developer mode:', developerMode);
304
+ }, [developerMode]);
305
+
306
+ const getTabComponent = (tabId: TabType | 'tab-management') => {
307
+ if (tabId === 'tab-management') {
308
+ return <TabManagement />;
309
+ }
310
+
311
+ switch (tabId) {
312
+ case 'profile':
313
+ return <ProfileTab />;
314
+ case 'settings':
315
+ return <SettingsTab />;
316
+ case 'notifications':
317
+ return <NotificationsTab />;
318
+ case 'features':
319
+ return <FeaturesTab />;
320
+ case 'data':
321
+ return <DataTab />;
322
+ case 'cloud-providers':
323
+ return <CloudProvidersTab />;
324
+ case 'local-providers':
325
+ return <LocalProvidersTab />;
326
+ case 'connection':
327
+ return <ConnectionsTab />;
328
+ case 'debug':
329
+ return <DebugTab />;
330
+ case 'event-logs':
331
+ return <EventLogsTab />;
332
+ case 'update':
333
+ return <UpdateTab />;
334
+ case 'task-manager':
335
+ return <TaskManagerTab />;
336
+ case 'service-status':
337
+ return <ServiceStatusTab />;
338
+ default:
339
+ return null;
340
+ }
341
+ };
342
+
343
+ const getTabUpdateStatus = (tabId: TabType): boolean => {
344
+ switch (tabId) {
345
+ case 'update':
346
+ return hasUpdate;
347
+ case 'features':
348
+ return hasNewFeatures;
349
+ case 'notifications':
350
+ return hasUnreadNotifications;
351
+ case 'connection':
352
+ return hasConnectionIssues;
353
+ case 'debug':
354
+ return hasActiveWarnings;
355
+ default:
356
+ return false;
357
+ }
358
+ };
359
+
360
+ const getStatusMessage = (tabId: TabType): string => {
361
+ switch (tabId) {
362
+ case 'update':
363
+ return `New update available (v${currentVersion})`;
364
+ case 'features':
365
+ return `${unviewedFeatures.length} new feature${unviewedFeatures.length === 1 ? '' : 's'} to explore`;
366
+ case 'notifications':
367
+ return `${unreadNotifications.length} unread notification${unreadNotifications.length === 1 ? '' : 's'}`;
368
+ case 'connection':
369
+ return currentIssue === 'disconnected'
370
+ ? 'Connection lost'
371
+ : currentIssue === 'high-latency'
372
+ ? 'High latency detected'
373
+ : 'Connection issues detected';
374
+ case 'debug': {
375
+ const warnings = activeIssues.filter((i) => i.type === 'warning').length;
376
+ const errors = activeIssues.filter((i) => i.type === 'error').length;
377
+
378
+ return `${warnings} warning${warnings === 1 ? '' : 's'}, ${errors} error${errors === 1 ? '' : 's'}`;
379
+ }
380
+ default:
381
+ return '';
382
+ }
383
+ };
384
+
385
+ const handleTabClick = (tabId: TabType) => {
386
+ setLoadingTab(tabId);
387
+ setActiveTab(tabId);
388
+ setShowTabManagement(false);
389
+
390
+ // Acknowledge notifications based on tab
391
+ switch (tabId) {
392
+ case 'update':
393
+ acknowledgeUpdate();
394
+ break;
395
+ case 'features':
396
+ acknowledgeAllFeatures();
397
+ break;
398
+ case 'notifications':
399
+ markAllAsRead();
400
+ break;
401
+ case 'connection':
402
+ acknowledgeIssue();
403
+ break;
404
+ case 'debug':
405
+ acknowledgeAllIssues();
406
+ break;
407
+ }
408
+
409
+ // Clear loading state after a delay
410
+ setTimeout(() => setLoadingTab(null), 500);
411
+ };
412
+
413
+ return (
414
+ <RadixDialog.Root open={open}>
415
+ <RadixDialog.Portal>
416
+ <div className="fixed inset-0 flex items-center justify-center z-[100]">
417
+ <RadixDialog.Overlay asChild>
418
+ <motion.div
419
+ className="absolute inset-0 bg-black/50 backdrop-blur-sm"
420
+ initial={{ opacity: 0 }}
421
+ animate={{ opacity: 1 }}
422
+ exit={{ opacity: 0 }}
423
+ transition={{ duration: 0.2 }}
424
+ />
425
+ </RadixDialog.Overlay>
426
+
427
+ <RadixDialog.Content
428
+ aria-describedby={undefined}
429
+ onEscapeKeyDown={handleClose}
430
+ onPointerDownOutside={handleClose}
431
+ className="relative z-[101]"
432
+ >
433
+ <motion.div
434
+ className={classNames(
435
+ 'w-[1200px] h-[90vh]',
436
+ 'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
437
+ 'rounded-2xl shadow-2xl',
438
+ 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
439
+ 'flex flex-col overflow-hidden',
440
+ 'relative',
441
+ )}
442
+ initial={{ opacity: 0, scale: 0.95, y: 20 }}
443
+ animate={{ opacity: 1, scale: 1, y: 0 }}
444
+ exit={{ opacity: 0, scale: 0.95, y: 20 }}
445
+ transition={{ duration: 0.2 }}
446
+ >
447
+ <div className="absolute inset-0 overflow-hidden rounded-2xl">
448
+ <BackgroundRays />
449
+ </div>
450
+ <div className="relative z-10 flex flex-col h-full">
451
+ {/* Header */}
452
+ <div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
453
+ <div className="flex items-center space-x-4">
454
+ {(activeTab || showTabManagement) && (
455
+ <button
456
+ onClick={handleBack}
457
+ className="flex items-center justify-center w-8 h-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
458
+ >
459
+ <div className="i-ph:arrow-left w-4 h-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
460
+ </button>
461
+ )}
462
+ <DialogTitle className="text-xl font-semibold text-gray-900 dark:text-white">
463
+ {showTabManagement ? 'Tab Management' : activeTab ? TAB_LABELS[activeTab] : 'Control Panel'}
464
+ </DialogTitle>
465
+ </div>
466
+
467
+ <div className="flex items-center gap-6">
468
+ {/* Mode Toggle */}
469
+ <div className="flex items-center gap-2 min-w-[140px] border-r border-gray-200 dark:border-gray-800 pr-6">
470
+ <AnimatedSwitch
471
+ id="developer-mode"
472
+ checked={developerMode}
473
+ onCheckedChange={handleDeveloperModeChange}
474
+ label={developerMode ? 'Developer Mode' : 'User Mode'}
475
+ />
476
+ </div>
477
+
478
+ {/* Avatar and Dropdown */}
479
+ <div className="border-l border-gray-200 dark:border-gray-800 pl-6">
480
+ <AvatarDropdown onSelectTab={handleTabClick} />
481
+ </div>
482
+
483
+ {/* Close Button */}
484
+ <button
485
+ onClick={handleClose}
486
+ className="flex items-center justify-center w-8 h-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
487
+ >
488
+ <div className="i-ph:x w-4 h-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
489
+ </button>
490
+ </div>
491
+ </div>
492
+
493
+ {/* Content */}
494
+ <div
495
+ className={classNames(
496
+ 'flex-1',
497
+ 'overflow-y-auto',
498
+ 'hover:overflow-y-auto',
499
+ 'scrollbar scrollbar-w-2',
500
+ 'scrollbar-track-transparent',
501
+ 'scrollbar-thumb-[#E5E5E5] hover:scrollbar-thumb-[#CCCCCC]',
502
+ 'dark:scrollbar-thumb-[#333333] dark:hover:scrollbar-thumb-[#444444]',
503
+ 'will-change-scroll',
504
+ 'touch-auto',
505
+ )}
506
+ >
507
+ <motion.div
508
+ key={activeTab || 'home'}
509
+ initial={{ opacity: 0 }}
510
+ animate={{ opacity: 1 }}
511
+ exit={{ opacity: 0 }}
512
+ transition={{ duration: 0.2 }}
513
+ className="p-6"
514
+ >
515
+ {showTabManagement ? (
516
+ <TabManagement />
517
+ ) : activeTab ? (
518
+ getTabComponent(activeTab)
519
+ ) : (
520
+ <motion.div
521
+ className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 relative"
522
+ variants={gridLayoutVariants}
523
+ initial="hidden"
524
+ animate="visible"
525
+ >
526
+ <AnimatePresence mode="popLayout">
527
+ {(visibleTabs as TabWithDevType[]).map((tab: TabWithDevType) => (
528
+ <motion.div key={tab.id} layout variants={itemVariants} className="aspect-[1.5/1]">
529
+ <TabTile
530
+ tab={tab}
531
+ onClick={() => handleTabClick(tab.id as TabType)}
532
+ isActive={activeTab === tab.id}
533
+ hasUpdate={getTabUpdateStatus(tab.id)}
534
+ statusMessage={getStatusMessage(tab.id)}
535
+ description={TAB_DESCRIPTIONS[tab.id]}
536
+ isLoading={loadingTab === tab.id}
537
+ className="h-full relative"
538
+ >
539
+ {BETA_TABS.has(tab.id) && <BetaLabel />}
540
+ </TabTile>
541
+ </motion.div>
542
+ ))}
543
+ </AnimatePresence>
544
+ </motion.div>
545
+ )}
546
+ </motion.div>
547
+ </div>
548
+ </div>
549
+ </motion.div>
550
+ </RadixDialog.Content>
551
+ </div>
552
+ </RadixDialog.Portal>
553
+ </RadixDialog.Root>
554
+ );
555
+ };
app/components/@settings/core/constants.ts ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { TabType } from './types';
2
+
3
+ export const TAB_ICONS: Record<TabType, string> = {
4
+ profile: 'i-ph:user-circle-fill',
5
+ settings: 'i-ph:gear-six-fill',
6
+ notifications: 'i-ph:bell-fill',
7
+ features: 'i-ph:star-fill',
8
+ data: 'i-ph:database-fill',
9
+ 'cloud-providers': 'i-ph:cloud-fill',
10
+ 'local-providers': 'i-ph:desktop-fill',
11
+ 'service-status': 'i-ph:activity-bold',
12
+ connection: 'i-ph:wifi-high-fill',
13
+ debug: 'i-ph:bug-fill',
14
+ 'event-logs': 'i-ph:list-bullets-fill',
15
+ update: 'i-ph:arrow-clockwise-fill',
16
+ 'task-manager': 'i-ph:chart-line-fill',
17
+ 'tab-management': 'i-ph:squares-four-fill',
18
+ };
19
+
20
+ export const TAB_LABELS: Record<TabType, string> = {
21
+ profile: 'Profile',
22
+ settings: 'Settings',
23
+ notifications: 'Notifications',
24
+ features: 'Features',
25
+ data: 'Data Management',
26
+ 'cloud-providers': 'Cloud Providers',
27
+ 'local-providers': 'Local Providers',
28
+ 'service-status': 'Service Status',
29
+ connection: 'Connection',
30
+ debug: 'Debug',
31
+ 'event-logs': 'Event Logs',
32
+ update: 'Updates',
33
+ 'task-manager': 'Task Manager',
34
+ 'tab-management': 'Tab Management',
35
+ };
36
+
37
+ export const TAB_DESCRIPTIONS: Record<TabType, string> = {
38
+ profile: 'Manage your profile and account settings',
39
+ settings: 'Configure application preferences',
40
+ notifications: 'View and manage your notifications',
41
+ features: 'Explore new and upcoming features',
42
+ data: 'Manage your data and storage',
43
+ 'cloud-providers': 'Configure cloud AI providers and models',
44
+ 'local-providers': 'Configure local AI providers and models',
45
+ 'service-status': 'Monitor cloud LLM service status',
46
+ connection: 'Check connection status and settings',
47
+ debug: 'Debug tools and system information',
48
+ 'event-logs': 'View system events and logs',
49
+ update: 'Check for updates and release notes',
50
+ 'task-manager': 'Monitor system resources and processes',
51
+ 'tab-management': 'Configure visible tabs and their order',
52
+ };
53
+
54
+ export const DEFAULT_TAB_CONFIG = [
55
+ // User Window Tabs (Always visible by default)
56
+ { id: 'features', visible: true, window: 'user' as const, order: 0 },
57
+ { id: 'data', visible: true, window: 'user' as const, order: 1 },
58
+ { id: 'cloud-providers', visible: true, window: 'user' as const, order: 2 },
59
+ { id: 'local-providers', visible: true, window: 'user' as const, order: 3 },
60
+ { id: 'connection', visible: true, window: 'user' as const, order: 4 },
61
+ { id: 'notifications', visible: true, window: 'user' as const, order: 5 },
62
+ { id: 'event-logs', visible: true, window: 'user' as const, order: 6 },
63
+
64
+ // User Window Tabs (In dropdown, initially hidden)
65
+ { id: 'profile', visible: false, window: 'user' as const, order: 7 },
66
+ { id: 'settings', visible: false, window: 'user' as const, order: 8 },
67
+ { id: 'task-manager', visible: false, window: 'user' as const, order: 9 },
68
+ { id: 'service-status', visible: false, window: 'user' as const, order: 10 },
69
+
70
+ // User Window Tabs (Hidden, controlled by TaskManagerTab)
71
+ { id: 'debug', visible: false, window: 'user' as const, order: 11 },
72
+ { id: 'update', visible: false, window: 'user' as const, order: 12 },
73
+
74
+ // Developer Window Tabs (All visible by default)
75
+ { id: 'features', visible: true, window: 'developer' as const, order: 0 },
76
+ { id: 'data', visible: true, window: 'developer' as const, order: 1 },
77
+ { id: 'cloud-providers', visible: true, window: 'developer' as const, order: 2 },
78
+ { id: 'local-providers', visible: true, window: 'developer' as const, order: 3 },
79
+ { id: 'connection', visible: true, window: 'developer' as const, order: 4 },
80
+ { id: 'notifications', visible: true, window: 'developer' as const, order: 5 },
81
+ { id: 'event-logs', visible: true, window: 'developer' as const, order: 6 },
82
+ { id: 'profile', visible: true, window: 'developer' as const, order: 7 },
83
+ { id: 'settings', visible: true, window: 'developer' as const, order: 8 },
84
+ { id: 'task-manager', visible: true, window: 'developer' as const, order: 9 },
85
+ { id: 'service-status', visible: true, window: 'developer' as const, order: 10 },
86
+ { id: 'debug', visible: true, window: 'developer' as const, order: 11 },
87
+ { id: 'update', visible: true, window: 'developer' as const, order: 12 },
88
+ ];
app/components/@settings/core/types.ts ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ReactNode } from 'react';
2
+
3
+ export type SettingCategory = 'profile' | 'file_sharing' | 'connectivity' | 'system' | 'services' | 'preferences';
4
+
5
+ export type TabType =
6
+ | 'profile'
7
+ | 'settings'
8
+ | 'notifications'
9
+ | 'features'
10
+ | 'data'
11
+ | 'cloud-providers'
12
+ | 'local-providers'
13
+ | 'service-status'
14
+ | 'connection'
15
+ | 'debug'
16
+ | 'event-logs'
17
+ | 'update'
18
+ | 'task-manager'
19
+ | 'tab-management';
20
+
21
+ export type WindowType = 'user' | 'developer';
22
+
23
+ export interface UserProfile {
24
+ nickname: any;
25
+ name: string;
26
+ email: string;
27
+ avatar?: string;
28
+ theme: 'light' | 'dark' | 'system';
29
+ notifications: boolean;
30
+ password?: string;
31
+ bio?: string;
32
+ language: string;
33
+ timezone: string;
34
+ }
35
+
36
+ export interface SettingItem {
37
+ id: TabType;
38
+ label: string;
39
+ icon: string;
40
+ category: SettingCategory;
41
+ description?: string;
42
+ component: () => ReactNode;
43
+ badge?: string;
44
+ keywords?: string[];
45
+ }
46
+
47
+ export interface TabVisibilityConfig {
48
+ id: TabType;
49
+ visible: boolean;
50
+ window: WindowType;
51
+ order: number;
52
+ isExtraDevTab?: boolean;
53
+ locked?: boolean;
54
+ }
55
+
56
+ export interface DevTabConfig extends TabVisibilityConfig {
57
+ window: 'developer';
58
+ }
59
+
60
+ export interface UserTabConfig extends TabVisibilityConfig {
61
+ window: 'user';
62
+ }
63
+
64
+ export interface TabWindowConfig {
65
+ userTabs: UserTabConfig[];
66
+ developerTabs: DevTabConfig[];
67
+ }
68
+
69
+ export const TAB_LABELS: Record<TabType, string> = {
70
+ profile: 'Profile',
71
+ settings: 'Settings',
72
+ notifications: 'Notifications',
73
+ features: 'Features',
74
+ data: 'Data Management',
75
+ 'cloud-providers': 'Cloud Providers',
76
+ 'local-providers': 'Local Providers',
77
+ 'service-status': 'Service Status',
78
+ connection: 'Connections',
79
+ debug: 'Debug',
80
+ 'event-logs': 'Event Logs',
81
+ update: 'Updates',
82
+ 'task-manager': 'Task Manager',
83
+ 'tab-management': 'Tab Management',
84
+ };
85
+
86
+ export const categoryLabels: Record<SettingCategory, string> = {
87
+ profile: 'Profile & Account',
88
+ file_sharing: 'File Sharing',
89
+ connectivity: 'Connectivity',
90
+ system: 'System',
91
+ services: 'Services',
92
+ preferences: 'Preferences',
93
+ };
94
+
95
+ export const categoryIcons: Record<SettingCategory, string> = {
96
+ profile: 'i-ph:user-circle',
97
+ file_sharing: 'i-ph:folder-simple',
98
+ connectivity: 'i-ph:wifi-high',
99
+ system: 'i-ph:gear',
100
+ services: 'i-ph:cube',
101
+ preferences: 'i-ph:sliders',
102
+ };
103
+
104
+ export interface Profile {
105
+ username?: string;
106
+ bio?: string;
107
+ avatar?: string;
108
+ preferences?: {
109
+ notifications?: boolean;
110
+ theme?: 'light' | 'dark' | 'system';
111
+ language?: string;
112
+ timezone?: string;
113
+ };
114
+ }
app/components/@settings/index.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Core exports
2
+ export { ControlPanel } from './core/ControlPanel';
3
+ export type { TabType, TabVisibilityConfig } from './core/types';
4
+
5
+ // Constants
6
+ export { TAB_LABELS, TAB_DESCRIPTIONS, DEFAULT_TAB_CONFIG } from './core/constants';
7
+
8
+ // Shared components
9
+ export { TabTile } from './shared/components/TabTile';
10
+ export { TabManagement } from './shared/components/TabManagement';
11
+
12
+ // Utils
13
+ export { getVisibleTabs, reorderTabs, resetToDefaultConfig } from './utils/tab-helpers';
14
+ export * from './utils/animations';
app/components/@settings/shared/components/DraggableTabList.tsx ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useDrag, useDrop } from 'react-dnd';
2
+ import { motion } from 'framer-motion';
3
+ import { classNames } from '~/utils/classNames';
4
+ import type { TabVisibilityConfig } from '~/components/@settings/core/types';
5
+ import { TAB_LABELS } from '~/components/@settings/core/types';
6
+ import { Switch } from '~/components/ui/Switch';
7
+
8
+ interface DraggableTabListProps {
9
+ tabs: TabVisibilityConfig[];
10
+ onReorder: (tabs: TabVisibilityConfig[]) => void;
11
+ onWindowChange?: (tab: TabVisibilityConfig, window: 'user' | 'developer') => void;
12
+ onVisibilityChange?: (tab: TabVisibilityConfig, visible: boolean) => void;
13
+ showControls?: boolean;
14
+ }
15
+
16
+ interface DraggableTabItemProps {
17
+ tab: TabVisibilityConfig;
18
+ index: number;
19
+ moveTab: (dragIndex: number, hoverIndex: number) => void;
20
+ showControls?: boolean;
21
+ onWindowChange?: (tab: TabVisibilityConfig, window: 'user' | 'developer') => void;
22
+ onVisibilityChange?: (tab: TabVisibilityConfig, visible: boolean) => void;
23
+ }
24
+
25
+ interface DragItem {
26
+ type: string;
27
+ index: number;
28
+ id: string;
29
+ }
30
+
31
+ const DraggableTabItem = ({
32
+ tab,
33
+ index,
34
+ moveTab,
35
+ showControls,
36
+ onWindowChange,
37
+ onVisibilityChange,
38
+ }: DraggableTabItemProps) => {
39
+ const [{ isDragging }, dragRef] = useDrag({
40
+ type: 'tab',
41
+ item: { type: 'tab', index, id: tab.id },
42
+ collect: (monitor) => ({
43
+ isDragging: monitor.isDragging(),
44
+ }),
45
+ });
46
+
47
+ const [, dropRef] = useDrop({
48
+ accept: 'tab',
49
+ hover: (item: DragItem, monitor) => {
50
+ if (!monitor.isOver({ shallow: true })) {
51
+ return;
52
+ }
53
+
54
+ if (item.index === index) {
55
+ return;
56
+ }
57
+
58
+ if (item.id === tab.id) {
59
+ return;
60
+ }
61
+
62
+ moveTab(item.index, index);
63
+ item.index = index;
64
+ },
65
+ });
66
+
67
+ const ref = (node: HTMLDivElement | null) => {
68
+ dragRef(node);
69
+ dropRef(node);
70
+ };
71
+
72
+ return (
73
+ <motion.div
74
+ ref={ref}
75
+ initial={false}
76
+ animate={{
77
+ scale: isDragging ? 1.02 : 1,
78
+ boxShadow: isDragging ? '0 8px 16px rgba(0,0,0,0.1)' : 'none',
79
+ }}
80
+ className={classNames(
81
+ 'flex items-center justify-between p-4 rounded-lg',
82
+ 'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
83
+ 'border border-[#E5E5E5] dark:border-[#333333]',
84
+ isDragging ? 'z-50' : '',
85
+ )}
86
+ >
87
+ <div className="flex items-center gap-4">
88
+ <div className="cursor-grab">
89
+ <div className="i-ph:dots-six-vertical w-4 h-4 text-bolt-elements-textSecondary" />
90
+ </div>
91
+ <div>
92
+ <div className="font-medium text-bolt-elements-textPrimary">{TAB_LABELS[tab.id]}</div>
93
+ {showControls && (
94
+ <div className="text-xs text-bolt-elements-textSecondary">
95
+ Order: {tab.order}, Window: {tab.window}
96
+ </div>
97
+ )}
98
+ </div>
99
+ </div>
100
+ {showControls && !tab.locked && (
101
+ <div className="flex items-center gap-4">
102
+ <div className="flex items-center gap-2">
103
+ <Switch
104
+ checked={tab.visible}
105
+ onCheckedChange={(checked: boolean) => onVisibilityChange?.(tab, checked)}
106
+ className="data-[state=checked]:bg-purple-500"
107
+ aria-label={`Toggle ${TAB_LABELS[tab.id]} visibility`}
108
+ />
109
+ <label className="text-sm text-bolt-elements-textSecondary">Visible</label>
110
+ </div>
111
+ <div className="flex items-center gap-2">
112
+ <label className="text-sm text-bolt-elements-textSecondary">User</label>
113
+ <Switch
114
+ checked={tab.window === 'developer'}
115
+ onCheckedChange={(checked: boolean) => onWindowChange?.(tab, checked ? 'developer' : 'user')}
116
+ className="data-[state=checked]:bg-purple-500"
117
+ aria-label={`Toggle ${TAB_LABELS[tab.id]} window assignment`}
118
+ />
119
+ <label className="text-sm text-bolt-elements-textSecondary">Dev</label>
120
+ </div>
121
+ </div>
122
+ )}
123
+ </motion.div>
124
+ );
125
+ };
126
+
127
+ export const DraggableTabList = ({
128
+ tabs,
129
+ onReorder,
130
+ onWindowChange,
131
+ onVisibilityChange,
132
+ showControls = false,
133
+ }: DraggableTabListProps) => {
134
+ const moveTab = (dragIndex: number, hoverIndex: number) => {
135
+ const items = Array.from(tabs);
136
+ const [reorderedItem] = items.splice(dragIndex, 1);
137
+ items.splice(hoverIndex, 0, reorderedItem);
138
+
139
+ // Update order numbers based on position
140
+ const reorderedTabs = items.map((tab, index) => ({
141
+ ...tab,
142
+ order: index + 1,
143
+ }));
144
+
145
+ onReorder(reorderedTabs);
146
+ };
147
+
148
+ return (
149
+ <div className="space-y-2">
150
+ {tabs.map((tab, index) => (
151
+ <DraggableTabItem
152
+ key={tab.id}
153
+ tab={tab}
154
+ index={index}
155
+ moveTab={moveTab}
156
+ showControls={showControls}
157
+ onWindowChange={onWindowChange}
158
+ onVisibilityChange={onVisibilityChange}
159
+ />
160
+ ))}
161
+ </div>
162
+ );
163
+ };
app/components/@settings/shared/components/TabManagement.tsx ADDED
@@ -0,0 +1,380 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react';
2
+ import { motion } from 'framer-motion';
3
+ import { useStore } from '@nanostores/react';
4
+ import { Switch } from '~/components/ui/Switch';
5
+ import { classNames } from '~/utils/classNames';
6
+ import { tabConfigurationStore } from '~/lib/stores/settings';
7
+ import { TAB_LABELS } from '~/components/@settings/core/constants';
8
+ import type { TabType } from '~/components/@settings/core/types';
9
+ import { toast } from 'react-toastify';
10
+ import { TbLayoutGrid } from 'react-icons/tb';
11
+ import { useSettingsStore } from '~/lib/stores/settings';
12
+
13
+ // Define tab icons mapping
14
+ const TAB_ICONS: Record<TabType, string> = {
15
+ profile: 'i-ph:user-circle-fill',
16
+ settings: 'i-ph:gear-six-fill',
17
+ notifications: 'i-ph:bell-fill',
18
+ features: 'i-ph:star-fill',
19
+ data: 'i-ph:database-fill',
20
+ 'cloud-providers': 'i-ph:cloud-fill',
21
+ 'local-providers': 'i-ph:desktop-fill',
22
+ 'service-status': 'i-ph:activity-fill',
23
+ connection: 'i-ph:wifi-high-fill',
24
+ debug: 'i-ph:bug-fill',
25
+ 'event-logs': 'i-ph:list-bullets-fill',
26
+ update: 'i-ph:arrow-clockwise-fill',
27
+ 'task-manager': 'i-ph:chart-line-fill',
28
+ 'tab-management': 'i-ph:squares-four-fill',
29
+ };
30
+
31
+ // Define which tabs are default in user mode
32
+ const DEFAULT_USER_TABS: TabType[] = [
33
+ 'features',
34
+ 'data',
35
+ 'cloud-providers',
36
+ 'local-providers',
37
+ 'connection',
38
+ 'notifications',
39
+ 'event-logs',
40
+ ];
41
+
42
+ // Define which tabs can be added to user mode
43
+ const OPTIONAL_USER_TABS: TabType[] = ['profile', 'settings', 'task-manager', 'service-status', 'debug', 'update'];
44
+
45
+ // All available tabs for user mode
46
+ const ALL_USER_TABS = [...DEFAULT_USER_TABS, ...OPTIONAL_USER_TABS];
47
+
48
+ // Define which tabs are beta
49
+ const BETA_TABS = new Set<TabType>(['task-manager', 'service-status', 'update', 'local-providers']);
50
+
51
+ // Beta label component
52
+ const BetaLabel = () => (
53
+ <span className="px-1.5 py-0.5 text-[10px] rounded-full bg-purple-500/10 text-purple-500 font-medium">BETA</span>
54
+ );
55
+
56
+ export const TabManagement = () => {
57
+ const [searchQuery, setSearchQuery] = useState('');
58
+ const tabConfiguration = useStore(tabConfigurationStore);
59
+ const { setSelectedTab } = useSettingsStore();
60
+
61
+ const handleTabVisibilityChange = (tabId: TabType, checked: boolean) => {
62
+ // Get current tab configuration
63
+ const currentTab = tabConfiguration.userTabs.find((tab) => tab.id === tabId);
64
+
65
+ // If tab doesn't exist in configuration, create it
66
+ if (!currentTab) {
67
+ const newTab = {
68
+ id: tabId,
69
+ visible: checked,
70
+ window: 'user' as const,
71
+ order: tabConfiguration.userTabs.length,
72
+ };
73
+
74
+ const updatedTabs = [...tabConfiguration.userTabs, newTab];
75
+
76
+ tabConfigurationStore.set({
77
+ ...tabConfiguration,
78
+ userTabs: updatedTabs,
79
+ });
80
+
81
+ toast.success(`Tab ${checked ? 'enabled' : 'disabled'} successfully`);
82
+
83
+ return;
84
+ }
85
+
86
+ // Check if tab can be enabled in user mode
87
+ const canBeEnabled = DEFAULT_USER_TABS.includes(tabId) || OPTIONAL_USER_TABS.includes(tabId);
88
+
89
+ if (!canBeEnabled && checked) {
90
+ toast.error('This tab cannot be enabled in user mode');
91
+ return;
92
+ }
93
+
94
+ // Update tab visibility
95
+ const updatedTabs = tabConfiguration.userTabs.map((tab) => {
96
+ if (tab.id === tabId) {
97
+ return { ...tab, visible: checked };
98
+ }
99
+
100
+ return tab;
101
+ });
102
+
103
+ // Update store
104
+ tabConfigurationStore.set({
105
+ ...tabConfiguration,
106
+ userTabs: updatedTabs,
107
+ });
108
+
109
+ // Show success message
110
+ toast.success(`Tab ${checked ? 'enabled' : 'disabled'} successfully`);
111
+ };
112
+
113
+ // Create a map of existing tab configurations
114
+ const tabConfigMap = new Map(tabConfiguration.userTabs.map((tab) => [tab.id, tab]));
115
+
116
+ // Generate the complete list of tabs, including those not in the configuration
117
+ const allTabs = ALL_USER_TABS.map((tabId) => {
118
+ return (
119
+ tabConfigMap.get(tabId) || {
120
+ id: tabId,
121
+ visible: false,
122
+ window: 'user' as const,
123
+ order: -1,
124
+ }
125
+ );
126
+ });
127
+
128
+ // Filter tabs based on search query
129
+ const filteredTabs = allTabs.filter((tab) => TAB_LABELS[tab.id].toLowerCase().includes(searchQuery.toLowerCase()));
130
+
131
+ useEffect(() => {
132
+ // Reset to first tab when component unmounts
133
+ return () => {
134
+ setSelectedTab('user'); // Reset to user tab when unmounting
135
+ };
136
+ }, [setSelectedTab]);
137
+
138
+ return (
139
+ <div className="space-y-6">
140
+ <motion.div
141
+ className="space-y-4"
142
+ initial={{ opacity: 0, y: 20 }}
143
+ animate={{ opacity: 1, y: 0 }}
144
+ transition={{ duration: 0.3 }}
145
+ >
146
+ {/* Header */}
147
+ <div className="flex items-center justify-between gap-4 mt-8 mb-4">
148
+ <div className="flex items-center gap-2">
149
+ <div
150
+ className={classNames(
151
+ 'w-8 h-8 flex items-center justify-center rounded-lg',
152
+ 'bg-bolt-elements-background-depth-3',
153
+ 'text-purple-500',
154
+ )}
155
+ >
156
+ <TbLayoutGrid className="w-5 h-5" />
157
+ </div>
158
+ <div>
159
+ <h4 className="text-md font-medium text-bolt-elements-textPrimary">Tab Management</h4>
160
+ <p className="text-sm text-bolt-elements-textSecondary">Configure visible tabs and their order</p>
161
+ </div>
162
+ </div>
163
+
164
+ {/* Search */}
165
+ <div className="relative w-64">
166
+ <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
167
+ <div className="i-ph:magnifying-glass w-4 h-4 text-gray-400" />
168
+ </div>
169
+ <input
170
+ type="text"
171
+ value={searchQuery}
172
+ onChange={(e) => setSearchQuery(e.target.value)}
173
+ placeholder="Search tabs..."
174
+ className={classNames(
175
+ 'w-full pl-10 pr-4 py-2 rounded-lg',
176
+ 'bg-bolt-elements-background-depth-2',
177
+ 'border border-bolt-elements-borderColor',
178
+ 'text-bolt-elements-textPrimary',
179
+ 'placeholder-bolt-elements-textTertiary',
180
+ 'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
181
+ 'transition-all duration-200',
182
+ )}
183
+ />
184
+ </div>
185
+ </div>
186
+
187
+ {/* Tab Grid */}
188
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
189
+ {/* Default Section Header */}
190
+ {filteredTabs.some((tab) => DEFAULT_USER_TABS.includes(tab.id)) && (
191
+ <div className="col-span-full flex items-center gap-2 mt-4 mb-2">
192
+ <div className="i-ph:star-fill w-4 h-4 text-purple-500" />
193
+ <span className="text-sm font-medium text-bolt-elements-textPrimary">Default Tabs</span>
194
+ </div>
195
+ )}
196
+
197
+ {/* Default Tabs */}
198
+ {filteredTabs
199
+ .filter((tab) => DEFAULT_USER_TABS.includes(tab.id))
200
+ .map((tab, index) => (
201
+ <motion.div
202
+ key={tab.id}
203
+ className={classNames(
204
+ 'rounded-lg border bg-bolt-elements-background text-bolt-elements-textPrimary',
205
+ 'bg-bolt-elements-background-depth-2',
206
+ 'hover:bg-bolt-elements-background-depth-3',
207
+ 'transition-all duration-200',
208
+ 'relative overflow-hidden group',
209
+ )}
210
+ initial={{ opacity: 0, y: 20 }}
211
+ animate={{ opacity: 1, y: 0 }}
212
+ transition={{ delay: index * 0.1 }}
213
+ whileHover={{ scale: 1.02 }}
214
+ >
215
+ {/* Status Badges */}
216
+ <div className="absolute top-1 right-1.5 flex gap-1">
217
+ <span className="px-1.5 py-0.25 text-xs rounded-full bg-purple-500/10 text-purple-500 font-medium mr-2">
218
+ Default
219
+ </span>
220
+ </div>
221
+
222
+ <div className="flex items-start gap-4 p-4">
223
+ <motion.div
224
+ className={classNames(
225
+ 'w-10 h-10 flex items-center justify-center rounded-xl',
226
+ 'bg-bolt-elements-background-depth-3 group-hover:bg-bolt-elements-background-depth-4',
227
+ 'transition-all duration-200',
228
+ tab.visible ? 'text-purple-500' : 'text-bolt-elements-textSecondary',
229
+ )}
230
+ whileHover={{ scale: 1.1 }}
231
+ whileTap={{ scale: 0.9 }}
232
+ >
233
+ <div
234
+ className={classNames('w-6 h-6', 'transition-transform duration-200', 'group-hover:rotate-12')}
235
+ >
236
+ <div className={classNames(TAB_ICONS[tab.id], 'w-full h-full')} />
237
+ </div>
238
+ </motion.div>
239
+
240
+ <div className="flex-1 min-w-0">
241
+ <div className="flex items-center justify-between gap-4">
242
+ <div>
243
+ <div className="flex items-center gap-2">
244
+ <h4 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-purple-500 transition-colors">
245
+ {TAB_LABELS[tab.id]}
246
+ </h4>
247
+ {BETA_TABS.has(tab.id) && <BetaLabel />}
248
+ </div>
249
+ <p className="text-xs text-bolt-elements-textSecondary mt-0.5">
250
+ {tab.visible ? 'Visible in user mode' : 'Hidden in user mode'}
251
+ </p>
252
+ </div>
253
+ <Switch
254
+ checked={tab.visible}
255
+ onCheckedChange={(checked) => {
256
+ const isDisabled =
257
+ !DEFAULT_USER_TABS.includes(tab.id) && !OPTIONAL_USER_TABS.includes(tab.id);
258
+
259
+ if (!isDisabled) {
260
+ handleTabVisibilityChange(tab.id, checked);
261
+ }
262
+ }}
263
+ className={classNames('data-[state=checked]:bg-purple-500 ml-4', {
264
+ 'opacity-50 pointer-events-none':
265
+ !DEFAULT_USER_TABS.includes(tab.id) && !OPTIONAL_USER_TABS.includes(tab.id),
266
+ })}
267
+ />
268
+ </div>
269
+ </div>
270
+ </div>
271
+
272
+ <motion.div
273
+ className="absolute inset-0 border-2 border-purple-500/0 rounded-lg pointer-events-none"
274
+ animate={{
275
+ borderColor: tab.visible ? 'rgba(168, 85, 247, 0.2)' : 'rgba(168, 85, 247, 0)',
276
+ scale: tab.visible ? 1 : 0.98,
277
+ }}
278
+ transition={{ duration: 0.2 }}
279
+ />
280
+ </motion.div>
281
+ ))}
282
+
283
+ {/* Optional Section Header */}
284
+ {filteredTabs.some((tab) => OPTIONAL_USER_TABS.includes(tab.id)) && (
285
+ <div className="col-span-full flex items-center gap-2 mt-8 mb-2">
286
+ <div className="i-ph:plus-circle-fill w-4 h-4 text-blue-500" />
287
+ <span className="text-sm font-medium text-bolt-elements-textPrimary">Optional Tabs</span>
288
+ </div>
289
+ )}
290
+
291
+ {/* Optional Tabs */}
292
+ {filteredTabs
293
+ .filter((tab) => OPTIONAL_USER_TABS.includes(tab.id))
294
+ .map((tab, index) => (
295
+ <motion.div
296
+ key={tab.id}
297
+ className={classNames(
298
+ 'rounded-lg border bg-bolt-elements-background text-bolt-elements-textPrimary',
299
+ 'bg-bolt-elements-background-depth-2',
300
+ 'hover:bg-bolt-elements-background-depth-3',
301
+ 'transition-all duration-200',
302
+ 'relative overflow-hidden group',
303
+ )}
304
+ initial={{ opacity: 0, y: 20 }}
305
+ animate={{ opacity: 1, y: 0 }}
306
+ transition={{ delay: index * 0.1 }}
307
+ whileHover={{ scale: 1.02 }}
308
+ >
309
+ {/* Status Badges */}
310
+ <div className="absolute top-1 right-1.5 flex gap-1">
311
+ <span className="px-1.5 py-0.25 text-xs rounded-full bg-blue-500/10 text-blue-500 font-medium mr-2">
312
+ Optional
313
+ </span>
314
+ </div>
315
+
316
+ <div className="flex items-start gap-4 p-4">
317
+ <motion.div
318
+ className={classNames(
319
+ 'w-10 h-10 flex items-center justify-center rounded-xl',
320
+ 'bg-bolt-elements-background-depth-3 group-hover:bg-bolt-elements-background-depth-4',
321
+ 'transition-all duration-200',
322
+ tab.visible ? 'text-purple-500' : 'text-bolt-elements-textSecondary',
323
+ )}
324
+ whileHover={{ scale: 1.1 }}
325
+ whileTap={{ scale: 0.9 }}
326
+ >
327
+ <div
328
+ className={classNames('w-6 h-6', 'transition-transform duration-200', 'group-hover:rotate-12')}
329
+ >
330
+ <div className={classNames(TAB_ICONS[tab.id], 'w-full h-full')} />
331
+ </div>
332
+ </motion.div>
333
+
334
+ <div className="flex-1 min-w-0">
335
+ <div className="flex items-center justify-between gap-4">
336
+ <div>
337
+ <div className="flex items-center gap-2">
338
+ <h4 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-purple-500 transition-colors">
339
+ {TAB_LABELS[tab.id]}
340
+ </h4>
341
+ {BETA_TABS.has(tab.id) && <BetaLabel />}
342
+ </div>
343
+ <p className="text-xs text-bolt-elements-textSecondary mt-0.5">
344
+ {tab.visible ? 'Visible in user mode' : 'Hidden in user mode'}
345
+ </p>
346
+ </div>
347
+ <Switch
348
+ checked={tab.visible}
349
+ onCheckedChange={(checked) => {
350
+ const isDisabled =
351
+ !DEFAULT_USER_TABS.includes(tab.id) && !OPTIONAL_USER_TABS.includes(tab.id);
352
+
353
+ if (!isDisabled) {
354
+ handleTabVisibilityChange(tab.id, checked);
355
+ }
356
+ }}
357
+ className={classNames('data-[state=checked]:bg-purple-500 ml-4', {
358
+ 'opacity-50 pointer-events-none':
359
+ !DEFAULT_USER_TABS.includes(tab.id) && !OPTIONAL_USER_TABS.includes(tab.id),
360
+ })}
361
+ />
362
+ </div>
363
+ </div>
364
+ </div>
365
+
366
+ <motion.div
367
+ className="absolute inset-0 border-2 border-purple-500/0 rounded-lg pointer-events-none"
368
+ animate={{
369
+ borderColor: tab.visible ? 'rgba(168, 85, 247, 0.2)' : 'rgba(168, 85, 247, 0)',
370
+ scale: tab.visible ? 1 : 0.98,
371
+ }}
372
+ transition={{ duration: 0.2 }}
373
+ />
374
+ </motion.div>
375
+ ))}
376
+ </div>
377
+ </motion.div>
378
+ </div>
379
+ );
380
+ };
app/components/@settings/shared/components/TabTile.tsx ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { motion } from 'framer-motion';
2
+ import * as Tooltip from '@radix-ui/react-tooltip';
3
+ import { classNames } from '~/utils/classNames';
4
+ import type { TabVisibilityConfig } from '~/components/@settings/core/types';
5
+ import { TAB_LABELS, TAB_ICONS } from '~/components/@settings/core/constants';
6
+
7
+ interface TabTileProps {
8
+ tab: TabVisibilityConfig;
9
+ onClick?: () => void;
10
+ isActive?: boolean;
11
+ hasUpdate?: boolean;
12
+ statusMessage?: string;
13
+ description?: string;
14
+ isLoading?: boolean;
15
+ className?: string;
16
+ children?: React.ReactNode;
17
+ }
18
+
19
+ export const TabTile: React.FC<TabTileProps> = ({
20
+ tab,
21
+ onClick,
22
+ isActive,
23
+ hasUpdate,
24
+ statusMessage,
25
+ description,
26
+ isLoading,
27
+ className,
28
+ children,
29
+ }: TabTileProps) => {
30
+ return (
31
+ <Tooltip.Provider delayDuration={200}>
32
+ <Tooltip.Root>
33
+ <Tooltip.Trigger asChild>
34
+ <motion.div
35
+ onClick={onClick}
36
+ className={classNames(
37
+ 'relative flex flex-col items-center p-6 rounded-xl',
38
+ 'w-full h-full min-h-[160px]',
39
+ 'bg-white dark:bg-[#141414]',
40
+ 'border border-[#E5E5E5] dark:border-[#333333]',
41
+ 'group',
42
+ 'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
43
+ 'hover:border-purple-200 dark:hover:border-purple-900/30',
44
+ isActive ? 'border-purple-500 dark:border-purple-500/50 bg-purple-500/5 dark:bg-purple-500/10' : '',
45
+ isLoading ? 'cursor-wait opacity-70' : '',
46
+ className || '',
47
+ )}
48
+ >
49
+ {/* Main Content */}
50
+ <div className="flex flex-col items-center justify-center flex-1 w-full">
51
+ {/* Icon */}
52
+ <motion.div
53
+ className={classNames(
54
+ 'relative',
55
+ 'w-14 h-14',
56
+ 'flex items-center justify-center',
57
+ 'rounded-xl',
58
+ 'bg-gray-100 dark:bg-gray-800',
59
+ 'ring-1 ring-gray-200 dark:ring-gray-700',
60
+ 'group-hover:bg-purple-100 dark:group-hover:bg-gray-700/80',
61
+ 'group-hover:ring-purple-200 dark:group-hover:ring-purple-800/30',
62
+ isActive ? 'bg-purple-500/10 dark:bg-purple-500/10 ring-purple-500/30 dark:ring-purple-500/20' : '',
63
+ )}
64
+ >
65
+ <motion.div
66
+ className={classNames(
67
+ TAB_ICONS[tab.id],
68
+ 'w-8 h-8',
69
+ 'text-gray-600 dark:text-gray-300',
70
+ 'group-hover:text-purple-500 dark:group-hover:text-purple-400/80',
71
+ isActive ? 'text-purple-500 dark:text-purple-400/90' : '',
72
+ )}
73
+ />
74
+ </motion.div>
75
+
76
+ {/* Label and Description */}
77
+ <div className="flex flex-col items-center mt-5 w-full">
78
+ <h3
79
+ className={classNames(
80
+ 'text-[15px] font-medium leading-snug mb-2',
81
+ 'text-gray-700 dark:text-gray-200',
82
+ 'group-hover:text-purple-600 dark:group-hover:text-purple-300/90',
83
+ isActive ? 'text-purple-500 dark:text-purple-400/90' : '',
84
+ )}
85
+ >
86
+ {TAB_LABELS[tab.id]}
87
+ </h3>
88
+ {description && (
89
+ <p
90
+ className={classNames(
91
+ 'text-[13px] leading-relaxed',
92
+ 'text-gray-500 dark:text-gray-400',
93
+ 'max-w-[85%]',
94
+ 'text-center',
95
+ 'group-hover:text-purple-500 dark:group-hover:text-purple-400/70',
96
+ isActive ? 'text-purple-400 dark:text-purple-400/80' : '',
97
+ )}
98
+ >
99
+ {description}
100
+ </p>
101
+ )}
102
+ </div>
103
+ </div>
104
+
105
+ {/* Update Indicator with Tooltip */}
106
+ {hasUpdate && (
107
+ <>
108
+ <div className="absolute top-4 right-4 w-2 h-2 rounded-full bg-purple-500 dark:bg-purple-400 animate-pulse" />
109
+ <Tooltip.Portal>
110
+ <Tooltip.Content
111
+ className={classNames(
112
+ 'px-3 py-1.5 rounded-lg',
113
+ 'bg-[#18181B] text-white',
114
+ 'text-sm font-medium',
115
+ 'select-none',
116
+ 'z-[100]',
117
+ )}
118
+ side="top"
119
+ sideOffset={5}
120
+ >
121
+ {statusMessage}
122
+ <Tooltip.Arrow className="fill-[#18181B]" />
123
+ </Tooltip.Content>
124
+ </Tooltip.Portal>
125
+ </>
126
+ )}
127
+
128
+ {/* Children (e.g. Beta Label) */}
129
+ {children}
130
+ </motion.div>
131
+ </Tooltip.Trigger>
132
+ </Tooltip.Root>
133
+ </Tooltip.Provider>
134
+ );
135
+ };
app/components/@settings/tabs/connections/ConnectionsTab.tsx ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { motion } from 'framer-motion';
2
+ import { GithubConnection } from './GithubConnection';
3
+ import { NetlifyConnection } from './NetlifyConnection';
4
+
5
+ export default function ConnectionsTab() {
6
+ return (
7
+ <div className="space-y-4">
8
+ {/* Header */}
9
+ <motion.div
10
+ className="flex items-center gap-2 mb-2"
11
+ initial={{ opacity: 0, y: 20 }}
12
+ animate={{ opacity: 1, y: 0 }}
13
+ transition={{ delay: 0.1 }}
14
+ >
15
+ <div className="i-ph:plugs-connected w-5 h-5 text-purple-500" />
16
+ <h2 className="text-lg font-medium text-bolt-elements-textPrimary">Connection Settings</h2>
17
+ </motion.div>
18
+ <p className="text-sm text-bolt-elements-textSecondary mb-6">
19
+ Manage your external service connections and integrations
20
+ </p>
21
+
22
+ <div className="grid grid-cols-1 gap-4">
23
+ <GithubConnection />
24
+ <NetlifyConnection />
25
+ </div>
26
+ </div>
27
+ );
28
+ }
app/components/@settings/tabs/connections/GithubConnection.tsx ADDED
@@ -0,0 +1,557 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react';
2
+ import { motion } from 'framer-motion';
3
+ import { toast } from 'react-toastify';
4
+ import { logStore } from '~/lib/stores/logs';
5
+ import { classNames } from '~/utils/classNames';
6
+
7
+ interface GitHubUserResponse {
8
+ login: string;
9
+ avatar_url: string;
10
+ html_url: string;
11
+ name: string;
12
+ bio: string;
13
+ public_repos: number;
14
+ followers: number;
15
+ following: number;
16
+ created_at: string;
17
+ public_gists: number;
18
+ }
19
+
20
+ interface GitHubRepoInfo {
21
+ name: string;
22
+ full_name: string;
23
+ html_url: string;
24
+ description: string;
25
+ stargazers_count: number;
26
+ forks_count: number;
27
+ default_branch: string;
28
+ updated_at: string;
29
+ languages_url: string;
30
+ }
31
+
32
+ interface GitHubOrganization {
33
+ login: string;
34
+ avatar_url: string;
35
+ html_url: string;
36
+ }
37
+
38
+ interface GitHubEvent {
39
+ id: string;
40
+ type: string;
41
+ repo: {
42
+ name: string;
43
+ };
44
+ created_at: string;
45
+ }
46
+
47
+ interface GitHubLanguageStats {
48
+ [language: string]: number;
49
+ }
50
+
51
+ interface GitHubStats {
52
+ repos: GitHubRepoInfo[];
53
+ totalStars: number;
54
+ totalForks: number;
55
+ organizations: GitHubOrganization[];
56
+ recentActivity: GitHubEvent[];
57
+ languages: GitHubLanguageStats;
58
+ totalGists: number;
59
+ }
60
+
61
+ interface GitHubConnection {
62
+ user: GitHubUserResponse | null;
63
+ token: string;
64
+ tokenType: 'classic' | 'fine-grained';
65
+ stats?: GitHubStats;
66
+ }
67
+
68
+ export function GithubConnection() {
69
+ const [connection, setConnection] = useState<GitHubConnection>({
70
+ user: null,
71
+ token: '',
72
+ tokenType: 'classic',
73
+ });
74
+ const [isLoading, setIsLoading] = useState(true);
75
+ const [isConnecting, setIsConnecting] = useState(false);
76
+ const [isFetchingStats, setIsFetchingStats] = useState(false);
77
+ const [isStatsExpanded, setIsStatsExpanded] = useState(false);
78
+
79
+ const fetchGitHubStats = async (token: string) => {
80
+ try {
81
+ setIsFetchingStats(true);
82
+
83
+ const reposResponse = await fetch(
84
+ 'https://api.github.com/user/repos?sort=updated&per_page=10&affiliation=owner,organization_member,collaborator',
85
+ {
86
+ headers: {
87
+ Authorization: `Bearer ${token}`,
88
+ },
89
+ },
90
+ );
91
+
92
+ if (!reposResponse.ok) {
93
+ throw new Error('Failed to fetch repositories');
94
+ }
95
+
96
+ const repos = (await reposResponse.json()) as GitHubRepoInfo[];
97
+
98
+ const orgsResponse = await fetch('https://api.github.com/user/orgs', {
99
+ headers: {
100
+ Authorization: `Bearer ${token}`,
101
+ },
102
+ });
103
+
104
+ if (!orgsResponse.ok) {
105
+ throw new Error('Failed to fetch organizations');
106
+ }
107
+
108
+ const organizations = (await orgsResponse.json()) as GitHubOrganization[];
109
+
110
+ const eventsResponse = await fetch('https://api.github.com/users/' + connection.user?.login + '/events/public', {
111
+ headers: {
112
+ Authorization: `Bearer ${token}`,
113
+ },
114
+ });
115
+
116
+ if (!eventsResponse.ok) {
117
+ throw new Error('Failed to fetch events');
118
+ }
119
+
120
+ const recentActivity = ((await eventsResponse.json()) as GitHubEvent[]).slice(0, 5);
121
+
122
+ const languagePromises = repos.map((repo) =>
123
+ fetch(repo.languages_url, {
124
+ headers: {
125
+ Authorization: `Bearer ${token}`,
126
+ },
127
+ }).then((res) => res.json() as Promise<Record<string, number>>),
128
+ );
129
+
130
+ const repoLanguages = await Promise.all(languagePromises);
131
+ const languages: GitHubLanguageStats = {};
132
+
133
+ repoLanguages.forEach((repoLang) => {
134
+ Object.entries(repoLang).forEach(([lang, bytes]) => {
135
+ languages[lang] = (languages[lang] || 0) + bytes;
136
+ });
137
+ });
138
+
139
+ const totalStars = repos.reduce((acc, repo) => acc + repo.stargazers_count, 0);
140
+ const totalForks = repos.reduce((acc, repo) => acc + repo.forks_count, 0);
141
+ const totalGists = connection.user?.public_gists || 0;
142
+
143
+ setConnection((prev) => ({
144
+ ...prev,
145
+ stats: {
146
+ repos,
147
+ totalStars,
148
+ totalForks,
149
+ organizations,
150
+ recentActivity,
151
+ languages,
152
+ totalGists,
153
+ },
154
+ }));
155
+ } catch (error) {
156
+ logStore.logError('Failed to fetch GitHub stats', { error });
157
+ toast.error('Failed to fetch GitHub statistics');
158
+ } finally {
159
+ setIsFetchingStats(false);
160
+ }
161
+ };
162
+
163
+ useEffect(() => {
164
+ const savedConnection = localStorage.getItem('github_connection');
165
+
166
+ if (savedConnection) {
167
+ const parsed = JSON.parse(savedConnection);
168
+
169
+ if (!parsed.tokenType) {
170
+ parsed.tokenType = 'classic';
171
+ }
172
+
173
+ setConnection(parsed);
174
+
175
+ if (parsed.user && parsed.token) {
176
+ fetchGitHubStats(parsed.token);
177
+ }
178
+ }
179
+
180
+ setIsLoading(false);
181
+ }, []);
182
+
183
+ if (isLoading || isConnecting || isFetchingStats) {
184
+ return <LoadingSpinner />;
185
+ }
186
+
187
+ const fetchGithubUser = async (token: string) => {
188
+ try {
189
+ setIsConnecting(true);
190
+
191
+ const response = await fetch('https://api.github.com/user', {
192
+ headers: {
193
+ Authorization: `Bearer ${token}`,
194
+ },
195
+ });
196
+
197
+ if (!response.ok) {
198
+ throw new Error('Invalid token or unauthorized');
199
+ }
200
+
201
+ const data = (await response.json()) as GitHubUserResponse;
202
+ const newConnection: GitHubConnection = {
203
+ user: data,
204
+ token,
205
+ tokenType: connection.tokenType,
206
+ };
207
+
208
+ localStorage.setItem('github_connection', JSON.stringify(newConnection));
209
+ setConnection(newConnection);
210
+
211
+ await fetchGitHubStats(token);
212
+
213
+ toast.success('Successfully connected to GitHub');
214
+ } catch (error) {
215
+ logStore.logError('Failed to authenticate with GitHub', { error });
216
+ toast.error('Failed to connect to GitHub');
217
+ setConnection({ user: null, token: '', tokenType: 'classic' });
218
+ } finally {
219
+ setIsConnecting(false);
220
+ }
221
+ };
222
+
223
+ const handleConnect = async (event: React.FormEvent) => {
224
+ event.preventDefault();
225
+ await fetchGithubUser(connection.token);
226
+ };
227
+
228
+ const handleDisconnect = () => {
229
+ localStorage.removeItem('github_connection');
230
+ setConnection({ user: null, token: '', tokenType: 'classic' });
231
+ toast.success('Disconnected from GitHub');
232
+ };
233
+
234
+ return (
235
+ <motion.div
236
+ className="bg-[#FFFFFF] dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A]"
237
+ initial={{ opacity: 0, y: 20 }}
238
+ animate={{ opacity: 1, y: 0 }}
239
+ transition={{ delay: 0.2 }}
240
+ >
241
+ <div className="p-6 space-y-6">
242
+ <div className="flex items-center gap-2">
243
+ <div className="i-ph:github-logo w-5 h-5 text-bolt-elements-textPrimary" />
244
+ <h3 className="text-base font-medium text-bolt-elements-textPrimary">GitHub Connection</h3>
245
+ </div>
246
+
247
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
248
+ <div>
249
+ <label className="block text-sm text-bolt-elements-textSecondary mb-2">Token Type</label>
250
+ <select
251
+ value={connection.tokenType}
252
+ onChange={(e) =>
253
+ setConnection((prev) => ({ ...prev, tokenType: e.target.value as 'classic' | 'fine-grained' }))
254
+ }
255
+ disabled={isConnecting || !!connection.user}
256
+ className={classNames(
257
+ 'w-full px-3 py-2 rounded-lg text-sm',
258
+ 'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
259
+ 'border border-[#E5E5E5] dark:border-[#333333]',
260
+ 'text-bolt-elements-textPrimary',
261
+ 'focus:outline-none focus:ring-1 focus:ring-purple-500',
262
+ 'disabled:opacity-50',
263
+ )}
264
+ >
265
+ <option value="classic">Personal Access Token (Classic)</option>
266
+ <option value="fine-grained">Fine-grained Token</option>
267
+ </select>
268
+ </div>
269
+
270
+ <div>
271
+ <label className="block text-sm text-bolt-elements-textSecondary mb-2">
272
+ {connection.tokenType === 'classic' ? 'Personal Access Token' : 'Fine-grained Token'}
273
+ </label>
274
+ <input
275
+ type="password"
276
+ value={connection.token}
277
+ onChange={(e) => setConnection((prev) => ({ ...prev, token: e.target.value }))}
278
+ disabled={isConnecting || !!connection.user}
279
+ placeholder={`Enter your GitHub ${
280
+ connection.tokenType === 'classic' ? 'personal access token' : 'fine-grained token'
281
+ }`}
282
+ className={classNames(
283
+ 'w-full px-3 py-2 rounded-lg text-sm',
284
+ 'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
285
+ 'border border-[#E5E5E5] dark:border-[#333333]',
286
+ 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
287
+ 'focus:outline-none focus:ring-1 focus:ring-purple-500',
288
+ 'disabled:opacity-50',
289
+ )}
290
+ />
291
+ <div className="mt-2 text-sm text-bolt-elements-textSecondary">
292
+ <a
293
+ href={`https://github.com/settings/tokens${connection.tokenType === 'fine-grained' ? '/beta' : '/new'}`}
294
+ target="_blank"
295
+ rel="noopener noreferrer"
296
+ className="text-purple-500 hover:underline inline-flex items-center gap-1"
297
+ >
298
+ Get your token
299
+ <div className="i-ph:arrow-square-out w-10 h-5" />
300
+ </a>
301
+ <span className="mx-2">•</span>
302
+ <span>
303
+ Required scopes:{' '}
304
+ {connection.tokenType === 'classic'
305
+ ? 'repo, read:org, read:user'
306
+ : 'Repository access, Organization access'}
307
+ </span>
308
+ </div>
309
+ </div>
310
+ </div>
311
+
312
+ <div className="flex items-center gap-3">
313
+ {!connection.user ? (
314
+ <button
315
+ onClick={handleConnect}
316
+ disabled={isConnecting || !connection.token}
317
+ className={classNames(
318
+ 'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
319
+ 'bg-purple-500 text-white',
320
+ 'hover:bg-purple-600',
321
+ 'disabled:opacity-50 disabled:cursor-not-allowed',
322
+ )}
323
+ >
324
+ {isConnecting ? (
325
+ <>
326
+ <div className="i-ph:spinner-gap animate-spin" />
327
+ Connecting...
328
+ </>
329
+ ) : (
330
+ <>
331
+ <div className="i-ph:plug-charging w-4 h-4" />
332
+ Connect
333
+ </>
334
+ )}
335
+ </button>
336
+ ) : (
337
+ <button
338
+ onClick={handleDisconnect}
339
+ className={classNames(
340
+ 'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
341
+ 'bg-red-500 text-white',
342
+ 'hover:bg-red-600',
343
+ )}
344
+ >
345
+ <div className="i-ph:plug-x w-4 h-4" />
346
+ Disconnect
347
+ </button>
348
+ )}
349
+
350
+ {connection.user && (
351
+ <span className="text-sm text-bolt-elements-textSecondary flex items-center gap-1">
352
+ <div className="i-ph:check-circle w-4 h-4" />
353
+ Connected to GitHub
354
+ </span>
355
+ )}
356
+ </div>
357
+
358
+ {connection.user && connection.stats && (
359
+ <div className="mt-6 border-t border-[#E5E5E5] dark:border-[#1A1A1A] pt-6">
360
+ <button onClick={() => setIsStatsExpanded(!isStatsExpanded)} className="w-full bg-transparent">
361
+ <div className="flex items-center gap-4">
362
+ <img src={connection.user.avatar_url} alt={connection.user.login} className="w-16 h-16 rounded-full" />
363
+ <div className="flex-1">
364
+ <div className="flex items-center justify-between">
365
+ <h3 className="text-lg font-medium text-bolt-elements-textPrimary">
366
+ {connection.user.name || connection.user.login}
367
+ </h3>
368
+ <div
369
+ className={classNames(
370
+ 'i-ph:caret-down w-4 h-4 text-bolt-elements-textSecondary transition-transform',
371
+ isStatsExpanded ? 'rotate-180' : '',
372
+ )}
373
+ />
374
+ </div>
375
+ {connection.user.bio && (
376
+ <p className="text-sm text-start text-bolt-elements-textSecondary">{connection.user.bio}</p>
377
+ )}
378
+ <div className="flex gap-4 mt-2 text-sm text-bolt-elements-textSecondary">
379
+ <span className="flex items-center gap-1">
380
+ <div className="i-ph:users w-4 h-4" />
381
+ {connection.user.followers} followers
382
+ </span>
383
+ <span className="flex items-center gap-1">
384
+ <div className="i-ph:book-bookmark w-4 h-4" />
385
+ {connection.user.public_repos} public repos
386
+ </span>
387
+ <span className="flex items-center gap-1">
388
+ <div className="i-ph:star w-4 h-4" />
389
+ {connection.stats.totalStars} stars
390
+ </span>
391
+ <span className="flex items-center gap-1">
392
+ <div className="i-ph:git-fork w-4 h-4" />
393
+ {connection.stats.totalForks} forks
394
+ </span>
395
+ </div>
396
+ </div>
397
+ </div>
398
+ </button>
399
+
400
+ {isStatsExpanded && (
401
+ <div className="pt-4">
402
+ {connection.stats.organizations.length > 0 && (
403
+ <div className="mb-6">
404
+ <h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Organizations</h4>
405
+ <div className="flex flex-wrap gap-3">
406
+ {connection.stats.organizations.map((org) => (
407
+ <a
408
+ key={org.login}
409
+ href={org.html_url}
410
+ target="_blank"
411
+ rel="noopener noreferrer"
412
+ className="flex items-center gap-2 p-2 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] hover:bg-[#F0F0F0] dark:hover:bg-[#252525] transition-colors"
413
+ >
414
+ <img src={org.avatar_url} alt={org.login} className="w-6 h-6 rounded-md" />
415
+ <span className="text-sm text-bolt-elements-textPrimary">{org.login}</span>
416
+ </a>
417
+ ))}
418
+ </div>
419
+ </div>
420
+ )}
421
+
422
+ {/* Languages Section */}
423
+ <div className="mb-6">
424
+ <h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Top Languages</h4>
425
+ <div className="flex flex-wrap gap-2">
426
+ {Object.entries(connection.stats.languages)
427
+ .sort(([, a], [, b]) => b - a)
428
+ .slice(0, 5)
429
+ .map(([language]) => (
430
+ <span
431
+ key={language}
432
+ className="px-3 py-1 text-xs rounded-full bg-purple-500/10 text-purple-500 dark:bg-purple-500/20"
433
+ >
434
+ {language}
435
+ </span>
436
+ ))}
437
+ </div>
438
+ </div>
439
+
440
+ {/* Recent Activity Section */}
441
+ <div className="mb-6">
442
+ <h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Recent Activity</h4>
443
+ <div className="space-y-3">
444
+ {connection.stats.recentActivity.map((event) => (
445
+ <div key={event.id} className="p-3 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] text-sm">
446
+ <div className="flex items-center gap-2 text-bolt-elements-textPrimary">
447
+ <div className="i-ph:git-commit w-4 h-4 text-bolt-elements-textSecondary" />
448
+ <span className="font-medium">{event.type.replace('Event', '')}</span>
449
+ <span>on</span>
450
+ <a
451
+ href={`https://github.com/${event.repo.name}`}
452
+ target="_blank"
453
+ rel="noopener noreferrer"
454
+ className="text-purple-500 hover:underline"
455
+ >
456
+ {event.repo.name}
457
+ </a>
458
+ </div>
459
+ <div className="mt-1 text-xs text-bolt-elements-textSecondary">
460
+ {new Date(event.created_at).toLocaleDateString()} at{' '}
461
+ {new Date(event.created_at).toLocaleTimeString()}
462
+ </div>
463
+ </div>
464
+ ))}
465
+ </div>
466
+ </div>
467
+
468
+ {/* Additional Stats */}
469
+ <div className="grid grid-cols-4 gap-4 mb-6">
470
+ <div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
471
+ <div className="text-sm text-bolt-elements-textSecondary">Member Since</div>
472
+ <div className="text-lg font-medium text-bolt-elements-textPrimary">
473
+ {new Date(connection.user.created_at).toLocaleDateString()}
474
+ </div>
475
+ </div>
476
+ <div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
477
+ <div className="text-sm text-bolt-elements-textSecondary">Public Gists</div>
478
+ <div className="text-lg font-medium text-bolt-elements-textPrimary">
479
+ {connection.stats.totalGists}
480
+ </div>
481
+ </div>
482
+ <div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
483
+ <div className="text-sm text-bolt-elements-textSecondary">Organizations</div>
484
+ <div className="text-lg font-medium text-bolt-elements-textPrimary">
485
+ {connection.stats.organizations.length}
486
+ </div>
487
+ </div>
488
+ <div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
489
+ <div className="text-sm text-bolt-elements-textSecondary">Languages</div>
490
+ <div className="text-lg font-medium text-bolt-elements-textPrimary">
491
+ {Object.keys(connection.stats.languages).length}
492
+ </div>
493
+ </div>
494
+ </div>
495
+
496
+ {/* Repositories Section */}
497
+ <h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Recent Repositories</h4>
498
+ <div className="space-y-3">
499
+ {connection.stats.repos.map((repo) => (
500
+ <a
501
+ key={repo.full_name}
502
+ href={repo.html_url}
503
+ target="_blank"
504
+ rel="noopener noreferrer"
505
+ className="block p-3 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] hover:bg-[#F0F0F0] dark:hover:bg-[#252525] transition-colors"
506
+ >
507
+ <div className="flex items-center justify-between">
508
+ <div>
509
+ <h5 className="text-sm font-medium text-bolt-elements-textPrimary flex items-center gap-2">
510
+ <div className="i-ph:git-repository w-4 h-4 text-bolt-elements-textSecondary" />
511
+ {repo.name}
512
+ </h5>
513
+ {repo.description && (
514
+ <p className="text-xs text-bolt-elements-textSecondary mt-1">{repo.description}</p>
515
+ )}
516
+ <div className="flex items-center gap-2 mt-2 text-xs text-bolt-elements-textSecondary">
517
+ <span className="flex items-center gap-1">
518
+ <div className="i-ph:git-branch w-3 h-3" />
519
+ {repo.default_branch}
520
+ </span>
521
+ <span>•</span>
522
+ <span>Updated {new Date(repo.updated_at).toLocaleDateString()}</span>
523
+ </div>
524
+ </div>
525
+ <div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
526
+ <span className="flex items-center gap-1">
527
+ <div className="i-ph:star w-3 h-3" />
528
+ {repo.stargazers_count}
529
+ </span>
530
+ <span className="flex items-center gap-1">
531
+ <div className="i-ph:git-fork w-3 h-3" />
532
+ {repo.forks_count}
533
+ </span>
534
+ </div>
535
+ </div>
536
+ </a>
537
+ ))}
538
+ </div>
539
+ </div>
540
+ )}
541
+ </div>
542
+ )}
543
+ </div>
544
+ </motion.div>
545
+ );
546
+ }
547
+
548
+ function LoadingSpinner() {
549
+ return (
550
+ <div className="flex items-center justify-center p-4">
551
+ <div className="flex items-center gap-2">
552
+ <div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
553
+ <span className="text-bolt-elements-textSecondary">Loading...</span>
554
+ </div>
555
+ </div>
556
+ );
557
+ }
app/components/@settings/tabs/connections/NetlifyConnection.tsx ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState } from 'react';
2
+ import { motion } from 'framer-motion';
3
+ import { toast } from 'react-toastify';
4
+ import { useStore } from '@nanostores/react';
5
+ import { logStore } from '~/lib/stores/logs';
6
+ import { classNames } from '~/utils/classNames';
7
+ import {
8
+ netlifyConnection,
9
+ isConnecting,
10
+ isFetchingStats,
11
+ updateNetlifyConnection,
12
+ fetchNetlifyStats,
13
+ } from '~/lib/stores/netlify';
14
+ import type { NetlifyUser } from '~/types/netlify';
15
+
16
+ export function NetlifyConnection() {
17
+ const connection = useStore(netlifyConnection);
18
+ const connecting = useStore(isConnecting);
19
+ const fetchingStats = useStore(isFetchingStats);
20
+ const [isSitesExpanded, setIsSitesExpanded] = useState(false);
21
+
22
+ useEffect(() => {
23
+ const fetchSites = async () => {
24
+ if (connection.user && connection.token) {
25
+ await fetchNetlifyStats(connection.token);
26
+ }
27
+ };
28
+ fetchSites();
29
+ }, [connection.user, connection.token]);
30
+
31
+ const handleConnect = async (event: React.FormEvent) => {
32
+ event.preventDefault();
33
+ isConnecting.set(true);
34
+
35
+ try {
36
+ const response = await fetch('https://api.netlify.com/api/v1/user', {
37
+ headers: {
38
+ Authorization: `Bearer ${connection.token}`,
39
+ 'Content-Type': 'application/json',
40
+ },
41
+ });
42
+
43
+ if (!response.ok) {
44
+ throw new Error('Invalid token or unauthorized');
45
+ }
46
+
47
+ const userData = (await response.json()) as NetlifyUser;
48
+ updateNetlifyConnection({
49
+ user: userData,
50
+ token: connection.token,
51
+ });
52
+
53
+ await fetchNetlifyStats(connection.token);
54
+ toast.success('Successfully connected to Netlify');
55
+ } catch (error) {
56
+ console.error('Auth error:', error);
57
+ logStore.logError('Failed to authenticate with Netlify', { error });
58
+ toast.error('Failed to connect to Netlify');
59
+ updateNetlifyConnection({ user: null, token: '' });
60
+ } finally {
61
+ isConnecting.set(false);
62
+ }
63
+ };
64
+
65
+ const handleDisconnect = () => {
66
+ updateNetlifyConnection({ user: null, token: '' });
67
+ toast.success('Disconnected from Netlify');
68
+ };
69
+
70
+ return (
71
+ <motion.div
72
+ className="bg-[#FFFFFF] dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A]"
73
+ initial={{ opacity: 0, y: 20 }}
74
+ animate={{ opacity: 1, y: 0 }}
75
+ transition={{ delay: 0.3 }}
76
+ >
77
+ <div className="p-6 space-y-6">
78
+ <div className="flex items-center justify-between">
79
+ <div className="flex items-center gap-2">
80
+ <img
81
+ className="w-5 h-5"
82
+ height="24"
83
+ width="24"
84
+ crossOrigin="anonymous"
85
+ src="https://cdn.simpleicons.org/netlify"
86
+ />
87
+ <h3 className="text-base font-medium text-bolt-elements-textPrimary">Netlify Connection</h3>
88
+ </div>
89
+ </div>
90
+
91
+ {!connection.user ? (
92
+ <div className="space-y-4">
93
+ <div>
94
+ <label className="block text-sm text-bolt-elements-textSecondary mb-2">Personal Access Token</label>
95
+ <input
96
+ type="password"
97
+ value={connection.token}
98
+ onChange={(e) => updateNetlifyConnection({ ...connection, token: e.target.value })}
99
+ disabled={connecting}
100
+ placeholder="Enter your Netlify personal access token"
101
+ className={classNames(
102
+ 'w-full px-3 py-2 rounded-lg text-sm',
103
+ 'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
104
+ 'border border-[#E5E5E5] dark:border-[#333333]',
105
+ 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
106
+ 'focus:outline-none focus:ring-1 focus:ring-[#00AD9F]',
107
+ 'disabled:opacity-50',
108
+ )}
109
+ />
110
+ <div className="mt-2 text-sm text-bolt-elements-textSecondary">
111
+ <a
112
+ href="https://app.netlify.com/user/applications#personal-access-tokens"
113
+ target="_blank"
114
+ rel="noopener noreferrer"
115
+ className="text-[#00AD9F] hover:underline inline-flex items-center gap-1"
116
+ >
117
+ Get your token
118
+ <div className="i-ph:arrow-square-out w-4 h-4" />
119
+ </a>
120
+ </div>
121
+ </div>
122
+
123
+ <button
124
+ onClick={handleConnect}
125
+ disabled={connecting || !connection.token}
126
+ className={classNames(
127
+ 'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
128
+ 'bg-[#00AD9F] text-white',
129
+ 'hover:bg-[#00968A]',
130
+ 'disabled:opacity-50 disabled:cursor-not-allowed',
131
+ )}
132
+ >
133
+ {connecting ? (
134
+ <>
135
+ <div className="i-ph:spinner-gap animate-spin" />
136
+ Connecting...
137
+ </>
138
+ ) : (
139
+ <>
140
+ <div className="i-ph:plug-charging w-4 h-4" />
141
+ Connect
142
+ </>
143
+ )}
144
+ </button>
145
+ </div>
146
+ ) : (
147
+ <div className="space-y-6">
148
+ <div className="flex items-center justify-between">
149
+ <div className="flex items-center gap-3">
150
+ <button
151
+ onClick={handleDisconnect}
152
+ className={classNames(
153
+ 'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
154
+ 'bg-red-500 text-white',
155
+ 'hover:bg-red-600',
156
+ )}
157
+ >
158
+ <div className="i-ph:plug w-4 h-4" />
159
+ Disconnect
160
+ </button>
161
+ <span className="text-sm text-bolt-elements-textSecondary flex items-center gap-1">
162
+ <div className="i-ph:check-circle w-4 h-4 text-green-500" />
163
+ Connected to Netlify
164
+ </span>
165
+ </div>
166
+ </div>
167
+
168
+ <div className="flex items-center gap-4 p-4 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg">
169
+ <img
170
+ src={connection.user.avatar_url}
171
+ referrerPolicy="no-referrer"
172
+ crossOrigin="anonymous"
173
+ alt={connection.user.full_name}
174
+ className="w-12 h-12 rounded-full border-2 border-[#00AD9F]"
175
+ />
176
+ <div>
177
+ <h4 className="text-sm font-medium text-bolt-elements-textPrimary">{connection.user.full_name}</h4>
178
+ <p className="text-sm text-bolt-elements-textSecondary">{connection.user.email}</p>
179
+ </div>
180
+ </div>
181
+
182
+ {fetchingStats ? (
183
+ <div className="flex items-center gap-2 text-sm text-bolt-elements-textSecondary">
184
+ <div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
185
+ Fetching Netlify sites...
186
+ </div>
187
+ ) : (
188
+ <div>
189
+ <button
190
+ onClick={() => setIsSitesExpanded(!isSitesExpanded)}
191
+ className="w-full bg-transparent text-left text-sm font-medium text-bolt-elements-textPrimary mb-3 flex items-center gap-2"
192
+ >
193
+ <div className="i-ph:buildings w-4 h-4" />
194
+ Your Sites ({connection.stats?.totalSites || 0})
195
+ <div
196
+ className={classNames(
197
+ 'i-ph:caret-down w-4 h-4 ml-auto transition-transform',
198
+ isSitesExpanded ? 'rotate-180' : '',
199
+ )}
200
+ />
201
+ </button>
202
+ {isSitesExpanded && connection.stats?.sites?.length ? (
203
+ <div className="grid gap-3">
204
+ {connection.stats.sites.map((site) => (
205
+ <a
206
+ key={site.id}
207
+ href={site.admin_url}
208
+ target="_blank"
209
+ rel="noopener noreferrer"
210
+ className="block p-4 rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A] hover:border-[#00AD9F] dark:hover:border-[#00AD9F] transition-colors"
211
+ >
212
+ <div className="flex items-center justify-between">
213
+ <div>
214
+ <h5 className="text-sm font-medium text-bolt-elements-textPrimary flex items-center gap-2">
215
+ <div className="i-ph:globe w-4 h-4 text-[#00AD9F]" />
216
+ {site.name}
217
+ </h5>
218
+ <div className="flex items-center gap-2 mt-2 text-xs text-bolt-elements-textSecondary">
219
+ <a
220
+ href={site.url}
221
+ target="_blank"
222
+ rel="noopener noreferrer"
223
+ className="hover:text-[#00AD9F]"
224
+ >
225
+ {site.url}
226
+ </a>
227
+ {site.published_deploy && (
228
+ <>
229
+ <span>•</span>
230
+ <span className="flex items-center gap-1">
231
+ <div className="i-ph:clock w-3 h-3" />
232
+ {new Date(site.published_deploy.published_at).toLocaleDateString()}
233
+ </span>
234
+ </>
235
+ )}
236
+ </div>
237
+ </div>
238
+ {site.build_settings?.provider && (
239
+ <div className="text-xs text-bolt-elements-textSecondary px-2 py-1 rounded-md bg-[#F0F0F0] dark:bg-[#252525]">
240
+ <span className="flex items-center gap-1">
241
+ <div className="i-ph:git-branch w-3 h-3" />
242
+ {site.build_settings.provider}
243
+ </span>
244
+ </div>
245
+ )}
246
+ </div>
247
+ </a>
248
+ ))}
249
+ </div>
250
+ ) : isSitesExpanded ? (
251
+ <div className="text-sm text-bolt-elements-textSecondary flex items-center gap-2">
252
+ <div className="i-ph:info w-4 h-4" />
253
+ No sites found in your Netlify account
254
+ </div>
255
+ ) : null}
256
+ </div>
257
+ )}
258
+ </div>
259
+ )}
260
+ </div>
261
+ </motion.div>
262
+ );
263
+ }
app/components/@settings/tabs/connections/components/ConnectionForm.tsx ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect } from 'react';
2
+ import { classNames } from '~/utils/classNames';
3
+ import type { GitHubAuthState } from '~/components/@settings/tabs/connections/types/GitHub';
4
+ import Cookies from 'js-cookie';
5
+ import { getLocalStorage } from '~/lib/persistence';
6
+
7
+ const GITHUB_TOKEN_KEY = 'github_token';
8
+
9
+ interface ConnectionFormProps {
10
+ authState: GitHubAuthState;
11
+ setAuthState: React.Dispatch<React.SetStateAction<GitHubAuthState>>;
12
+ onSave: (e: React.FormEvent) => void;
13
+ onDisconnect: () => void;
14
+ }
15
+
16
+ export function ConnectionForm({ authState, setAuthState, onSave, onDisconnect }: ConnectionFormProps) {
17
+ // Check for saved token on mount
18
+ useEffect(() => {
19
+ const savedToken = Cookies.get(GITHUB_TOKEN_KEY) || getLocalStorage(GITHUB_TOKEN_KEY);
20
+
21
+ if (savedToken && !authState.tokenInfo?.token) {
22
+ setAuthState((prev: GitHubAuthState) => ({
23
+ ...prev,
24
+ tokenInfo: {
25
+ token: savedToken,
26
+ scope: [],
27
+ avatar_url: '',
28
+ name: null,
29
+ created_at: new Date().toISOString(),
30
+ followers: 0,
31
+ },
32
+ }));
33
+ }
34
+ }, []);
35
+
36
+ return (
37
+ <div className="rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] overflow-hidden">
38
+ <div className="p-6">
39
+ <div className="flex items-center justify-between mb-6">
40
+ <div className="flex items-center gap-3">
41
+ <div className="p-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
42
+ <div className="i-ph:plug-fill text-bolt-elements-textTertiary" />
43
+ </div>
44
+ <div>
45
+ <h3 className="text-lg font-medium text-bolt-elements-textPrimary">Connection Settings</h3>
46
+ <p className="text-sm text-bolt-elements-textSecondary">Configure your GitHub connection</p>
47
+ </div>
48
+ </div>
49
+ </div>
50
+
51
+ <form onSubmit={onSave} className="space-y-4">
52
+ <div>
53
+ <label htmlFor="username" className="block text-sm font-medium text-bolt-elements-textSecondary mb-2">
54
+ GitHub Username
55
+ </label>
56
+ <input
57
+ id="username"
58
+ type="text"
59
+ value={authState.username}
60
+ onChange={(e) => setAuthState((prev: GitHubAuthState) => ({ ...prev, username: e.target.value }))}
61
+ className={classNames(
62
+ 'w-full px-4 py-2.5 bg-[#F5F5F5] dark:bg-[#1A1A1A] border rounded-lg',
63
+ 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary text-base',
64
+ 'border-[#E5E5E5] dark:border-[#1A1A1A]',
65
+ 'focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500',
66
+ 'transition-all duration-200',
67
+ )}
68
+ placeholder="e.g., octocat"
69
+ />
70
+ </div>
71
+
72
+ <div>
73
+ <div className="flex items-center justify-between mb-2">
74
+ <label htmlFor="token" className="block text-sm font-medium text-bolt-elements-textSecondary">
75
+ Personal Access Token
76
+ </label>
77
+ <a
78
+ href="https://github.com/settings/tokens/new?scopes=repo,user,read:org,workflow,delete_repo,write:packages,read:packages"
79
+ target="_blank"
80
+ rel="noopener noreferrer"
81
+ className={classNames(
82
+ 'inline-flex items-center gap-1.5 text-xs',
83
+ 'text-purple-500 hover:text-purple-600 dark:text-purple-400 dark:hover:text-purple-300',
84
+ 'transition-colors duration-200',
85
+ )}
86
+ >
87
+ <span>Generate new token</span>
88
+ <div className="i-ph:plus-circle" />
89
+ </a>
90
+ </div>
91
+ <input
92
+ id="token"
93
+ type="password"
94
+ value={authState.tokenInfo?.token || ''}
95
+ onChange={(e) =>
96
+ setAuthState((prev: GitHubAuthState) => ({
97
+ ...prev,
98
+ tokenInfo: {
99
+ token: e.target.value,
100
+ scope: [],
101
+ avatar_url: '',
102
+ name: null,
103
+ created_at: new Date().toISOString(),
104
+ followers: 0,
105
+ },
106
+ username: '',
107
+ isConnected: false,
108
+ isVerifying: false,
109
+ isLoadingRepos: false,
110
+ }))
111
+ }
112
+ className={classNames(
113
+ 'w-full px-4 py-2.5 bg-[#F5F5F5] dark:bg-[#1A1A1A] border rounded-lg',
114
+ 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary text-base',
115
+ 'border-[#E5E5E5] dark:border-[#1A1A1A]',
116
+ 'focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500',
117
+ 'transition-all duration-200',
118
+ )}
119
+ placeholder="ghp_xxxxxxxxxxxx"
120
+ />
121
+ </div>
122
+
123
+ <div className="flex items-center justify-between pt-4 border-t border-[#E5E5E5] dark:border-[#1A1A1A]">
124
+ <div className="flex items-center gap-4">
125
+ {!authState.isConnected ? (
126
+ <button
127
+ type="submit"
128
+ disabled={authState.isVerifying || !authState.username || !authState.tokenInfo?.token}
129
+ className={classNames(
130
+ 'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
131
+ 'bg-purple-500 hover:bg-purple-600',
132
+ 'text-white',
133
+ 'disabled:opacity-50 disabled:cursor-not-allowed',
134
+ )}
135
+ >
136
+ {authState.isVerifying ? (
137
+ <>
138
+ <div className="i-ph:spinner animate-spin" />
139
+ <span>Verifying...</span>
140
+ </>
141
+ ) : (
142
+ <>
143
+ <div className="i-ph:plug-fill" />
144
+ <span>Connect</span>
145
+ </>
146
+ )}
147
+ </button>
148
+ ) : (
149
+ <>
150
+ <button
151
+ onClick={onDisconnect}
152
+ className={classNames(
153
+ 'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
154
+ 'bg-[#F5F5F5] hover:bg-red-500/10 hover:text-red-500',
155
+ 'dark:bg-[#1A1A1A] dark:hover:bg-red-500/20 dark:hover:text-red-500',
156
+ 'text-bolt-elements-textPrimary',
157
+ )}
158
+ >
159
+ <div className="i-ph:plug-fill" />
160
+ <span>Disconnect</span>
161
+ </button>
162
+ <span className="inline-flex items-center gap-2 px-3 py-1.5 text-sm text-green-600 dark:text-green-400 bg-green-500/5 rounded-lg border border-green-500/20">
163
+ <div className="i-ph:check-circle-fill" />
164
+ <span>Connected</span>
165
+ </span>
166
+ </>
167
+ )}
168
+ </div>
169
+ {authState.rateLimits && (
170
+ <div className="flex items-center gap-2 text-sm text-bolt-elements-textTertiary">
171
+ <div className="i-ph:clock-countdown opacity-60" />
172
+ <span>Rate limit resets at {authState.rateLimits.reset.toLocaleTimeString()}</span>
173
+ </div>
174
+ )}
175
+ </div>
176
+ </form>
177
+ </div>
178
+ </div>
179
+ );
180
+ }
app/components/@settings/tabs/connections/components/CreateBranchDialog.tsx ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react';
2
+ import * as Dialog from '@radix-ui/react-dialog';
3
+ import { classNames } from '~/utils/classNames';
4
+ import type { GitHubRepoInfo } from '~/components/@settings/tabs/connections/types/GitHub';
5
+ import { GitBranch } from '@phosphor-icons/react';
6
+
7
+ interface GitHubBranch {
8
+ name: string;
9
+ default?: boolean;
10
+ }
11
+
12
+ interface CreateBranchDialogProps {
13
+ isOpen: boolean;
14
+ onClose: () => void;
15
+ onConfirm: (branchName: string, sourceBranch: string) => void;
16
+ repository: GitHubRepoInfo;
17
+ branches?: GitHubBranch[];
18
+ }
19
+
20
+ export function CreateBranchDialog({ isOpen, onClose, onConfirm, repository, branches }: CreateBranchDialogProps) {
21
+ const [branchName, setBranchName] = useState('');
22
+ const [sourceBranch, setSourceBranch] = useState(branches?.find((b) => b.default)?.name || 'main');
23
+
24
+ const handleSubmit = (e: React.FormEvent) => {
25
+ e.preventDefault();
26
+ onConfirm(branchName, sourceBranch);
27
+ setBranchName('');
28
+ onClose();
29
+ };
30
+
31
+ return (
32
+ <Dialog.Root open={isOpen} onOpenChange={onClose}>
33
+ <Dialog.Portal>
34
+ <Dialog.Overlay className="fixed inset-0 bg-black/50 dark:bg-black/80" />
35
+ <Dialog.Content
36
+ className={classNames(
37
+ 'fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%]',
38
+ 'w-full max-w-md p-6 rounded-xl shadow-lg',
39
+ 'bg-white dark:bg-[#0A0A0A]',
40
+ 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
41
+ )}
42
+ >
43
+ <Dialog.Title className="text-lg font-medium text-bolt-elements-textPrimary mb-4">
44
+ Create New Branch
45
+ </Dialog.Title>
46
+
47
+ <form onSubmit={handleSubmit}>
48
+ <div className="space-y-4">
49
+ <div>
50
+ <label htmlFor="branchName" className="block text-sm font-medium text-bolt-elements-textSecondary mb-2">
51
+ Branch Name
52
+ </label>
53
+ <input
54
+ id="branchName"
55
+ type="text"
56
+ value={branchName}
57
+ onChange={(e) => setBranchName(e.target.value)}
58
+ placeholder="feature/my-new-branch"
59
+ className={classNames(
60
+ 'w-full px-3 py-2 rounded-lg',
61
+ 'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
62
+ 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
63
+ 'text-bolt-elements-textPrimary placeholder:text-bolt-elements-textTertiary',
64
+ 'focus:outline-none focus:ring-2 focus:ring-purple-500/50',
65
+ )}
66
+ required
67
+ />
68
+ </div>
69
+
70
+ <div>
71
+ <label
72
+ htmlFor="sourceBranch"
73
+ className="block text-sm font-medium text-bolt-elements-textSecondary mb-2"
74
+ >
75
+ Source Branch
76
+ </label>
77
+ <select
78
+ id="sourceBranch"
79
+ value={sourceBranch}
80
+ onChange={(e) => setSourceBranch(e.target.value)}
81
+ className={classNames(
82
+ 'w-full px-3 py-2 rounded-lg',
83
+ 'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
84
+ 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
85
+ 'text-bolt-elements-textPrimary',
86
+ 'focus:outline-none focus:ring-2 focus:ring-purple-500/50',
87
+ )}
88
+ >
89
+ {branches?.map((branch) => (
90
+ <option key={branch.name} value={branch.name}>
91
+ {branch.name} {branch.default ? '(default)' : ''}
92
+ </option>
93
+ ))}
94
+ </select>
95
+ </div>
96
+
97
+ <div className="mt-4 p-3 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-lg">
98
+ <h4 className="text-sm font-medium text-bolt-elements-textSecondary mb-2">Branch Overview</h4>
99
+ <ul className="space-y-2 text-sm text-bolt-elements-textSecondary">
100
+ <li className="flex items-center gap-2">
101
+ <GitBranch className="text-lg" />
102
+ Repository: {repository.name}
103
+ </li>
104
+ {branchName && (
105
+ <li className="flex items-center gap-2">
106
+ <div className="i-ph:check-circle text-green-500" />
107
+ New branch will be created as: {branchName}
108
+ </li>
109
+ )}
110
+ <li className="flex items-center gap-2">
111
+ <div className="i-ph:check-circle text-green-500" />
112
+ Based on: {sourceBranch}
113
+ </li>
114
+ </ul>
115
+ </div>
116
+ </div>
117
+
118
+ <div className="mt-6 flex justify-end gap-3">
119
+ <button
120
+ type="button"
121
+ onClick={onClose}
122
+ className={classNames(
123
+ 'px-4 py-2 rounded-lg text-sm font-medium',
124
+ 'text-bolt-elements-textPrimary',
125
+ 'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
126
+ 'hover:bg-purple-500/10 hover:text-purple-500',
127
+ 'dark:hover:bg-purple-500/20 dark:hover:text-purple-500',
128
+ 'transition-colors',
129
+ )}
130
+ >
131
+ Cancel
132
+ </button>
133
+ <button
134
+ type="submit"
135
+ className={classNames(
136
+ 'px-4 py-2 rounded-lg text-sm font-medium',
137
+ 'text-white bg-purple-500',
138
+ 'hover:bg-purple-600',
139
+ 'transition-colors',
140
+ )}
141
+ >
142
+ Create Branch
143
+ </button>
144
+ </div>
145
+ </form>
146
+ </Dialog.Content>
147
+ </Dialog.Portal>
148
+ </Dialog.Root>
149
+ );
150
+ }
app/components/@settings/tabs/connections/components/PushToGitHubDialog.tsx ADDED
@@ -0,0 +1,528 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as Dialog from '@radix-ui/react-dialog';
2
+ import { useState, useEffect } from 'react';
3
+ import { toast } from 'react-toastify';
4
+ import { motion } from 'framer-motion';
5
+ import { getLocalStorage } from '~/lib/persistence';
6
+ import { classNames } from '~/utils/classNames';
7
+ import type { GitHubUserResponse } from '~/types/GitHub';
8
+ import { logStore } from '~/lib/stores/logs';
9
+ import { workbenchStore } from '~/lib/stores/workbench';
10
+ import { extractRelativePath } from '~/utils/diff';
11
+ import { formatSize } from '~/utils/formatSize';
12
+ import type { FileMap, File } from '~/lib/stores/files';
13
+ import { Octokit } from '@octokit/rest';
14
+
15
+ interface PushToGitHubDialogProps {
16
+ isOpen: boolean;
17
+ onClose: () => void;
18
+ onPush: (repoName: string, username?: string, token?: string, isPrivate?: boolean) => Promise<string>;
19
+ }
20
+
21
+ interface GitHubRepo {
22
+ name: string;
23
+ full_name: string;
24
+ html_url: string;
25
+ description: string;
26
+ stargazers_count: number;
27
+ forks_count: number;
28
+ default_branch: string;
29
+ updated_at: string;
30
+ language: string;
31
+ private: boolean;
32
+ }
33
+
34
+ export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDialogProps) {
35
+ const [repoName, setRepoName] = useState('');
36
+ const [isPrivate, setIsPrivate] = useState(false);
37
+ const [isLoading, setIsLoading] = useState(false);
38
+ const [user, setUser] = useState<GitHubUserResponse | null>(null);
39
+ const [recentRepos, setRecentRepos] = useState<GitHubRepo[]>([]);
40
+ const [isFetchingRepos, setIsFetchingRepos] = useState(false);
41
+ const [showSuccessDialog, setShowSuccessDialog] = useState(false);
42
+ const [createdRepoUrl, setCreatedRepoUrl] = useState('');
43
+ const [pushedFiles, setPushedFiles] = useState<{ path: string; size: number }[]>([]);
44
+
45
+ // Load GitHub connection on mount
46
+ useEffect(() => {
47
+ if (isOpen) {
48
+ const connection = getLocalStorage('github_connection');
49
+
50
+ if (connection?.user && connection?.token) {
51
+ setUser(connection.user);
52
+
53
+ // Only fetch if we have both user and token
54
+ if (connection.token.trim()) {
55
+ fetchRecentRepos(connection.token);
56
+ }
57
+ }
58
+ }
59
+ }, [isOpen]);
60
+
61
+ const fetchRecentRepos = async (token: string) => {
62
+ if (!token) {
63
+ logStore.logError('No GitHub token available');
64
+ toast.error('GitHub authentication required');
65
+
66
+ return;
67
+ }
68
+
69
+ try {
70
+ setIsFetchingRepos(true);
71
+
72
+ const response = await fetch(
73
+ 'https://api.github.com/user/repos?sort=updated&per_page=5&type=all&affiliation=owner,organization_member',
74
+ {
75
+ headers: {
76
+ Accept: 'application/vnd.github.v3+json',
77
+ Authorization: `Bearer ${token.trim()}`,
78
+ },
79
+ },
80
+ );
81
+
82
+ if (!response.ok) {
83
+ const errorData = await response.json().catch(() => ({}));
84
+
85
+ if (response.status === 401) {
86
+ toast.error('GitHub token expired. Please reconnect your account.');
87
+
88
+ // Clear invalid token
89
+ const connection = getLocalStorage('github_connection');
90
+
91
+ if (connection) {
92
+ localStorage.removeItem('github_connection');
93
+ setUser(null);
94
+ }
95
+ } else {
96
+ logStore.logError('Failed to fetch GitHub repositories', {
97
+ status: response.status,
98
+ statusText: response.statusText,
99
+ error: errorData,
100
+ });
101
+ toast.error(`Failed to fetch repositories: ${response.statusText}`);
102
+ }
103
+
104
+ return;
105
+ }
106
+
107
+ const repos = (await response.json()) as GitHubRepo[];
108
+ setRecentRepos(repos);
109
+ } catch (error) {
110
+ logStore.logError('Failed to fetch GitHub repositories', { error });
111
+ toast.error('Failed to fetch recent repositories');
112
+ } finally {
113
+ setIsFetchingRepos(false);
114
+ }
115
+ };
116
+
117
+ const handleSubmit = async (e: React.FormEvent) => {
118
+ e.preventDefault();
119
+
120
+ const connection = getLocalStorage('github_connection');
121
+
122
+ if (!connection?.token || !connection?.user) {
123
+ toast.error('Please connect your GitHub account in Settings > Connections first');
124
+ return;
125
+ }
126
+
127
+ if (!repoName.trim()) {
128
+ toast.error('Repository name is required');
129
+ return;
130
+ }
131
+
132
+ setIsLoading(true);
133
+
134
+ try {
135
+ // Check if repository exists first
136
+ const octokit = new Octokit({ auth: connection.token });
137
+
138
+ try {
139
+ await octokit.repos.get({
140
+ owner: connection.user.login,
141
+ repo: repoName,
142
+ });
143
+
144
+ // If we get here, the repo exists
145
+ const confirmOverwrite = window.confirm(
146
+ `Repository "${repoName}" already exists. Do you want to update it? This will add or modify files in the repository.`,
147
+ );
148
+
149
+ if (!confirmOverwrite) {
150
+ setIsLoading(false);
151
+ return;
152
+ }
153
+ } catch (error) {
154
+ // 404 means repo doesn't exist, which is what we want for new repos
155
+ if (error instanceof Error && 'status' in error && error.status !== 404) {
156
+ throw error;
157
+ }
158
+ }
159
+
160
+ const repoUrl = await onPush(repoName, connection.user.login, connection.token, isPrivate);
161
+ setCreatedRepoUrl(repoUrl);
162
+
163
+ // Get list of pushed files
164
+ const files = workbenchStore.files.get();
165
+ const filesList = Object.entries(files as FileMap)
166
+ .filter(([, dirent]) => dirent?.type === 'file' && !dirent.isBinary)
167
+ .map(([path, dirent]) => ({
168
+ path: extractRelativePath(path),
169
+ size: new TextEncoder().encode((dirent as File).content || '').length,
170
+ }));
171
+
172
+ setPushedFiles(filesList);
173
+ setShowSuccessDialog(true);
174
+ } catch (error) {
175
+ console.error('Error pushing to GitHub:', error);
176
+ toast.error('Failed to push to GitHub. Please check your repository name and try again.');
177
+ } finally {
178
+ setIsLoading(false);
179
+ }
180
+ };
181
+
182
+ const handleClose = () => {
183
+ setRepoName('');
184
+ setIsPrivate(false);
185
+ setShowSuccessDialog(false);
186
+ setCreatedRepoUrl('');
187
+ onClose();
188
+ };
189
+
190
+ // Success Dialog
191
+ if (showSuccessDialog) {
192
+ return (
193
+ <Dialog.Root open={isOpen} onOpenChange={(open) => !open && handleClose()}>
194
+ <Dialog.Portal>
195
+ <Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9999]" />
196
+ <div className="fixed inset-0 flex items-center justify-center z-[9999]">
197
+ <motion.div
198
+ initial={{ opacity: 0, scale: 0.95 }}
199
+ animate={{ opacity: 1, scale: 1 }}
200
+ exit={{ opacity: 0, scale: 0.95 }}
201
+ transition={{ duration: 0.2 }}
202
+ className="w-[90vw] md:w-[600px] max-h-[85vh] overflow-y-auto"
203
+ >
204
+ <Dialog.Content className="bg-white dark:bg-[#1E1E1E] rounded-lg border border-[#E5E5E5] dark:border-[#333333] shadow-xl">
205
+ <div className="p-6 space-y-4">
206
+ <div className="flex items-center justify-between">
207
+ <div className="flex items-center gap-2 text-green-500">
208
+ <div className="i-ph:check-circle w-5 h-5" />
209
+ <h3 className="text-lg font-medium">Successfully pushed to GitHub</h3>
210
+ </div>
211
+ <Dialog.Close
212
+ onClick={handleClose}
213
+ className="p-2 text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-400"
214
+ >
215
+ <div className="i-ph:x w-5 h-5" />
216
+ </Dialog.Close>
217
+ </div>
218
+
219
+ <div className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg p-3 text-left">
220
+ <p className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark mb-2">
221
+ Repository URL
222
+ </p>
223
+ <div className="flex items-center gap-2">
224
+ <code className="flex-1 text-sm bg-bolt-elements-background dark:bg-bolt-elements-background-dark px-3 py-2 rounded border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark font-mono">
225
+ {createdRepoUrl}
226
+ </code>
227
+ <motion.button
228
+ onClick={() => {
229
+ navigator.clipboard.writeText(createdRepoUrl);
230
+ toast.success('URL copied to clipboard');
231
+ }}
232
+ className="p-2 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary dark:text-bolt-elements-textSecondary-dark dark:hover:text-bolt-elements-textPrimary-dark"
233
+ whileHover={{ scale: 1.1 }}
234
+ whileTap={{ scale: 0.9 }}
235
+ >
236
+ <div className="i-ph:copy w-4 h-4" />
237
+ </motion.button>
238
+ </div>
239
+ </div>
240
+
241
+ <div className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg p-3">
242
+ <p className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark mb-2">
243
+ Pushed Files ({pushedFiles.length})
244
+ </p>
245
+ <div className="max-h-[200px] overflow-y-auto custom-scrollbar">
246
+ {pushedFiles.map((file) => (
247
+ <div
248
+ key={file.path}
249
+ className="flex items-center justify-between py-1 text-sm text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark"
250
+ >
251
+ <span className="font-mono truncate flex-1">{file.path}</span>
252
+ <span className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark ml-2">
253
+ {formatSize(file.size)}
254
+ </span>
255
+ </div>
256
+ ))}
257
+ </div>
258
+ </div>
259
+
260
+ <div className="flex justify-end gap-2 pt-2">
261
+ <motion.a
262
+ href={createdRepoUrl}
263
+ target="_blank"
264
+ rel="noopener noreferrer"
265
+ className="px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 text-sm inline-flex items-center gap-2"
266
+ whileHover={{ scale: 1.02 }}
267
+ whileTap={{ scale: 0.98 }}
268
+ >
269
+ <div className="i-ph:github-logo w-4 h-4" />
270
+ View Repository
271
+ </motion.a>
272
+ <motion.button
273
+ onClick={() => {
274
+ navigator.clipboard.writeText(createdRepoUrl);
275
+ toast.success('URL copied to clipboard');
276
+ }}
277
+ className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] text-gray-600 dark:text-gray-400 hover:bg-[#E5E5E5] dark:hover:bg-[#252525] text-sm inline-flex items-center gap-2"
278
+ whileHover={{ scale: 1.02 }}
279
+ whileTap={{ scale: 0.98 }}
280
+ >
281
+ <div className="i-ph:copy w-4 h-4" />
282
+ Copy URL
283
+ </motion.button>
284
+ <motion.button
285
+ onClick={handleClose}
286
+ className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] text-gray-600 dark:text-gray-400 hover:bg-[#E5E5E5] dark:hover:bg-[#252525] text-sm"
287
+ whileHover={{ scale: 1.02 }}
288
+ whileTap={{ scale: 0.98 }}
289
+ >
290
+ Close
291
+ </motion.button>
292
+ </div>
293
+ </div>
294
+ </Dialog.Content>
295
+ </motion.div>
296
+ </div>
297
+ </Dialog.Portal>
298
+ </Dialog.Root>
299
+ );
300
+ }
301
+
302
+ if (!user) {
303
+ return (
304
+ <Dialog.Root open={isOpen} onOpenChange={(open) => !open && handleClose()}>
305
+ <Dialog.Portal>
306
+ <Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9999]" />
307
+ <div className="fixed inset-0 flex items-center justify-center z-[9999]">
308
+ <motion.div
309
+ initial={{ opacity: 0, scale: 0.95 }}
310
+ animate={{ opacity: 1, scale: 1 }}
311
+ exit={{ opacity: 0, scale: 0.95 }}
312
+ transition={{ duration: 0.2 }}
313
+ className="w-[90vw] md:w-[500px]"
314
+ >
315
+ <Dialog.Content className="bg-white dark:bg-[#0A0A0A] rounded-lg p-6 border border-[#E5E5E5] dark:border-[#1A1A1A] shadow-xl">
316
+ <div className="text-center space-y-4">
317
+ <motion.div
318
+ initial={{ scale: 0.8 }}
319
+ animate={{ scale: 1 }}
320
+ transition={{ delay: 0.1 }}
321
+ className="mx-auto w-12 h-12 rounded-xl bg-bolt-elements-background-depth-3 flex items-center justify-center text-purple-500"
322
+ >
323
+ <div className="i-ph:github-logo w-6 h-6" />
324
+ </motion.div>
325
+ <h3 className="text-lg font-medium text-gray-900 dark:text-white">GitHub Connection Required</h3>
326
+ <p className="text-sm text-gray-600 dark:text-gray-400">
327
+ Please connect your GitHub account in Settings {'>'} Connections to push your code to GitHub.
328
+ </p>
329
+ <motion.button
330
+ className="px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600 inline-flex items-center gap-2"
331
+ whileHover={{ scale: 1.02 }}
332
+ whileTap={{ scale: 0.98 }}
333
+ onClick={handleClose}
334
+ >
335
+ <div className="i-ph:x-circle" />
336
+ Close
337
+ </motion.button>
338
+ </div>
339
+ </Dialog.Content>
340
+ </motion.div>
341
+ </div>
342
+ </Dialog.Portal>
343
+ </Dialog.Root>
344
+ );
345
+ }
346
+
347
+ return (
348
+ <Dialog.Root open={isOpen} onOpenChange={(open) => !open && handleClose()}>
349
+ <Dialog.Portal>
350
+ <Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9999]" />
351
+ <div className="fixed inset-0 flex items-center justify-center z-[9999]">
352
+ <motion.div
353
+ initial={{ opacity: 0, scale: 0.95 }}
354
+ animate={{ opacity: 1, scale: 1 }}
355
+ exit={{ opacity: 0, scale: 0.95 }}
356
+ transition={{ duration: 0.2 }}
357
+ className="w-[90vw] md:w-[500px]"
358
+ >
359
+ <Dialog.Content className="bg-white dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A] shadow-xl">
360
+ <div className="p-6">
361
+ <div className="flex items-center gap-4 mb-6">
362
+ <motion.div
363
+ initial={{ scale: 0.8 }}
364
+ animate={{ scale: 1 }}
365
+ transition={{ delay: 0.1 }}
366
+ className="w-10 h-10 rounded-xl bg-bolt-elements-background-depth-3 flex items-center justify-center text-purple-500"
367
+ >
368
+ <div className="i-ph:git-branch w-5 h-5" />
369
+ </motion.div>
370
+ <div>
371
+ <Dialog.Title className="text-lg font-medium text-gray-900 dark:text-white">
372
+ Push to GitHub
373
+ </Dialog.Title>
374
+ <p className="text-sm text-gray-600 dark:text-gray-400">
375
+ Push your code to a new or existing GitHub repository
376
+ </p>
377
+ </div>
378
+ <Dialog.Close
379
+ className="ml-auto p-2 text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-400"
380
+ onClick={handleClose}
381
+ >
382
+ <div className="i-ph:x w-5 h-5" />
383
+ </Dialog.Close>
384
+ </div>
385
+
386
+ <div className="flex items-center gap-3 mb-6 p-3 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 rounded-lg">
387
+ <img src={user.avatar_url} alt={user.login} className="w-10 h-10 rounded-full" />
388
+ <div>
389
+ <p className="text-sm font-medium text-gray-900 dark:text-white">{user.name || user.login}</p>
390
+ <p className="text-sm text-gray-500 dark:text-gray-400">@{user.login}</p>
391
+ </div>
392
+ </div>
393
+
394
+ <form onSubmit={handleSubmit} className="space-y-4">
395
+ <div className="space-y-2">
396
+ <label htmlFor="repoName" className="text-sm text-gray-600 dark:text-gray-400">
397
+ Repository Name
398
+ </label>
399
+ <input
400
+ id="repoName"
401
+ type="text"
402
+ value={repoName}
403
+ onChange={(e) => setRepoName(e.target.value)}
404
+ placeholder="my-awesome-project"
405
+ className="w-full px-4 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 border border-[#E5E5E5] dark:border-[#1A1A1A] text-gray-900 dark:text-white placeholder-gray-400"
406
+ required
407
+ />
408
+ </div>
409
+
410
+ {recentRepos.length > 0 && (
411
+ <div className="space-y-2">
412
+ <label className="text-sm text-gray-600 dark:text-gray-400">Recent Repositories</label>
413
+ <div className="space-y-2">
414
+ {recentRepos.map((repo) => (
415
+ <motion.button
416
+ key={repo.full_name}
417
+ type="button"
418
+ onClick={() => setRepoName(repo.name)}
419
+ className="w-full p-3 text-left rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 transition-colors group"
420
+ whileHover={{ scale: 1.01 }}
421
+ whileTap={{ scale: 0.99 }}
422
+ >
423
+ <div className="flex items-center justify-between">
424
+ <div className="flex items-center gap-2">
425
+ <div className="i-ph:git-repository w-4 h-4 text-purple-500" />
426
+ <span className="text-sm font-medium text-gray-900 dark:text-white group-hover:text-purple-500">
427
+ {repo.name}
428
+ </span>
429
+ </div>
430
+ {repo.private && (
431
+ <span className="text-xs px-2 py-1 rounded-full bg-purple-500/10 text-purple-500">
432
+ Private
433
+ </span>
434
+ )}
435
+ </div>
436
+ {repo.description && (
437
+ <p className="mt-1 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
438
+ {repo.description}
439
+ </p>
440
+ )}
441
+ <div className="mt-2 flex items-center gap-3 text-xs text-gray-400 dark:text-gray-500">
442
+ {repo.language && (
443
+ <span className="flex items-center gap-1">
444
+ <div className="i-ph:code w-3 h-3" />
445
+ {repo.language}
446
+ </span>
447
+ )}
448
+ <span className="flex items-center gap-1">
449
+ <div className="i-ph:star w-3 h-3" />
450
+ {repo.stargazers_count.toLocaleString()}
451
+ </span>
452
+ <span className="flex items-center gap-1">
453
+ <div className="i-ph:git-fork w-3 h-3" />
454
+ {repo.forks_count.toLocaleString()}
455
+ </span>
456
+ <span className="flex items-center gap-1">
457
+ <div className="i-ph:clock w-3 h-3" />
458
+ {new Date(repo.updated_at).toLocaleDateString()}
459
+ </span>
460
+ </div>
461
+ </motion.button>
462
+ ))}
463
+ </div>
464
+ </div>
465
+ )}
466
+
467
+ {isFetchingRepos && (
468
+ <div className="flex items-center justify-center py-4 text-gray-500 dark:text-gray-400">
469
+ <div className="i-ph:spinner-gap-bold animate-spin w-4 h-4 mr-2" />
470
+ Loading repositories...
471
+ </div>
472
+ )}
473
+
474
+ <div className="flex items-center gap-2">
475
+ <input
476
+ type="checkbox"
477
+ id="private"
478
+ checked={isPrivate}
479
+ onChange={(e) => setIsPrivate(e.target.checked)}
480
+ className="rounded border-[#E5E5E5] dark:border-[#1A1A1A] text-purple-500 focus:ring-purple-500 dark:bg-[#0A0A0A]"
481
+ />
482
+ <label htmlFor="private" className="text-sm text-gray-600 dark:text-gray-400">
483
+ Make repository private
484
+ </label>
485
+ </div>
486
+
487
+ <div className="pt-4 flex gap-2">
488
+ <motion.button
489
+ type="button"
490
+ onClick={handleClose}
491
+ className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] text-gray-600 dark:text-gray-400 hover:bg-[#E5E5E5] dark:hover:bg-[#252525] text-sm"
492
+ whileHover={{ scale: 1.02 }}
493
+ whileTap={{ scale: 0.98 }}
494
+ >
495
+ Cancel
496
+ </motion.button>
497
+ <motion.button
498
+ type="submit"
499
+ disabled={isLoading}
500
+ className={classNames(
501
+ 'flex-1 px-4 py-2 bg-purple-500 text-white rounded-lg hover:bg-purple-600 text-sm inline-flex items-center justify-center gap-2',
502
+ isLoading ? 'opacity-50 cursor-not-allowed' : '',
503
+ )}
504
+ whileHover={!isLoading ? { scale: 1.02 } : {}}
505
+ whileTap={!isLoading ? { scale: 0.98 } : {}}
506
+ >
507
+ {isLoading ? (
508
+ <>
509
+ <div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
510
+ Pushing...
511
+ </>
512
+ ) : (
513
+ <>
514
+ <div className="i-ph:git-branch w-4 h-4" />
515
+ Push to GitHub
516
+ </>
517
+ )}
518
+ </motion.button>
519
+ </div>
520
+ </form>
521
+ </div>
522
+ </Dialog.Content>
523
+ </motion.div>
524
+ </div>
525
+ </Dialog.Portal>
526
+ </Dialog.Root>
527
+ );
528
+ }
app/components/@settings/tabs/connections/components/RepositorySelectionDialog.tsx ADDED
@@ -0,0 +1,693 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { GitHubRepoInfo, GitHubContent, RepositoryStats } from '~/types/GitHub';
2
+ import { useState, useEffect } from 'react';
3
+ import { toast } from 'react-toastify';
4
+ import * as Dialog from '@radix-ui/react-dialog';
5
+ import { classNames } from '~/utils/classNames';
6
+ import { getLocalStorage } from '~/lib/persistence';
7
+ import { motion } from 'framer-motion';
8
+ import { formatSize } from '~/utils/formatSize';
9
+ import { Input } from '~/components/ui/Input';
10
+
11
+ interface GitHubTreeResponse {
12
+ tree: Array<{
13
+ path: string;
14
+ type: string;
15
+ size?: number;
16
+ }>;
17
+ }
18
+
19
+ interface RepositorySelectionDialogProps {
20
+ isOpen: boolean;
21
+ onClose: () => void;
22
+ onSelect: (url: string) => void;
23
+ }
24
+
25
+ interface SearchFilters {
26
+ language?: string;
27
+ stars?: number;
28
+ forks?: number;
29
+ }
30
+
31
+ interface StatsDialogProps {
32
+ isOpen: boolean;
33
+ onClose: () => void;
34
+ onConfirm: () => void;
35
+ stats: RepositoryStats;
36
+ isLargeRepo?: boolean;
37
+ }
38
+
39
+ function StatsDialog({ isOpen, onClose, onConfirm, stats, isLargeRepo }: StatsDialogProps) {
40
+ return (
41
+ <Dialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
42
+ <Dialog.Portal>
43
+ <Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9999]" />
44
+ <div className="fixed inset-0 flex items-center justify-center z-[9999]">
45
+ <motion.div
46
+ initial={{ opacity: 0, scale: 0.95 }}
47
+ animate={{ opacity: 1, scale: 1 }}
48
+ exit={{ opacity: 0, scale: 0.95 }}
49
+ transition={{ duration: 0.2 }}
50
+ className="w-[90vw] md:w-[500px]"
51
+ >
52
+ <Dialog.Content className="bg-white dark:bg-[#1E1E1E] rounded-lg border border-[#E5E5E5] dark:border-[#333333] shadow-xl">
53
+ <div className="p-6 space-y-4">
54
+ <div>
55
+ <h3 className="text-lg font-medium text-[#111111] dark:text-white">Repository Overview</h3>
56
+ <div className="mt-4 space-y-2">
57
+ <p className="text-sm text-[#666666] dark:text-[#999999]">Repository Statistics:</p>
58
+ <div className="space-y-2 text-sm text-[#111111] dark:text-white">
59
+ <div className="flex items-center gap-2">
60
+ <span className="i-ph:files text-purple-500 w-4 h-4" />
61
+ <span>Total Files: {stats.totalFiles}</span>
62
+ </div>
63
+ <div className="flex items-center gap-2">
64
+ <span className="i-ph:database text-purple-500 w-4 h-4" />
65
+ <span>Total Size: {formatSize(stats.totalSize)}</span>
66
+ </div>
67
+ <div className="flex items-center gap-2">
68
+ <span className="i-ph:code text-purple-500 w-4 h-4" />
69
+ <span>
70
+ Languages:{' '}
71
+ {Object.entries(stats.languages)
72
+ .sort(([, a], [, b]) => b - a)
73
+ .slice(0, 3)
74
+ .map(([lang, size]) => `${lang} (${formatSize(size)})`)
75
+ .join(', ')}
76
+ </span>
77
+ </div>
78
+ {stats.hasPackageJson && (
79
+ <div className="flex items-center gap-2">
80
+ <span className="i-ph:package text-purple-500 w-4 h-4" />
81
+ <span>Has package.json</span>
82
+ </div>
83
+ )}
84
+ {stats.hasDependencies && (
85
+ <div className="flex items-center gap-2">
86
+ <span className="i-ph:tree-structure text-purple-500 w-4 h-4" />
87
+ <span>Has dependencies</span>
88
+ </div>
89
+ )}
90
+ </div>
91
+ </div>
92
+ {isLargeRepo && (
93
+ <div className="mt-4 p-3 bg-yellow-50 dark:bg-yellow-500/10 rounded-lg text-sm flex items-start gap-2">
94
+ <span className="i-ph:warning text-yellow-600 dark:text-yellow-500 w-4 h-4 flex-shrink-0 mt-0.5" />
95
+ <div className="text-yellow-800 dark:text-yellow-500">
96
+ This repository is quite large ({formatSize(stats.totalSize)}). Importing it might take a while
97
+ and could impact performance.
98
+ </div>
99
+ </div>
100
+ )}
101
+ </div>
102
+ </div>
103
+ <div className="border-t border-[#E5E5E5] dark:border-[#333333] p-4 flex justify-end gap-3 bg-[#F9F9F9] dark:bg-[#252525] rounded-b-lg">
104
+ <button
105
+ onClick={onClose}
106
+ className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#333333] text-[#666666] hover:text-[#111111] dark:text-[#999999] dark:hover:text-white transition-colors"
107
+ >
108
+ Cancel
109
+ </button>
110
+ <button
111
+ onClick={onConfirm}
112
+ className="px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-colors"
113
+ >
114
+ OK
115
+ </button>
116
+ </div>
117
+ </Dialog.Content>
118
+ </motion.div>
119
+ </div>
120
+ </Dialog.Portal>
121
+ </Dialog.Root>
122
+ );
123
+ }
124
+
125
+ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: RepositorySelectionDialogProps) {
126
+ const [selectedRepository, setSelectedRepository] = useState<GitHubRepoInfo | null>(null);
127
+ const [isLoading, setIsLoading] = useState(false);
128
+ const [repositories, setRepositories] = useState<GitHubRepoInfo[]>([]);
129
+ const [searchQuery, setSearchQuery] = useState('');
130
+ const [searchResults, setSearchResults] = useState<GitHubRepoInfo[]>([]);
131
+ const [activeTab, setActiveTab] = useState<'my-repos' | 'search' | 'url'>('my-repos');
132
+ const [customUrl, setCustomUrl] = useState('');
133
+ const [branches, setBranches] = useState<{ name: string; default?: boolean }[]>([]);
134
+ const [selectedBranch, setSelectedBranch] = useState('');
135
+ const [filters, setFilters] = useState<SearchFilters>({});
136
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
137
+ const [stats, setStats] = useState<RepositoryStats | null>(null);
138
+ const [showStatsDialog, setShowStatsDialog] = useState(false);
139
+ const [currentStats, setCurrentStats] = useState<RepositoryStats | null>(null);
140
+ const [pendingGitUrl, setPendingGitUrl] = useState<string>('');
141
+
142
+ // Fetch user's repositories when dialog opens
143
+ useEffect(() => {
144
+ if (isOpen && activeTab === 'my-repos') {
145
+ fetchUserRepos();
146
+ }
147
+ }, [isOpen, activeTab]);
148
+
149
+ const fetchUserRepos = async () => {
150
+ const connection = getLocalStorage('github_connection');
151
+
152
+ if (!connection?.token) {
153
+ toast.error('Please connect your GitHub account first');
154
+ return;
155
+ }
156
+
157
+ setIsLoading(true);
158
+
159
+ try {
160
+ const response = await fetch('https://api.github.com/user/repos?sort=updated&per_page=100&type=all', {
161
+ headers: {
162
+ Authorization: `Bearer ${connection.token}`,
163
+ },
164
+ });
165
+
166
+ if (!response.ok) {
167
+ throw new Error('Failed to fetch repositories');
168
+ }
169
+
170
+ const data = await response.json();
171
+
172
+ // Add type assertion and validation
173
+ if (
174
+ Array.isArray(data) &&
175
+ data.every((item) => typeof item === 'object' && item !== null && 'full_name' in item)
176
+ ) {
177
+ setRepositories(data as GitHubRepoInfo[]);
178
+ } else {
179
+ throw new Error('Invalid repository data format');
180
+ }
181
+ } catch (error) {
182
+ console.error('Error fetching repos:', error);
183
+ toast.error('Failed to fetch your repositories');
184
+ } finally {
185
+ setIsLoading(false);
186
+ }
187
+ };
188
+
189
+ const handleSearch = async (query: string) => {
190
+ setIsLoading(true);
191
+ setSearchResults([]);
192
+
193
+ try {
194
+ let searchQuery = query;
195
+
196
+ if (filters.language) {
197
+ searchQuery += ` language:${filters.language}`;
198
+ }
199
+
200
+ if (filters.stars) {
201
+ searchQuery += ` stars:>${filters.stars}`;
202
+ }
203
+
204
+ if (filters.forks) {
205
+ searchQuery += ` forks:>${filters.forks}`;
206
+ }
207
+
208
+ const response = await fetch(
209
+ `https://api.github.com/search/repositories?q=${encodeURIComponent(searchQuery)}&sort=stars&order=desc`,
210
+ {
211
+ headers: {
212
+ Accept: 'application/vnd.github.v3+json',
213
+ },
214
+ },
215
+ );
216
+
217
+ if (!response.ok) {
218
+ throw new Error('Failed to search repositories');
219
+ }
220
+
221
+ const data = await response.json();
222
+
223
+ // Add type assertion and validation
224
+ if (typeof data === 'object' && data !== null && 'items' in data && Array.isArray(data.items)) {
225
+ setSearchResults(data.items as GitHubRepoInfo[]);
226
+ } else {
227
+ throw new Error('Invalid search results format');
228
+ }
229
+ } catch (error) {
230
+ console.error('Error searching repos:', error);
231
+ toast.error('Failed to search repositories');
232
+ } finally {
233
+ setIsLoading(false);
234
+ }
235
+ };
236
+
237
+ const fetchBranches = async (repo: GitHubRepoInfo) => {
238
+ setIsLoading(true);
239
+
240
+ try {
241
+ const response = await fetch(`https://api.github.com/repos/${repo.full_name}/branches`, {
242
+ headers: {
243
+ Authorization: `Bearer ${getLocalStorage('github_connection')?.token}`,
244
+ },
245
+ });
246
+
247
+ if (!response.ok) {
248
+ throw new Error('Failed to fetch branches');
249
+ }
250
+
251
+ const data = await response.json();
252
+
253
+ // Add type assertion and validation
254
+ if (Array.isArray(data) && data.every((item) => typeof item === 'object' && item !== null && 'name' in item)) {
255
+ setBranches(
256
+ data.map((branch) => ({
257
+ name: branch.name,
258
+ default: branch.name === repo.default_branch,
259
+ })),
260
+ );
261
+ } else {
262
+ throw new Error('Invalid branch data format');
263
+ }
264
+ } catch (error) {
265
+ console.error('Error fetching branches:', error);
266
+ toast.error('Failed to fetch branches');
267
+ } finally {
268
+ setIsLoading(false);
269
+ }
270
+ };
271
+
272
+ const handleRepoSelect = async (repo: GitHubRepoInfo) => {
273
+ setSelectedRepository(repo);
274
+ await fetchBranches(repo);
275
+ };
276
+
277
+ const formatGitUrl = (url: string): string => {
278
+ // Remove any tree references and ensure .git extension
279
+ const baseUrl = url
280
+ .replace(/\/tree\/[^/]+/, '') // Remove /tree/branch-name
281
+ .replace(/\/$/, '') // Remove trailing slash
282
+ .replace(/\.git$/, ''); // Remove .git if present
283
+ return `${baseUrl}.git`;
284
+ };
285
+
286
+ const verifyRepository = async (repoUrl: string): Promise<RepositoryStats | null> => {
287
+ try {
288
+ const [owner, repo] = repoUrl
289
+ .replace(/\.git$/, '')
290
+ .split('/')
291
+ .slice(-2);
292
+
293
+ const connection = getLocalStorage('github_connection');
294
+ const headers: HeadersInit = connection?.token ? { Authorization: `Bearer ${connection.token}` } : {};
295
+
296
+ // Fetch repository tree
297
+ const treeResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/main?recursive=1`, {
298
+ headers,
299
+ });
300
+
301
+ if (!treeResponse.ok) {
302
+ throw new Error('Failed to fetch repository structure');
303
+ }
304
+
305
+ const treeData = (await treeResponse.json()) as GitHubTreeResponse;
306
+
307
+ // Calculate repository stats
308
+ let totalSize = 0;
309
+ let totalFiles = 0;
310
+ const languages: { [key: string]: number } = {};
311
+ let hasPackageJson = false;
312
+ let hasDependencies = false;
313
+
314
+ for (const file of treeData.tree) {
315
+ if (file.type === 'blob') {
316
+ totalFiles++;
317
+
318
+ if (file.size) {
319
+ totalSize += file.size;
320
+ }
321
+
322
+ // Check for package.json
323
+ if (file.path === 'package.json') {
324
+ hasPackageJson = true;
325
+
326
+ // Fetch package.json content to check dependencies
327
+ const contentResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/package.json`, {
328
+ headers,
329
+ });
330
+
331
+ if (contentResponse.ok) {
332
+ const content = (await contentResponse.json()) as GitHubContent;
333
+ const packageJson = JSON.parse(Buffer.from(content.content, 'base64').toString());
334
+ hasDependencies = !!(
335
+ packageJson.dependencies ||
336
+ packageJson.devDependencies ||
337
+ packageJson.peerDependencies
338
+ );
339
+ }
340
+ }
341
+
342
+ // Detect language based on file extension
343
+ const ext = file.path.split('.').pop()?.toLowerCase();
344
+
345
+ if (ext) {
346
+ languages[ext] = (languages[ext] || 0) + (file.size || 0);
347
+ }
348
+ }
349
+ }
350
+
351
+ const stats: RepositoryStats = {
352
+ totalFiles,
353
+ totalSize,
354
+ languages,
355
+ hasPackageJson,
356
+ hasDependencies,
357
+ };
358
+
359
+ setStats(stats);
360
+
361
+ return stats;
362
+ } catch (error) {
363
+ console.error('Error verifying repository:', error);
364
+ toast.error('Failed to verify repository');
365
+
366
+ return null;
367
+ }
368
+ };
369
+
370
+ const handleImport = async () => {
371
+ try {
372
+ let gitUrl: string;
373
+
374
+ if (activeTab === 'url' && customUrl) {
375
+ gitUrl = formatGitUrl(customUrl);
376
+ } else if (selectedRepository) {
377
+ gitUrl = formatGitUrl(selectedRepository.html_url);
378
+
379
+ if (selectedBranch) {
380
+ gitUrl = `${gitUrl}#${selectedBranch}`;
381
+ }
382
+ } else {
383
+ return;
384
+ }
385
+
386
+ // Verify repository before importing
387
+ const stats = await verifyRepository(gitUrl);
388
+
389
+ if (!stats) {
390
+ return;
391
+ }
392
+
393
+ setCurrentStats(stats);
394
+ setPendingGitUrl(gitUrl);
395
+ setShowStatsDialog(true);
396
+ } catch (error) {
397
+ console.error('Error preparing repository:', error);
398
+ toast.error('Failed to prepare repository. Please try again.');
399
+ }
400
+ };
401
+
402
+ const handleStatsConfirm = () => {
403
+ setShowStatsDialog(false);
404
+
405
+ if (pendingGitUrl) {
406
+ onSelect(pendingGitUrl);
407
+ onClose();
408
+ }
409
+ };
410
+
411
+ const handleFilterChange = (key: keyof SearchFilters, value: string) => {
412
+ let parsedValue: string | number | undefined = value;
413
+
414
+ if (key === 'stars' || key === 'forks') {
415
+ parsedValue = value ? parseInt(value, 10) : undefined;
416
+ }
417
+
418
+ setFilters((prev) => ({ ...prev, [key]: parsedValue }));
419
+ handleSearch(searchQuery);
420
+ };
421
+
422
+ // Handle dialog close properly
423
+ const handleClose = () => {
424
+ setIsLoading(false); // Reset loading state
425
+ setSearchQuery(''); // Reset search
426
+ setSearchResults([]); // Reset results
427
+ onClose();
428
+ };
429
+
430
+ return (
431
+ <Dialog.Root
432
+ open={isOpen}
433
+ onOpenChange={(open) => {
434
+ if (!open) {
435
+ handleClose();
436
+ }
437
+ }}
438
+ >
439
+ <Dialog.Portal>
440
+ <Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50" />
441
+ <Dialog.Content className="fixed top-[50%] left-[50%] -translate-x-1/2 -translate-y-1/2 w-[90vw] md:w-[600px] max-h-[85vh] overflow-hidden bg-white dark:bg-[#1A1A1A] rounded-xl shadow-xl z-[51] border border-[#E5E5E5] dark:border-[#333333]">
442
+ <div className="p-4 border-b border-[#E5E5E5] dark:border-[#333333] flex items-center justify-between">
443
+ <Dialog.Title className="text-lg font-semibold text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
444
+ Import GitHub Repository
445
+ </Dialog.Title>
446
+ <Dialog.Close
447
+ onClick={handleClose}
448
+ className={classNames(
449
+ 'p-2 rounded-lg transition-all duration-200 ease-in-out',
450
+ 'text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary',
451
+ 'dark:text-bolt-elements-textTertiary-dark dark:hover:text-bolt-elements-textPrimary-dark',
452
+ 'hover:bg-bolt-elements-background-depth-2 dark:hover:bg-bolt-elements-background-depth-3',
453
+ 'focus:outline-none focus:ring-2 focus:ring-bolt-elements-borderColor dark:focus:ring-bolt-elements-borderColor-dark',
454
+ )}
455
+ >
456
+ <span className="i-ph:x block w-5 h-5" aria-hidden="true" />
457
+ <span className="sr-only">Close dialog</span>
458
+ </Dialog.Close>
459
+ </div>
460
+
461
+ <div className="p-4">
462
+ <div className="flex gap-2 mb-4">
463
+ <TabButton active={activeTab === 'my-repos'} onClick={() => setActiveTab('my-repos')}>
464
+ <span className="i-ph:book-bookmark" />
465
+ My Repos
466
+ </TabButton>
467
+ <TabButton active={activeTab === 'search'} onClick={() => setActiveTab('search')}>
468
+ <span className="i-ph:magnifying-glass" />
469
+ Search
470
+ </TabButton>
471
+ <TabButton active={activeTab === 'url'} onClick={() => setActiveTab('url')}>
472
+ <span className="i-ph:link" />
473
+ URL
474
+ </TabButton>
475
+ </div>
476
+
477
+ {activeTab === 'url' ? (
478
+ <div className="space-y-4">
479
+ <Input
480
+ placeholder="Enter repository URL"
481
+ value={customUrl}
482
+ onChange={(e) => setCustomUrl(e.target.value)}
483
+ className={classNames('w-full', {
484
+ 'border-red-500': false,
485
+ })}
486
+ />
487
+ <button
488
+ onClick={handleImport}
489
+ disabled={!customUrl}
490
+ className="w-full h-10 px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 flex items-center gap-2 justify-center"
491
+ >
492
+ Import Repository
493
+ </button>
494
+ </div>
495
+ ) : (
496
+ <>
497
+ {activeTab === 'search' && (
498
+ <div className="space-y-4 mb-4">
499
+ <div className="flex gap-2">
500
+ <input
501
+ type="text"
502
+ placeholder="Search repositories..."
503
+ value={searchQuery}
504
+ onChange={(e) => {
505
+ setSearchQuery(e.target.value);
506
+ handleSearch(e.target.value);
507
+ }}
508
+ className="flex-1 px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333] text-bolt-elements-textPrimary"
509
+ />
510
+ <button
511
+ onClick={() => setFilters({})}
512
+ className="px-3 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#252525] text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary"
513
+ >
514
+ <span className="i-ph:funnel-simple" />
515
+ </button>
516
+ </div>
517
+ <div className="grid grid-cols-2 gap-2">
518
+ <input
519
+ type="text"
520
+ placeholder="Filter by language..."
521
+ value={filters.language || ''}
522
+ onChange={(e) => {
523
+ setFilters({ ...filters, language: e.target.value });
524
+ handleSearch(searchQuery);
525
+ }}
526
+ className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]"
527
+ />
528
+ <input
529
+ type="number"
530
+ placeholder="Min stars..."
531
+ value={filters.stars || ''}
532
+ onChange={(e) => handleFilterChange('stars', e.target.value)}
533
+ className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]"
534
+ />
535
+ </div>
536
+ <input
537
+ type="number"
538
+ placeholder="Min forks..."
539
+ value={filters.forks || ''}
540
+ onChange={(e) => handleFilterChange('forks', e.target.value)}
541
+ className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]"
542
+ />
543
+ </div>
544
+ )}
545
+
546
+ <div className="space-y-3 max-h-[400px] overflow-y-auto pr-2 custom-scrollbar">
547
+ {selectedRepository ? (
548
+ <div className="space-y-4">
549
+ <div className="flex items-center gap-2">
550
+ <button
551
+ onClick={() => setSelectedRepository(null)}
552
+ className="p-1.5 rounded-lg hover:bg-[#F5F5F5] dark:hover:bg-[#252525]"
553
+ >
554
+ <span className="i-ph:arrow-left w-4 h-4" />
555
+ </button>
556
+ <h3 className="font-medium">{selectedRepository.full_name}</h3>
557
+ </div>
558
+ <div className="space-y-2">
559
+ <label className="text-sm text-bolt-elements-textSecondary">Select Branch</label>
560
+ <select
561
+ value={selectedBranch}
562
+ onChange={(e) => setSelectedBranch(e.target.value)}
563
+ className="w-full px-3 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark focus:outline-none focus:ring-2 focus:ring-bolt-elements-borderColor dark:focus:ring-bolt-elements-borderColor-dark"
564
+ >
565
+ {branches.map((branch) => (
566
+ <option
567
+ key={branch.name}
568
+ value={branch.name}
569
+ className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark"
570
+ >
571
+ {branch.name} {branch.default ? '(default)' : ''}
572
+ </option>
573
+ ))}
574
+ </select>
575
+ <button
576
+ onClick={handleImport}
577
+ className="w-full h-10 px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-all duration-200 flex items-center gap-2 justify-center"
578
+ >
579
+ Import Selected Branch
580
+ </button>
581
+ </div>
582
+ </div>
583
+ ) : (
584
+ <RepositoryList
585
+ repos={activeTab === 'my-repos' ? repositories : searchResults}
586
+ isLoading={isLoading}
587
+ onSelect={handleRepoSelect}
588
+ activeTab={activeTab}
589
+ />
590
+ )}
591
+ </div>
592
+ </>
593
+ )}
594
+ </div>
595
+ </Dialog.Content>
596
+ </Dialog.Portal>
597
+ {currentStats && (
598
+ <StatsDialog
599
+ isOpen={showStatsDialog}
600
+ onClose={handleStatsConfirm}
601
+ onConfirm={handleStatsConfirm}
602
+ stats={currentStats}
603
+ isLargeRepo={currentStats.totalSize > 50 * 1024 * 1024}
604
+ />
605
+ )}
606
+ </Dialog.Root>
607
+ );
608
+ }
609
+
610
+ function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
611
+ return (
612
+ <button
613
+ onClick={onClick}
614
+ className={classNames(
615
+ 'px-4 py-2 h-10 rounded-lg transition-all duration-200 flex items-center gap-2 min-w-[120px] justify-center',
616
+ active
617
+ ? 'bg-purple-500 text-white hover:bg-purple-600'
618
+ : 'bg-[#F5F5F5] dark:bg-[#252525] text-bolt-elements-textPrimary dark:text-white hover:bg-[#E5E5E5] dark:hover:bg-[#333333] border border-[#E5E5E5] dark:border-[#333333]',
619
+ )}
620
+ >
621
+ {children}
622
+ </button>
623
+ );
624
+ }
625
+
626
+ function RepositoryList({
627
+ repos,
628
+ isLoading,
629
+ onSelect,
630
+ activeTab,
631
+ }: {
632
+ repos: GitHubRepoInfo[];
633
+ isLoading: boolean;
634
+ onSelect: (repo: GitHubRepoInfo) => void;
635
+ activeTab: string;
636
+ }) {
637
+ if (isLoading) {
638
+ return (
639
+ <div className="flex items-center justify-center py-8 text-bolt-elements-textSecondary">
640
+ <span className="i-ph:spinner animate-spin mr-2" />
641
+ Loading repositories...
642
+ </div>
643
+ );
644
+ }
645
+
646
+ if (repos.length === 0) {
647
+ return (
648
+ <div className="flex flex-col items-center justify-center py-8 text-bolt-elements-textSecondary">
649
+ <span className="i-ph:folder-simple-dashed w-12 h-12 mb-2 opacity-50" />
650
+ <p>{activeTab === 'my-repos' ? 'No repositories found' : 'Search for repositories'}</p>
651
+ </div>
652
+ );
653
+ }
654
+
655
+ return repos.map((repo) => <RepositoryCard key={repo.full_name} repo={repo} onSelect={() => onSelect(repo)} />);
656
+ }
657
+
658
+ function RepositoryCard({ repo, onSelect }: { repo: GitHubRepoInfo; onSelect: () => void }) {
659
+ return (
660
+ <div className="p-4 rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333] hover:border-purple-500/50 transition-colors">
661
+ <div className="flex items-center justify-between mb-2">
662
+ <div className="flex items-center gap-2">
663
+ <span className="i-ph:git-repository text-bolt-elements-textTertiary" />
664
+ <h3 className="font-medium text-bolt-elements-textPrimary dark:text-white">{repo.name}</h3>
665
+ </div>
666
+ <button
667
+ onClick={onSelect}
668
+ className="px-4 py-2 h-10 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-all duration-200 flex items-center gap-2 min-w-[120px] justify-center"
669
+ >
670
+ <span className="i-ph:download-simple w-4 h-4" />
671
+ Import
672
+ </button>
673
+ </div>
674
+ {repo.description && <p className="text-sm text-bolt-elements-textSecondary mb-3">{repo.description}</p>}
675
+ <div className="flex items-center gap-4 text-sm text-bolt-elements-textTertiary">
676
+ {repo.language && (
677
+ <span className="flex items-center gap-1">
678
+ <span className="i-ph:code" />
679
+ {repo.language}
680
+ </span>
681
+ )}
682
+ <span className="flex items-center gap-1">
683
+ <span className="i-ph:star" />
684
+ {repo.stargazers_count.toLocaleString()}
685
+ </span>
686
+ <span className="flex items-center gap-1">
687
+ <span className="i-ph:clock" />
688
+ {new Date(repo.updated_at).toLocaleDateString()}
689
+ </span>
690
+ </div>
691
+ </div>
692
+ );
693
+ }
app/components/@settings/tabs/connections/types/GitHub.ts ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface GitHubUserResponse {
2
+ login: string;
3
+ avatar_url: string;
4
+ html_url: string;
5
+ name: string;
6
+ bio: string;
7
+ public_repos: number;
8
+ followers: number;
9
+ following: number;
10
+ public_gists: number;
11
+ created_at: string;
12
+ updated_at: string;
13
+ }
14
+
15
+ export interface GitHubRepoInfo {
16
+ name: string;
17
+ full_name: string;
18
+ html_url: string;
19
+ description: string;
20
+ stargazers_count: number;
21
+ forks_count: number;
22
+ default_branch: string;
23
+ updated_at: string;
24
+ language: string;
25
+ languages_url: string;
26
+ }
27
+
28
+ export interface GitHubOrganization {
29
+ login: string;
30
+ avatar_url: string;
31
+ description: string;
32
+ html_url: string;
33
+ }
34
+
35
+ export interface GitHubEvent {
36
+ id: string;
37
+ type: string;
38
+ created_at: string;
39
+ repo: {
40
+ name: string;
41
+ url: string;
42
+ };
43
+ payload: {
44
+ action?: string;
45
+ ref?: string;
46
+ ref_type?: string;
47
+ description?: string;
48
+ };
49
+ }
50
+
51
+ export interface GitHubLanguageStats {
52
+ [key: string]: number;
53
+ }
54
+
55
+ export interface GitHubStats {
56
+ repos: GitHubRepoInfo[];
57
+ totalStars: number;
58
+ totalForks: number;
59
+ organizations: GitHubOrganization[];
60
+ recentActivity: GitHubEvent[];
61
+ languages: GitHubLanguageStats;
62
+ totalGists: number;
63
+ }
64
+
65
+ export interface GitHubConnection {
66
+ user: GitHubUserResponse | null;
67
+ token: string;
68
+ tokenType: 'classic' | 'fine-grained';
69
+ stats?: GitHubStats;
70
+ }
71
+
72
+ export interface GitHubTokenInfo {
73
+ token: string;
74
+ scope: string[];
75
+ avatar_url: string;
76
+ name: string | null;
77
+ created_at: string;
78
+ followers: number;
79
+ }
80
+
81
+ export interface GitHubRateLimits {
82
+ limit: number;
83
+ remaining: number;
84
+ reset: Date;
85
+ used: number;
86
+ }
87
+
88
+ export interface GitHubAuthState {
89
+ username: string;
90
+ tokenInfo: GitHubTokenInfo | null;
91
+ isConnected: boolean;
92
+ isVerifying: boolean;
93
+ isLoadingRepos: boolean;
94
+ rateLimits?: GitHubRateLimits;
95
+ }
app/components/@settings/tabs/data/DataTab.tsx ADDED
@@ -0,0 +1,452 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef } from 'react';
2
+ import { motion } from 'framer-motion';
3
+ import { toast } from 'react-toastify';
4
+ import { DialogRoot, DialogClose, Dialog, DialogTitle } from '~/components/ui/Dialog';
5
+ import { db, getAll, deleteById } from '~/lib/persistence';
6
+
7
+ export default function DataTab() {
8
+ const [isDownloadingTemplate, setIsDownloadingTemplate] = useState(false);
9
+ const [isImportingKeys, setIsImportingKeys] = useState(false);
10
+ const [isResetting, setIsResetting] = useState(false);
11
+ const [isDeleting, setIsDeleting] = useState(false);
12
+ const [showResetInlineConfirm, setShowResetInlineConfirm] = useState(false);
13
+ const [showDeleteInlineConfirm, setShowDeleteInlineConfirm] = useState(false);
14
+ const fileInputRef = useRef<HTMLInputElement>(null);
15
+ const apiKeyFileInputRef = useRef<HTMLInputElement>(null);
16
+
17
+ const handleExportAllChats = async () => {
18
+ try {
19
+ if (!db) {
20
+ throw new Error('Database not initialized');
21
+ }
22
+
23
+ // Get all chats from IndexedDB
24
+ const allChats = await getAll(db);
25
+ const exportData = {
26
+ chats: allChats,
27
+ exportDate: new Date().toISOString(),
28
+ };
29
+
30
+ // Download as JSON
31
+ const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
32
+ const url = URL.createObjectURL(blob);
33
+ const a = document.createElement('a');
34
+ a.href = url;
35
+ a.download = `bolt-chats-${new Date().toISOString()}.json`;
36
+ document.body.appendChild(a);
37
+ a.click();
38
+ document.body.removeChild(a);
39
+ URL.revokeObjectURL(url);
40
+
41
+ toast.success('Chats exported successfully');
42
+ } catch (error) {
43
+ console.error('Export error:', error);
44
+ toast.error('Failed to export chats');
45
+ }
46
+ };
47
+
48
+ const handleExportSettings = () => {
49
+ try {
50
+ const settings = {
51
+ userProfile: localStorage.getItem('bolt_user_profile'),
52
+ settings: localStorage.getItem('bolt_settings'),
53
+ exportDate: new Date().toISOString(),
54
+ };
55
+
56
+ const blob = new Blob([JSON.stringify(settings, null, 2)], { type: 'application/json' });
57
+ const url = URL.createObjectURL(blob);
58
+ const a = document.createElement('a');
59
+ a.href = url;
60
+ a.download = `bolt-settings-${new Date().toISOString()}.json`;
61
+ document.body.appendChild(a);
62
+ a.click();
63
+ document.body.removeChild(a);
64
+ URL.revokeObjectURL(url);
65
+
66
+ toast.success('Settings exported successfully');
67
+ } catch (error) {
68
+ console.error('Export error:', error);
69
+ toast.error('Failed to export settings');
70
+ }
71
+ };
72
+
73
+ const handleImportSettings = async (event: React.ChangeEvent<HTMLInputElement>) => {
74
+ const file = event.target.files?.[0];
75
+
76
+ if (!file) {
77
+ return;
78
+ }
79
+
80
+ try {
81
+ const content = await file.text();
82
+ const settings = JSON.parse(content);
83
+
84
+ if (settings.userProfile) {
85
+ localStorage.setItem('bolt_user_profile', settings.userProfile);
86
+ }
87
+
88
+ if (settings.settings) {
89
+ localStorage.setItem('bolt_settings', settings.settings);
90
+ }
91
+
92
+ window.location.reload(); // Reload to apply settings
93
+ toast.success('Settings imported successfully');
94
+ } catch (error) {
95
+ console.error('Import error:', error);
96
+ toast.error('Failed to import settings');
97
+ }
98
+ };
99
+
100
+ const handleImportAPIKeys = async (event: React.ChangeEvent<HTMLInputElement>) => {
101
+ const file = event.target.files?.[0];
102
+
103
+ if (!file) {
104
+ return;
105
+ }
106
+
107
+ setIsImportingKeys(true);
108
+
109
+ try {
110
+ const content = await file.text();
111
+ const keys = JSON.parse(content);
112
+
113
+ // Validate and save each key
114
+ Object.entries(keys).forEach(([key, value]) => {
115
+ if (typeof value !== 'string') {
116
+ throw new Error(`Invalid value for key: ${key}`);
117
+ }
118
+
119
+ localStorage.setItem(`bolt_${key.toLowerCase()}`, value);
120
+ });
121
+
122
+ toast.success('API keys imported successfully');
123
+ } catch (error) {
124
+ console.error('Error importing API keys:', error);
125
+ toast.error('Failed to import API keys');
126
+ } finally {
127
+ setIsImportingKeys(false);
128
+
129
+ if (apiKeyFileInputRef.current) {
130
+ apiKeyFileInputRef.current.value = '';
131
+ }
132
+ }
133
+ };
134
+
135
+ const handleDownloadTemplate = () => {
136
+ setIsDownloadingTemplate(true);
137
+
138
+ try {
139
+ const template = {
140
+ Anthropic_API_KEY: '',
141
+ OpenAI_API_KEY: '',
142
+ Google_API_KEY: '',
143
+ Groq_API_KEY: '',
144
+ HuggingFace_API_KEY: '',
145
+ OpenRouter_API_KEY: '',
146
+ Deepseek_API_KEY: '',
147
+ Mistral_API_KEY: '',
148
+ OpenAILike_API_KEY: '',
149
+ Together_API_KEY: '',
150
+ xAI_API_KEY: '',
151
+ Perplexity_API_KEY: '',
152
+ Cohere_API_KEY: '',
153
+ AzureOpenAI_API_KEY: '',
154
+ OPENAI_LIKE_API_BASE_URL: '',
155
+ LMSTUDIO_API_BASE_URL: '',
156
+ OLLAMA_API_BASE_URL: '',
157
+ TOGETHER_API_BASE_URL: '',
158
+ };
159
+
160
+ const blob = new Blob([JSON.stringify(template, null, 2)], { type: 'application/json' });
161
+ const url = URL.createObjectURL(blob);
162
+ const a = document.createElement('a');
163
+ a.href = url;
164
+ a.download = 'bolt-api-keys-template.json';
165
+ document.body.appendChild(a);
166
+ a.click();
167
+ document.body.removeChild(a);
168
+ URL.revokeObjectURL(url);
169
+
170
+ toast.success('Template downloaded successfully');
171
+ } catch (error) {
172
+ console.error('Error downloading template:', error);
173
+ toast.error('Failed to download template');
174
+ } finally {
175
+ setIsDownloadingTemplate(false);
176
+ }
177
+ };
178
+
179
+ const handleResetSettings = async () => {
180
+ setIsResetting(true);
181
+
182
+ try {
183
+ // Clear all stored settings from localStorage
184
+ localStorage.removeItem('bolt_user_profile');
185
+ localStorage.removeItem('bolt_settings');
186
+ localStorage.removeItem('bolt_chat_history');
187
+
188
+ // Clear all data from IndexedDB
189
+ if (!db) {
190
+ throw new Error('Database not initialized');
191
+ }
192
+
193
+ // Get all chats and delete them
194
+ const chats = await getAll(db as IDBDatabase);
195
+ const deletePromises = chats.map((chat) => deleteById(db as IDBDatabase, chat.id));
196
+ await Promise.all(deletePromises);
197
+
198
+ // Close the dialog first
199
+ setShowResetInlineConfirm(false);
200
+
201
+ // Then reload and show success message
202
+ window.location.reload();
203
+ toast.success('Settings reset successfully');
204
+ } catch (error) {
205
+ console.error('Reset error:', error);
206
+ setShowResetInlineConfirm(false);
207
+ toast.error('Failed to reset settings');
208
+ } finally {
209
+ setIsResetting(false);
210
+ }
211
+ };
212
+
213
+ const handleDeleteAllChats = async () => {
214
+ setIsDeleting(true);
215
+
216
+ try {
217
+ // Clear chat history from localStorage
218
+ localStorage.removeItem('bolt_chat_history');
219
+
220
+ // Clear chats from IndexedDB
221
+ if (!db) {
222
+ throw new Error('Database not initialized');
223
+ }
224
+
225
+ // Get all chats and delete them one by one
226
+ const chats = await getAll(db as IDBDatabase);
227
+ const deletePromises = chats.map((chat) => deleteById(db as IDBDatabase, chat.id));
228
+ await Promise.all(deletePromises);
229
+
230
+ // Close the dialog first
231
+ setShowDeleteInlineConfirm(false);
232
+
233
+ // Then show the success message
234
+ toast.success('Chat history deleted successfully');
235
+ } catch (error) {
236
+ console.error('Delete error:', error);
237
+ setShowDeleteInlineConfirm(false);
238
+ toast.error('Failed to delete chat history');
239
+ } finally {
240
+ setIsDeleting(false);
241
+ }
242
+ };
243
+
244
+ return (
245
+ <div className="space-y-6">
246
+ <input ref={fileInputRef} type="file" accept=".json" onChange={handleImportSettings} className="hidden" />
247
+ {/* Reset Settings Dialog */}
248
+ <DialogRoot open={showResetInlineConfirm} onOpenChange={setShowResetInlineConfirm}>
249
+ <Dialog showCloseButton={false} className="z-[1000]">
250
+ <div className="p-6">
251
+ <div className="flex items-center gap-3">
252
+ <div className="i-ph:warning-circle-fill w-5 h-5 text-yellow-500" />
253
+ <DialogTitle>Reset All Settings?</DialogTitle>
254
+ </div>
255
+ <p className="text-sm text-bolt-elements-textSecondary mt-2">
256
+ This will reset all your settings to their default values. This action cannot be undone.
257
+ </p>
258
+ <div className="flex justify-end items-center gap-3 mt-6">
259
+ <DialogClose asChild>
260
+ <button className="px-4 py-2 rounded-lg text-sm bg-[#F5F5F5] dark:bg-[#1A1A1A] text-[#666666] dark:text-[#999999] hover:text-[#333333] dark:hover:text-white">
261
+ Cancel
262
+ </button>
263
+ </DialogClose>
264
+ <motion.button
265
+ className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm bg-white dark:bg-[#1A1A1A] text-yellow-600 dark:text-yellow-500 hover:bg-yellow-50 dark:hover:bg-yellow-500/10 border border-transparent hover:border-yellow-500/10 dark:hover:border-yellow-500/20"
266
+ onClick={handleResetSettings}
267
+ disabled={isResetting}
268
+ whileHover={{ scale: 1.02 }}
269
+ whileTap={{ scale: 0.98 }}
270
+ >
271
+ {isResetting ? (
272
+ <div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
273
+ ) : (
274
+ <div className="i-ph:arrow-counter-clockwise w-4 h-4" />
275
+ )}
276
+ Reset Settings
277
+ </motion.button>
278
+ </div>
279
+ </div>
280
+ </Dialog>
281
+ </DialogRoot>
282
+
283
+ {/* Delete Confirmation Dialog */}
284
+ <DialogRoot open={showDeleteInlineConfirm} onOpenChange={setShowDeleteInlineConfirm}>
285
+ <Dialog showCloseButton={false} className="z-[1000]">
286
+ <div className="p-6">
287
+ <div className="flex items-center gap-3">
288
+ <div className="i-ph:warning-circle-fill w-5 h-5 text-red-500" />
289
+ <DialogTitle>Delete All Chats?</DialogTitle>
290
+ </div>
291
+ <p className="text-sm text-bolt-elements-textSecondary mt-2">
292
+ This will permanently delete all your chat history. This action cannot be undone.
293
+ </p>
294
+ <div className="flex justify-end items-center gap-3 mt-6">
295
+ <DialogClose asChild>
296
+ <button className="px-4 py-2 rounded-lg text-sm bg-[#F5F5F5] dark:bg-[#1A1A1A] text-[#666666] dark:text-[#999999] hover:text-[#333333] dark:hover:text-white">
297
+ Cancel
298
+ </button>
299
+ </DialogClose>
300
+ <motion.button
301
+ className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm bg-white dark:bg-[#1A1A1A] text-red-500 dark:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 border border-transparent hover:border-red-500/10 dark:hover:border-red-500/20"
302
+ onClick={handleDeleteAllChats}
303
+ disabled={isDeleting}
304
+ whileHover={{ scale: 1.02 }}
305
+ whileTap={{ scale: 0.98 }}
306
+ >
307
+ {isDeleting ? (
308
+ <div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
309
+ ) : (
310
+ <div className="i-ph:trash w-4 h-4" />
311
+ )}
312
+ Delete All
313
+ </motion.button>
314
+ </div>
315
+ </div>
316
+ </Dialog>
317
+ </DialogRoot>
318
+
319
+ {/* Chat History Section */}
320
+ <motion.div
321
+ className="bg-white dark:bg-[#0A0A0A] rounded-lg p-6 border border-[#E5E5E5] dark:border-[#1A1A1A]"
322
+ initial={{ opacity: 0, y: 20 }}
323
+ animate={{ opacity: 1, y: 0 }}
324
+ transition={{ delay: 0.1 }}
325
+ >
326
+ <div className="flex items-center gap-2 mb-2">
327
+ <div className="i-ph:chat-circle-duotone w-5 h-5 text-purple-500" />
328
+ <h3 className="text-lg font-medium text-gray-900 dark:text-white">Chat History</h3>
329
+ </div>
330
+ <p className="text-sm text-gray-600 dark:text-gray-400 mb-4">Export or delete all your chat history.</p>
331
+ <div className="flex gap-4">
332
+ <motion.button
333
+ className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600"
334
+ whileHover={{ scale: 1.02 }}
335
+ whileTap={{ scale: 0.98 }}
336
+ onClick={handleExportAllChats}
337
+ >
338
+ <div className="i-ph:download-simple w-4 h-4" />
339
+ Export All Chats
340
+ </motion.button>
341
+ <motion.button
342
+ className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-red-50 text-red-500 text-sm hover:bg-red-100 dark:bg-red-500/10 dark:hover:bg-red-500/20"
343
+ whileHover={{ scale: 1.02 }}
344
+ whileTap={{ scale: 0.98 }}
345
+ onClick={() => setShowDeleteInlineConfirm(true)}
346
+ >
347
+ <div className="i-ph:trash w-4 h-4" />
348
+ Delete All Chats
349
+ </motion.button>
350
+ </div>
351
+ </motion.div>
352
+
353
+ {/* Settings Backup Section */}
354
+ <motion.div
355
+ className="bg-white dark:bg-[#0A0A0A] rounded-lg p-6 border border-[#E5E5E5] dark:border-[#1A1A1A]"
356
+ initial={{ opacity: 0, y: 20 }}
357
+ animate={{ opacity: 1, y: 0 }}
358
+ transition={{ delay: 0.2 }}
359
+ >
360
+ <div className="flex items-center gap-2 mb-2">
361
+ <div className="i-ph:gear-duotone w-5 h-5 text-purple-500" />
362
+ <h3 className="text-lg font-medium text-gray-900 dark:text-white">Settings Backup</h3>
363
+ </div>
364
+ <p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
365
+ Export your settings to a JSON file or import settings from a previously exported file.
366
+ </p>
367
+ <div className="flex gap-4">
368
+ <motion.button
369
+ className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600"
370
+ whileHover={{ scale: 1.02 }}
371
+ whileTap={{ scale: 0.98 }}
372
+ onClick={handleExportSettings}
373
+ >
374
+ <div className="i-ph:download-simple w-4 h-4" />
375
+ Export Settings
376
+ </motion.button>
377
+ <motion.button
378
+ className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600"
379
+ whileHover={{ scale: 1.02 }}
380
+ whileTap={{ scale: 0.98 }}
381
+ onClick={() => fileInputRef.current?.click()}
382
+ >
383
+ <div className="i-ph:upload-simple w-4 h-4" />
384
+ Import Settings
385
+ </motion.button>
386
+ <motion.button
387
+ className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-yellow-50 text-yellow-600 text-sm hover:bg-yellow-100 dark:bg-yellow-500/10 dark:hover:bg-yellow-500/20 dark:text-yellow-500"
388
+ whileHover={{ scale: 1.02 }}
389
+ whileTap={{ scale: 0.98 }}
390
+ onClick={() => setShowResetInlineConfirm(true)}
391
+ >
392
+ <div className="i-ph:arrow-counter-clockwise w-4 h-4" />
393
+ Reset Settings
394
+ </motion.button>
395
+ </div>
396
+ </motion.div>
397
+
398
+ {/* API Keys Management Section */}
399
+ <motion.div
400
+ className="bg-white dark:bg-[#0A0A0A] rounded-lg p-6 border border-[#E5E5E5] dark:border-[#1A1A1A]"
401
+ initial={{ opacity: 0, y: 20 }}
402
+ animate={{ opacity: 1, y: 0 }}
403
+ transition={{ delay: 0.3 }}
404
+ >
405
+ <div className="flex items-center gap-2 mb-2">
406
+ <div className="i-ph:key-duotone w-5 h-5 text-purple-500" />
407
+ <h3 className="text-lg font-medium text-gray-900 dark:text-white">API Keys Management</h3>
408
+ </div>
409
+ <p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
410
+ Import API keys from a JSON file or download a template to fill in your keys.
411
+ </p>
412
+ <div className="flex gap-4">
413
+ <input
414
+ ref={apiKeyFileInputRef}
415
+ type="file"
416
+ accept=".json"
417
+ onChange={handleImportAPIKeys}
418
+ className="hidden"
419
+ />
420
+ <motion.button
421
+ className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600"
422
+ whileHover={{ scale: 1.02 }}
423
+ whileTap={{ scale: 0.98 }}
424
+ onClick={handleDownloadTemplate}
425
+ disabled={isDownloadingTemplate}
426
+ >
427
+ {isDownloadingTemplate ? (
428
+ <div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
429
+ ) : (
430
+ <div className="i-ph:download-simple w-4 h-4" />
431
+ )}
432
+ Download Template
433
+ </motion.button>
434
+ <motion.button
435
+ className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600"
436
+ whileHover={{ scale: 1.02 }}
437
+ whileTap={{ scale: 0.98 }}
438
+ onClick={() => apiKeyFileInputRef.current?.click()}
439
+ disabled={isImportingKeys}
440
+ >
441
+ {isImportingKeys ? (
442
+ <div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
443
+ ) : (
444
+ <div className="i-ph:upload-simple w-4 h-4" />
445
+ )}
446
+ Import API Keys
447
+ </motion.button>
448
+ </div>
449
+ </motion.div>
450
+ </div>
451
+ );
452
+ }
app/components/@settings/tabs/debug/DebugTab.tsx ADDED
@@ -0,0 +1,2045 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState, useMemo, useCallback } from 'react';
2
+ import { toast } from 'react-toastify';
3
+ import { classNames } from '~/utils/classNames';
4
+ import { logStore, type LogEntry } from '~/lib/stores/logs';
5
+ import { useStore } from '@nanostores/react';
6
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '~/components/ui/Collapsible';
7
+ import { Progress } from '~/components/ui/Progress';
8
+ import { ScrollArea } from '~/components/ui/ScrollArea';
9
+ import { Badge } from '~/components/ui/Badge';
10
+ import { Dialog, DialogRoot, DialogTitle } from '~/components/ui/Dialog';
11
+ import { jsPDF } from 'jspdf';
12
+ import { useSettings } from '~/lib/hooks/useSettings';
13
+
14
+ interface SystemInfo {
15
+ os: string;
16
+ arch: string;
17
+ platform: string;
18
+ cpus: string;
19
+ memory: {
20
+ total: string;
21
+ free: string;
22
+ used: string;
23
+ percentage: number;
24
+ };
25
+ node: string;
26
+ browser: {
27
+ name: string;
28
+ version: string;
29
+ language: string;
30
+ userAgent: string;
31
+ cookiesEnabled: boolean;
32
+ online: boolean;
33
+ platform: string;
34
+ cores: number;
35
+ };
36
+ screen: {
37
+ width: number;
38
+ height: number;
39
+ colorDepth: number;
40
+ pixelRatio: number;
41
+ };
42
+ time: {
43
+ timezone: string;
44
+ offset: number;
45
+ locale: string;
46
+ };
47
+ performance: {
48
+ memory: {
49
+ jsHeapSizeLimit: number;
50
+ totalJSHeapSize: number;
51
+ usedJSHeapSize: number;
52
+ usagePercentage: number;
53
+ };
54
+ timing: {
55
+ loadTime: number;
56
+ domReadyTime: number;
57
+ readyStart: number;
58
+ redirectTime: number;
59
+ appcacheTime: number;
60
+ unloadEventTime: number;
61
+ lookupDomainTime: number;
62
+ connectTime: number;
63
+ requestTime: number;
64
+ initDomTreeTime: number;
65
+ loadEventTime: number;
66
+ };
67
+ navigation: {
68
+ type: number;
69
+ redirectCount: number;
70
+ };
71
+ };
72
+ network: {
73
+ downlink: number;
74
+ effectiveType: string;
75
+ rtt: number;
76
+ saveData: boolean;
77
+ type: string;
78
+ };
79
+ battery?: {
80
+ charging: boolean;
81
+ chargingTime: number;
82
+ dischargingTime: number;
83
+ level: number;
84
+ };
85
+ storage: {
86
+ quota: number;
87
+ usage: number;
88
+ persistent: boolean;
89
+ temporary: boolean;
90
+ };
91
+ }
92
+
93
+ interface GitHubRepoInfo {
94
+ fullName: string;
95
+ defaultBranch: string;
96
+ stars: number;
97
+ forks: number;
98
+ openIssues?: number;
99
+ }
100
+
101
+ interface GitInfo {
102
+ local: {
103
+ commitHash: string;
104
+ branch: string;
105
+ commitTime: string;
106
+ author: string;
107
+ email: string;
108
+ remoteUrl: string;
109
+ repoName: string;
110
+ };
111
+ github?: {
112
+ currentRepo: GitHubRepoInfo;
113
+ upstream?: GitHubRepoInfo;
114
+ };
115
+ isForked?: boolean;
116
+ }
117
+
118
+ interface WebAppInfo {
119
+ name: string;
120
+ version: string;
121
+ description: string;
122
+ license: string;
123
+ environment: string;
124
+ timestamp: string;
125
+ runtimeInfo: {
126
+ nodeVersion: string;
127
+ };
128
+ dependencies: {
129
+ production: Array<{ name: string; version: string; type: string }>;
130
+ development: Array<{ name: string; version: string; type: string }>;
131
+ peer: Array<{ name: string; version: string; type: string }>;
132
+ optional: Array<{ name: string; version: string; type: string }>;
133
+ };
134
+ gitInfo: GitInfo;
135
+ }
136
+
137
+ // Add Ollama service status interface
138
+ interface OllamaServiceStatus {
139
+ isRunning: boolean;
140
+ lastChecked: Date;
141
+ error?: string;
142
+ models?: Array<{
143
+ name: string;
144
+ size: string;
145
+ quantization: string;
146
+ }>;
147
+ }
148
+
149
+ interface ExportFormat {
150
+ id: string;
151
+ label: string;
152
+ icon: string;
153
+ handler: () => void;
154
+ }
155
+
156
+ const DependencySection = ({
157
+ title,
158
+ deps,
159
+ }: {
160
+ title: string;
161
+ deps: Array<{ name: string; version: string; type: string }>;
162
+ }) => {
163
+ const [isOpen, setIsOpen] = useState(false);
164
+
165
+ if (deps.length === 0) {
166
+ return null;
167
+ }
168
+
169
+ return (
170
+ <Collapsible open={isOpen} onOpenChange={setIsOpen}>
171
+ <CollapsibleTrigger
172
+ className={classNames(
173
+ 'flex w-full items-center justify-between p-4',
174
+ 'bg-white dark:bg-[#0A0A0A]',
175
+ 'hover:bg-purple-50/50 dark:hover:bg-[#1a1a1a]',
176
+ 'border-b border-[#E5E5E5] dark:border-[#1A1A1A]',
177
+ 'transition-colors duration-200',
178
+ 'first:rounded-t-lg last:rounded-b-lg',
179
+ { 'hover:rounded-lg': !isOpen },
180
+ )}
181
+ >
182
+ <div className="flex items-center gap-3">
183
+ <div className="i-ph:package text-bolt-elements-textSecondary w-4 h-4" />
184
+ <span className="text-base text-bolt-elements-textPrimary">
185
+ {title} Dependencies ({deps.length})
186
+ </span>
187
+ </div>
188
+ <div className="flex items-center gap-2">
189
+ <span className="text-sm text-bolt-elements-textSecondary">{isOpen ? 'Hide' : 'Show'}</span>
190
+ <div
191
+ className={classNames(
192
+ 'i-ph:caret-down w-4 h-4 transform transition-transform duration-200 text-bolt-elements-textSecondary',
193
+ isOpen ? 'rotate-180' : '',
194
+ )}
195
+ />
196
+ </div>
197
+ </CollapsibleTrigger>
198
+ <CollapsibleContent>
199
+ <ScrollArea
200
+ className={classNames(
201
+ 'h-[200px] w-full',
202
+ 'bg-white dark:bg-[#0A0A0A]',
203
+ 'border-b border-[#E5E5E5] dark:border-[#1A1A1A]',
204
+ 'last:rounded-b-lg last:border-b-0',
205
+ )}
206
+ >
207
+ <div className="space-y-2 p-4">
208
+ {deps.map((dep) => (
209
+ <div key={dep.name} className="flex items-center justify-between text-sm">
210
+ <span className="text-bolt-elements-textPrimary">{dep.name}</span>
211
+ <span className="text-bolt-elements-textSecondary">{dep.version}</span>
212
+ </div>
213
+ ))}
214
+ </div>
215
+ </ScrollArea>
216
+ </CollapsibleContent>
217
+ </Collapsible>
218
+ );
219
+ };
220
+
221
+ export default function DebugTab() {
222
+ const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null);
223
+ const [webAppInfo, setWebAppInfo] = useState<WebAppInfo | null>(null);
224
+ const [ollamaStatus, setOllamaStatus] = useState<OllamaServiceStatus>({
225
+ isRunning: false,
226
+ lastChecked: new Date(),
227
+ });
228
+ const [loading, setLoading] = useState({
229
+ systemInfo: false,
230
+ webAppInfo: false,
231
+ errors: false,
232
+ performance: false,
233
+ });
234
+ const [openSections, setOpenSections] = useState({
235
+ system: false,
236
+ webapp: false,
237
+ errors: false,
238
+ performance: false,
239
+ });
240
+
241
+ const { providers } = useSettings();
242
+
243
+ // Subscribe to logStore updates
244
+ const logs = useStore(logStore.logs);
245
+ const errorLogs = useMemo(() => {
246
+ return Object.values(logs).filter(
247
+ (log): log is LogEntry => typeof log === 'object' && log !== null && 'level' in log && log.level === 'error',
248
+ );
249
+ }, [logs]);
250
+
251
+ // Set up error listeners when component mounts
252
+ useEffect(() => {
253
+ const handleError = (event: ErrorEvent) => {
254
+ logStore.logError(event.message, event.error, {
255
+ filename: event.filename,
256
+ lineNumber: event.lineno,
257
+ columnNumber: event.colno,
258
+ });
259
+ };
260
+
261
+ const handleRejection = (event: PromiseRejectionEvent) => {
262
+ logStore.logError('Unhandled Promise Rejection', event.reason);
263
+ };
264
+
265
+ window.addEventListener('error', handleError);
266
+ window.addEventListener('unhandledrejection', handleRejection);
267
+
268
+ return () => {
269
+ window.removeEventListener('error', handleError);
270
+ window.removeEventListener('unhandledrejection', handleRejection);
271
+ };
272
+ }, []);
273
+
274
+ // Check for errors when the errors section is opened
275
+ useEffect(() => {
276
+ if (openSections.errors) {
277
+ checkErrors();
278
+ }
279
+ }, [openSections.errors]);
280
+
281
+ // Load initial data when component mounts
282
+ useEffect(() => {
283
+ const loadInitialData = async () => {
284
+ await Promise.all([getSystemInfo(), getWebAppInfo()]);
285
+ };
286
+
287
+ loadInitialData();
288
+ }, []);
289
+
290
+ // Refresh data when sections are opened
291
+ useEffect(() => {
292
+ if (openSections.system) {
293
+ getSystemInfo();
294
+ }
295
+
296
+ if (openSections.webapp) {
297
+ getWebAppInfo();
298
+ }
299
+ }, [openSections.system, openSections.webapp]);
300
+
301
+ // Add periodic refresh of git info
302
+ useEffect(() => {
303
+ if (!openSections.webapp) {
304
+ return undefined;
305
+ }
306
+
307
+ // Initial fetch
308
+ const fetchGitInfo = async () => {
309
+ try {
310
+ const response = await fetch('/api/system/git-info');
311
+ const updatedGitInfo = (await response.json()) as GitInfo;
312
+
313
+ setWebAppInfo((prev) => {
314
+ if (!prev) {
315
+ return null;
316
+ }
317
+
318
+ // Only update if the data has changed
319
+ if (JSON.stringify(prev.gitInfo) === JSON.stringify(updatedGitInfo)) {
320
+ return prev;
321
+ }
322
+
323
+ return {
324
+ ...prev,
325
+ gitInfo: updatedGitInfo,
326
+ };
327
+ });
328
+ } catch (error) {
329
+ console.error('Failed to fetch git info:', error);
330
+ }
331
+ };
332
+
333
+ fetchGitInfo();
334
+
335
+ // Refresh every 5 minutes instead of every second
336
+ const interval = setInterval(fetchGitInfo, 5 * 60 * 1000);
337
+
338
+ return () => clearInterval(interval);
339
+ }, [openSections.webapp]);
340
+
341
+ const getSystemInfo = async () => {
342
+ try {
343
+ setLoading((prev) => ({ ...prev, systemInfo: true }));
344
+
345
+ // Get browser info
346
+ const ua = navigator.userAgent;
347
+ const browserName = ua.includes('Firefox')
348
+ ? 'Firefox'
349
+ : ua.includes('Chrome')
350
+ ? 'Chrome'
351
+ : ua.includes('Safari')
352
+ ? 'Safari'
353
+ : ua.includes('Edge')
354
+ ? 'Edge'
355
+ : 'Unknown';
356
+ const browserVersion = ua.match(/(Firefox|Chrome|Safari|Edge)\/([0-9.]+)/)?.[2] || 'Unknown';
357
+
358
+ // Get performance metrics
359
+ const memory = (performance as any).memory || {};
360
+ const timing = performance.timing;
361
+ const navigation = performance.navigation;
362
+ const connection = (navigator as any).connection;
363
+
364
+ // Get battery info
365
+ let batteryInfo;
366
+
367
+ try {
368
+ const battery = await (navigator as any).getBattery();
369
+ batteryInfo = {
370
+ charging: battery.charging,
371
+ chargingTime: battery.chargingTime,
372
+ dischargingTime: battery.dischargingTime,
373
+ level: battery.level * 100,
374
+ };
375
+ } catch {
376
+ console.log('Battery API not supported');
377
+ }
378
+
379
+ // Get storage info
380
+ let storageInfo = {
381
+ quota: 0,
382
+ usage: 0,
383
+ persistent: false,
384
+ temporary: false,
385
+ };
386
+
387
+ try {
388
+ const storage = await navigator.storage.estimate();
389
+ const persistent = await navigator.storage.persist();
390
+ storageInfo = {
391
+ quota: storage.quota || 0,
392
+ usage: storage.usage || 0,
393
+ persistent,
394
+ temporary: !persistent,
395
+ };
396
+ } catch {
397
+ console.log('Storage API not supported');
398
+ }
399
+
400
+ // Get memory info from browser performance API
401
+ const performanceMemory = (performance as any).memory || {};
402
+ const totalMemory = performanceMemory.jsHeapSizeLimit || 0;
403
+ const usedMemory = performanceMemory.usedJSHeapSize || 0;
404
+ const freeMemory = totalMemory - usedMemory;
405
+ const memoryPercentage = totalMemory ? (usedMemory / totalMemory) * 100 : 0;
406
+
407
+ const systemInfo: SystemInfo = {
408
+ os: navigator.platform,
409
+ arch: navigator.userAgent.includes('x64') ? 'x64' : navigator.userAgent.includes('arm') ? 'arm' : 'unknown',
410
+ platform: navigator.platform,
411
+ cpus: navigator.hardwareConcurrency + ' cores',
412
+ memory: {
413
+ total: formatBytes(totalMemory),
414
+ free: formatBytes(freeMemory),
415
+ used: formatBytes(usedMemory),
416
+ percentage: Math.round(memoryPercentage),
417
+ },
418
+ node: 'browser',
419
+ browser: {
420
+ name: browserName,
421
+ version: browserVersion,
422
+ language: navigator.language,
423
+ userAgent: navigator.userAgent,
424
+ cookiesEnabled: navigator.cookieEnabled,
425
+ online: navigator.onLine,
426
+ platform: navigator.platform,
427
+ cores: navigator.hardwareConcurrency,
428
+ },
429
+ screen: {
430
+ width: window.screen.width,
431
+ height: window.screen.height,
432
+ colorDepth: window.screen.colorDepth,
433
+ pixelRatio: window.devicePixelRatio,
434
+ },
435
+ time: {
436
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
437
+ offset: new Date().getTimezoneOffset(),
438
+ locale: navigator.language,
439
+ },
440
+ performance: {
441
+ memory: {
442
+ jsHeapSizeLimit: memory.jsHeapSizeLimit || 0,
443
+ totalJSHeapSize: memory.totalJSHeapSize || 0,
444
+ usedJSHeapSize: memory.usedJSHeapSize || 0,
445
+ usagePercentage: memory.totalJSHeapSize ? (memory.usedJSHeapSize / memory.totalJSHeapSize) * 100 : 0,
446
+ },
447
+ timing: {
448
+ loadTime: timing.loadEventEnd - timing.navigationStart,
449
+ domReadyTime: timing.domContentLoadedEventEnd - timing.navigationStart,
450
+ readyStart: timing.fetchStart - timing.navigationStart,
451
+ redirectTime: timing.redirectEnd - timing.redirectStart,
452
+ appcacheTime: timing.domainLookupStart - timing.fetchStart,
453
+ unloadEventTime: timing.unloadEventEnd - timing.unloadEventStart,
454
+ lookupDomainTime: timing.domainLookupEnd - timing.domainLookupStart,
455
+ connectTime: timing.connectEnd - timing.connectStart,
456
+ requestTime: timing.responseEnd - timing.requestStart,
457
+ initDomTreeTime: timing.domInteractive - timing.responseEnd,
458
+ loadEventTime: timing.loadEventEnd - timing.loadEventStart,
459
+ },
460
+ navigation: {
461
+ type: navigation.type,
462
+ redirectCount: navigation.redirectCount,
463
+ },
464
+ },
465
+ network: {
466
+ downlink: connection?.downlink || 0,
467
+ effectiveType: connection?.effectiveType || 'unknown',
468
+ rtt: connection?.rtt || 0,
469
+ saveData: connection?.saveData || false,
470
+ type: connection?.type || 'unknown',
471
+ },
472
+ battery: batteryInfo,
473
+ storage: storageInfo,
474
+ };
475
+
476
+ setSystemInfo(systemInfo);
477
+ toast.success('System information updated');
478
+ } catch (error) {
479
+ toast.error('Failed to get system information');
480
+ console.error('Failed to get system information:', error);
481
+ } finally {
482
+ setLoading((prev) => ({ ...prev, systemInfo: false }));
483
+ }
484
+ };
485
+
486
+ const getWebAppInfo = async () => {
487
+ try {
488
+ setLoading((prev) => ({ ...prev, webAppInfo: true }));
489
+
490
+ const [appResponse, gitResponse] = await Promise.all([
491
+ fetch('/api/system/app-info'),
492
+ fetch('/api/system/git-info'),
493
+ ]);
494
+
495
+ if (!appResponse.ok || !gitResponse.ok) {
496
+ throw new Error('Failed to fetch webapp info');
497
+ }
498
+
499
+ const appData = (await appResponse.json()) as Omit<WebAppInfo, 'gitInfo'>;
500
+ const gitData = (await gitResponse.json()) as GitInfo;
501
+
502
+ console.log('Git Info Response:', gitData); // Add logging to debug
503
+
504
+ setWebAppInfo({
505
+ ...appData,
506
+ gitInfo: gitData,
507
+ });
508
+
509
+ toast.success('WebApp information updated');
510
+
511
+ return true;
512
+ } catch (error) {
513
+ console.error('Failed to fetch webapp info:', error);
514
+ toast.error('Failed to fetch webapp information');
515
+ setWebAppInfo(null);
516
+
517
+ return false;
518
+ } finally {
519
+ setLoading((prev) => ({ ...prev, webAppInfo: false }));
520
+ }
521
+ };
522
+
523
+ // Helper function to format bytes to human readable format
524
+ const formatBytes = (bytes: number) => {
525
+ const units = ['B', 'KB', 'MB', 'GB'];
526
+ let size = bytes;
527
+ let unitIndex = 0;
528
+
529
+ while (size >= 1024 && unitIndex < units.length - 1) {
530
+ size /= 1024;
531
+ unitIndex++;
532
+ }
533
+
534
+ return `${Math.round(size)} ${units[unitIndex]}`;
535
+ };
536
+
537
+ const handleLogPerformance = () => {
538
+ try {
539
+ setLoading((prev) => ({ ...prev, performance: true }));
540
+
541
+ // Get performance metrics using modern Performance API
542
+ const performanceEntries = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
543
+ const memory = (performance as any).memory;
544
+
545
+ // Calculate timing metrics
546
+ const timingMetrics = {
547
+ loadTime: performanceEntries.loadEventEnd - performanceEntries.startTime,
548
+ domReadyTime: performanceEntries.domContentLoadedEventEnd - performanceEntries.startTime,
549
+ fetchTime: performanceEntries.responseEnd - performanceEntries.fetchStart,
550
+ redirectTime: performanceEntries.redirectEnd - performanceEntries.redirectStart,
551
+ dnsTime: performanceEntries.domainLookupEnd - performanceEntries.domainLookupStart,
552
+ tcpTime: performanceEntries.connectEnd - performanceEntries.connectStart,
553
+ ttfb: performanceEntries.responseStart - performanceEntries.requestStart,
554
+ processingTime: performanceEntries.loadEventEnd - performanceEntries.responseEnd,
555
+ };
556
+
557
+ // Get resource timing data
558
+ const resourceEntries = performance.getEntriesByType('resource');
559
+ const resourceStats = {
560
+ totalResources: resourceEntries.length,
561
+ totalSize: resourceEntries.reduce((total, entry) => total + ((entry as any).transferSize || 0), 0),
562
+ totalTime: Math.max(...resourceEntries.map((entry) => entry.duration)),
563
+ };
564
+
565
+ // Get memory metrics
566
+ const memoryMetrics = memory
567
+ ? {
568
+ jsHeapSizeLimit: memory.jsHeapSizeLimit,
569
+ totalJSHeapSize: memory.totalJSHeapSize,
570
+ usedJSHeapSize: memory.usedJSHeapSize,
571
+ heapUtilization: (memory.usedJSHeapSize / memory.totalJSHeapSize) * 100,
572
+ }
573
+ : null;
574
+
575
+ // Get frame rate metrics
576
+ let fps = 0;
577
+
578
+ if ('requestAnimationFrame' in window) {
579
+ const times: number[] = [];
580
+
581
+ function calculateFPS(now: number) {
582
+ times.push(now);
583
+
584
+ if (times.length > 10) {
585
+ const fps = Math.round((1000 * 10) / (now - times[0]));
586
+ times.shift();
587
+
588
+ return fps;
589
+ }
590
+
591
+ requestAnimationFrame(calculateFPS);
592
+
593
+ return 0;
594
+ }
595
+
596
+ fps = calculateFPS(performance.now());
597
+ }
598
+
599
+ // Log all performance metrics
600
+ logStore.logSystem('Performance Metrics', {
601
+ timing: timingMetrics,
602
+ resources: resourceStats,
603
+ memory: memoryMetrics,
604
+ fps,
605
+ timestamp: new Date().toISOString(),
606
+ navigationEntry: {
607
+ type: performanceEntries.type,
608
+ redirectCount: performanceEntries.redirectCount,
609
+ },
610
+ });
611
+
612
+ toast.success('Performance metrics logged');
613
+ } catch (error) {
614
+ toast.error('Failed to log performance metrics');
615
+ console.error('Failed to log performance metrics:', error);
616
+ } finally {
617
+ setLoading((prev) => ({ ...prev, performance: false }));
618
+ }
619
+ };
620
+
621
+ const checkErrors = async () => {
622
+ try {
623
+ setLoading((prev) => ({ ...prev, errors: true }));
624
+
625
+ // Get errors from log store
626
+ const storedErrors = errorLogs;
627
+
628
+ if (storedErrors.length === 0) {
629
+ toast.success('No errors found');
630
+ } else {
631
+ toast.warning(`Found ${storedErrors.length} error(s)`);
632
+ }
633
+ } catch (error) {
634
+ toast.error('Failed to check errors');
635
+ console.error('Failed to check errors:', error);
636
+ } finally {
637
+ setLoading((prev) => ({ ...prev, errors: false }));
638
+ }
639
+ };
640
+
641
+ const exportDebugInfo = () => {
642
+ try {
643
+ const debugData = {
644
+ timestamp: new Date().toISOString(),
645
+ system: systemInfo,
646
+ webApp: webAppInfo,
647
+ errors: logStore.getLogs().filter((log: LogEntry) => log.level === 'error'),
648
+ performance: {
649
+ memory: (performance as any).memory || {},
650
+ timing: performance.timing,
651
+ navigation: performance.navigation,
652
+ },
653
+ };
654
+
655
+ const blob = new Blob([JSON.stringify(debugData, null, 2)], { type: 'application/json' });
656
+ const url = window.URL.createObjectURL(blob);
657
+ const a = document.createElement('a');
658
+ a.href = url;
659
+ a.download = `bolt-debug-info-${new Date().toISOString()}.json`;
660
+ document.body.appendChild(a);
661
+ a.click();
662
+ window.URL.revokeObjectURL(url);
663
+ document.body.removeChild(a);
664
+ toast.success('Debug information exported successfully');
665
+ } catch (error) {
666
+ console.error('Failed to export debug info:', error);
667
+ toast.error('Failed to export debug information');
668
+ }
669
+ };
670
+
671
+ const exportAsCSV = () => {
672
+ try {
673
+ const debugData = {
674
+ system: systemInfo,
675
+ webApp: webAppInfo,
676
+ errors: logStore.getLogs().filter((log: LogEntry) => log.level === 'error'),
677
+ performance: {
678
+ memory: (performance as any).memory || {},
679
+ timing: performance.timing,
680
+ navigation: performance.navigation,
681
+ },
682
+ };
683
+
684
+ // Convert the data to CSV format
685
+ const csvData = [
686
+ ['Category', 'Key', 'Value'],
687
+ ...Object.entries(debugData).flatMap(([category, data]) =>
688
+ Object.entries(data || {}).map(([key, value]) => [
689
+ category,
690
+ key,
691
+ typeof value === 'object' ? JSON.stringify(value) : String(value),
692
+ ]),
693
+ ),
694
+ ];
695
+
696
+ // Create CSV content
697
+ const csvContent = csvData.map((row) => row.join(',')).join('\n');
698
+ const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
699
+ const url = window.URL.createObjectURL(blob);
700
+ const a = document.createElement('a');
701
+ a.href = url;
702
+ a.download = `bolt-debug-info-${new Date().toISOString()}.csv`;
703
+ document.body.appendChild(a);
704
+ a.click();
705
+ window.URL.revokeObjectURL(url);
706
+ document.body.removeChild(a);
707
+ toast.success('Debug information exported as CSV');
708
+ } catch (error) {
709
+ console.error('Failed to export CSV:', error);
710
+ toast.error('Failed to export debug information as CSV');
711
+ }
712
+ };
713
+
714
+ const exportAsPDF = () => {
715
+ try {
716
+ const debugData = {
717
+ system: systemInfo,
718
+ webApp: webAppInfo,
719
+ errors: logStore.getLogs().filter((log: LogEntry) => log.level === 'error'),
720
+ performance: {
721
+ memory: (performance as any).memory || {},
722
+ timing: performance.timing,
723
+ navigation: performance.navigation,
724
+ },
725
+ };
726
+
727
+ // Create new PDF document
728
+ const doc = new jsPDF();
729
+ const lineHeight = 7;
730
+ let yPos = 20;
731
+ const margin = 20;
732
+ const pageWidth = doc.internal.pageSize.getWidth();
733
+ const maxLineWidth = pageWidth - 2 * margin;
734
+
735
+ // Add key-value pair with better formatting
736
+ const addKeyValue = (key: string, value: any, indent = 0) => {
737
+ // Check if we need a new page
738
+ if (yPos > doc.internal.pageSize.getHeight() - 20) {
739
+ doc.addPage();
740
+ yPos = margin;
741
+ }
742
+
743
+ doc.setFontSize(10);
744
+ doc.setTextColor('#374151');
745
+ doc.setFont('helvetica', 'bold');
746
+
747
+ // Format the key with proper spacing
748
+ const formattedKey = key.replace(/([A-Z])/g, ' $1').trim();
749
+ doc.text(formattedKey + ':', margin + indent, yPos);
750
+ doc.setFont('helvetica', 'normal');
751
+ doc.setTextColor('#6B7280');
752
+
753
+ let valueText;
754
+
755
+ if (typeof value === 'object' && value !== null) {
756
+ // Skip rendering if value is empty object
757
+ if (Object.keys(value).length === 0) {
758
+ return;
759
+ }
760
+
761
+ yPos += lineHeight;
762
+ Object.entries(value).forEach(([subKey, subValue]) => {
763
+ // Check for page break before each sub-item
764
+ if (yPos > doc.internal.pageSize.getHeight() - 20) {
765
+ doc.addPage();
766
+ yPos = margin;
767
+ }
768
+
769
+ const formattedSubKey = subKey.replace(/([A-Z])/g, ' $1').trim();
770
+ addKeyValue(formattedSubKey, subValue, indent + 10);
771
+ });
772
+
773
+ return;
774
+ } else {
775
+ valueText = String(value);
776
+ }
777
+
778
+ const valueX = margin + indent + doc.getTextWidth(formattedKey + ': ');
779
+ const maxValueWidth = maxLineWidth - indent - doc.getTextWidth(formattedKey + ': ');
780
+ const lines = doc.splitTextToSize(valueText, maxValueWidth);
781
+
782
+ // Check if we need a new page for the value
783
+ if (yPos + lines.length * lineHeight > doc.internal.pageSize.getHeight() - 20) {
784
+ doc.addPage();
785
+ yPos = margin;
786
+ }
787
+
788
+ doc.text(lines, valueX, yPos);
789
+ yPos += lines.length * lineHeight;
790
+ };
791
+
792
+ // Add section header with page break check
793
+ const addSectionHeader = (title: string) => {
794
+ // Check if we need a new page
795
+ if (yPos + 20 > doc.internal.pageSize.getHeight() - 20) {
796
+ doc.addPage();
797
+ yPos = margin;
798
+ }
799
+
800
+ yPos += lineHeight;
801
+ doc.setFillColor('#F3F4F6');
802
+ doc.rect(margin - 2, yPos - 5, pageWidth - 2 * (margin - 2), lineHeight + 6, 'F');
803
+ doc.setFont('helvetica', 'bold');
804
+ doc.setTextColor('#111827');
805
+ doc.setFontSize(12);
806
+ doc.text(title.toUpperCase(), margin, yPos);
807
+ doc.setFont('helvetica', 'normal');
808
+ yPos += lineHeight * 1.5;
809
+ };
810
+
811
+ // Add horizontal line with page break check
812
+ const addHorizontalLine = () => {
813
+ // Check if we need a new page
814
+ if (yPos + 10 > doc.internal.pageSize.getHeight() - 20) {
815
+ doc.addPage();
816
+ yPos = margin;
817
+
818
+ return; // Skip drawing line if we just started a new page
819
+ }
820
+
821
+ doc.setDrawColor('#E5E5E5');
822
+ doc.line(margin, yPos, pageWidth - margin, yPos);
823
+ yPos += lineHeight;
824
+ };
825
+
826
+ // Helper function to add footer to all pages
827
+ const addFooters = () => {
828
+ const totalPages = doc.internal.pages.length - 1;
829
+
830
+ for (let i = 1; i <= totalPages; i++) {
831
+ doc.setPage(i);
832
+ doc.setFontSize(8);
833
+ doc.setTextColor('#9CA3AF');
834
+ doc.text(`Page ${i} of ${totalPages}`, pageWidth / 2, doc.internal.pageSize.getHeight() - 10, {
835
+ align: 'center',
836
+ });
837
+ }
838
+ };
839
+
840
+ // Title and Header (first page only)
841
+ doc.setFillColor('#6366F1');
842
+ doc.rect(0, 0, pageWidth, 40, 'F');
843
+ doc.setTextColor('#FFFFFF');
844
+ doc.setFontSize(24);
845
+ doc.setFont('helvetica', 'bold');
846
+ doc.text('Debug Information Report', margin, 25);
847
+ yPos = 50;
848
+
849
+ // Timestamp and metadata
850
+ doc.setTextColor('#6B7280');
851
+ doc.setFontSize(10);
852
+ doc.setFont('helvetica', 'normal');
853
+
854
+ const timestamp = new Date().toLocaleString(undefined, {
855
+ year: 'numeric',
856
+ month: '2-digit',
857
+ day: '2-digit',
858
+ hour: '2-digit',
859
+ minute: '2-digit',
860
+ second: '2-digit',
861
+ });
862
+ doc.text(`Generated: ${timestamp}`, margin, yPos);
863
+ yPos += lineHeight * 2;
864
+
865
+ // System Information Section
866
+ if (debugData.system) {
867
+ addSectionHeader('System Information');
868
+
869
+ // OS and Architecture
870
+ addKeyValue('Operating System', debugData.system.os);
871
+ addKeyValue('Architecture', debugData.system.arch);
872
+ addKeyValue('Platform', debugData.system.platform);
873
+ addKeyValue('CPU Cores', debugData.system.cpus);
874
+
875
+ // Memory
876
+ const memory = debugData.system.memory;
877
+ addKeyValue('Memory', {
878
+ 'Total Memory': memory.total,
879
+ 'Used Memory': memory.used,
880
+ 'Free Memory': memory.free,
881
+ Usage: memory.percentage + '%',
882
+ });
883
+
884
+ // Browser Information
885
+ const browser = debugData.system.browser;
886
+ addKeyValue('Browser', {
887
+ Name: browser.name,
888
+ Version: browser.version,
889
+ Language: browser.language,
890
+ Platform: browser.platform,
891
+ 'Cookies Enabled': browser.cookiesEnabled ? 'Yes' : 'No',
892
+ 'Online Status': browser.online ? 'Online' : 'Offline',
893
+ });
894
+
895
+ // Screen Information
896
+ const screen = debugData.system.screen;
897
+ addKeyValue('Screen', {
898
+ Resolution: `${screen.width}x${screen.height}`,
899
+ 'Color Depth': screen.colorDepth + ' bit',
900
+ 'Pixel Ratio': screen.pixelRatio + 'x',
901
+ });
902
+
903
+ // Time Information
904
+ const time = debugData.system.time;
905
+ addKeyValue('Time Settings', {
906
+ Timezone: time.timezone,
907
+ 'UTC Offset': time.offset / 60 + ' hours',
908
+ Locale: time.locale,
909
+ });
910
+
911
+ addHorizontalLine();
912
+ }
913
+
914
+ // Web App Information Section
915
+ if (debugData.webApp) {
916
+ addSectionHeader('Web App Information');
917
+
918
+ // Basic Info
919
+ addKeyValue('Application', {
920
+ Name: debugData.webApp.name,
921
+ Version: debugData.webApp.version,
922
+ Environment: debugData.webApp.environment,
923
+ 'Node Version': debugData.webApp.runtimeInfo.nodeVersion,
924
+ });
925
+
926
+ // Git Information
927
+ if (debugData.webApp.gitInfo) {
928
+ const gitInfo = debugData.webApp.gitInfo.local;
929
+ addKeyValue('Git Information', {
930
+ Branch: gitInfo.branch,
931
+ Commit: gitInfo.commitHash,
932
+ Author: gitInfo.author,
933
+ 'Commit Time': gitInfo.commitTime,
934
+ Repository: gitInfo.repoName,
935
+ });
936
+
937
+ if (debugData.webApp.gitInfo.github) {
938
+ const githubInfo = debugData.webApp.gitInfo.github.currentRepo;
939
+ addKeyValue('GitHub Information', {
940
+ Repository: githubInfo.fullName,
941
+ 'Default Branch': githubInfo.defaultBranch,
942
+ Stars: githubInfo.stars,
943
+ Forks: githubInfo.forks,
944
+ 'Open Issues': githubInfo.openIssues || 0,
945
+ });
946
+ }
947
+ }
948
+
949
+ addHorizontalLine();
950
+ }
951
+
952
+ // Performance Section
953
+ if (debugData.performance) {
954
+ addSectionHeader('Performance Metrics');
955
+
956
+ // Memory Usage
957
+ const memory = debugData.performance.memory || {};
958
+ const totalHeap = memory.totalJSHeapSize || 0;
959
+ const usedHeap = memory.usedJSHeapSize || 0;
960
+ const usagePercentage = memory.usagePercentage || 0;
961
+
962
+ addKeyValue('Memory Usage', {
963
+ 'Total Heap Size': formatBytes(totalHeap),
964
+ 'Used Heap Size': formatBytes(usedHeap),
965
+ Usage: usagePercentage.toFixed(1) + '%',
966
+ });
967
+
968
+ // Timing Metrics
969
+ const timing = debugData.performance.timing || {};
970
+ const navigationStart = timing.navigationStart || 0;
971
+ const loadEventEnd = timing.loadEventEnd || 0;
972
+ const domContentLoadedEventEnd = timing.domContentLoadedEventEnd || 0;
973
+ const responseEnd = timing.responseEnd || 0;
974
+ const requestStart = timing.requestStart || 0;
975
+
976
+ const loadTime = loadEventEnd > navigationStart ? loadEventEnd - navigationStart : 0;
977
+ const domReadyTime =
978
+ domContentLoadedEventEnd > navigationStart ? domContentLoadedEventEnd - navigationStart : 0;
979
+ const requestTime = responseEnd > requestStart ? responseEnd - requestStart : 0;
980
+
981
+ addKeyValue('Page Load Metrics', {
982
+ 'Total Load Time': (loadTime / 1000).toFixed(2) + ' seconds',
983
+ 'DOM Ready Time': (domReadyTime / 1000).toFixed(2) + ' seconds',
984
+ 'Request Time': (requestTime / 1000).toFixed(2) + ' seconds',
985
+ });
986
+
987
+ // Network Information
988
+ if (debugData.system?.network) {
989
+ const network = debugData.system.network;
990
+ addKeyValue('Network Information', {
991
+ 'Connection Type': network.type || 'Unknown',
992
+ 'Effective Type': network.effectiveType || 'Unknown',
993
+ 'Download Speed': (network.downlink || 0) + ' Mbps',
994
+ 'Latency (RTT)': (network.rtt || 0) + ' ms',
995
+ 'Data Saver': network.saveData ? 'Enabled' : 'Disabled',
996
+ });
997
+ }
998
+
999
+ addHorizontalLine();
1000
+ }
1001
+
1002
+ // Errors Section
1003
+ if (debugData.errors && debugData.errors.length > 0) {
1004
+ addSectionHeader('Error Log');
1005
+
1006
+ debugData.errors.forEach((error: LogEntry, index: number) => {
1007
+ doc.setTextColor('#DC2626');
1008
+ doc.setFontSize(10);
1009
+ doc.setFont('helvetica', 'bold');
1010
+ doc.text(`Error ${index + 1}:`, margin, yPos);
1011
+ yPos += lineHeight;
1012
+
1013
+ doc.setFont('helvetica', 'normal');
1014
+ doc.setTextColor('#6B7280');
1015
+ addKeyValue('Message', error.message, 10);
1016
+
1017
+ if (error.stack) {
1018
+ addKeyValue('Stack', error.stack, 10);
1019
+ }
1020
+
1021
+ if (error.source) {
1022
+ addKeyValue('Source', error.source, 10);
1023
+ }
1024
+
1025
+ yPos += lineHeight;
1026
+ });
1027
+ }
1028
+
1029
+ // Add footers to all pages at the end
1030
+ addFooters();
1031
+
1032
+ // Save the PDF
1033
+ doc.save(`bolt-debug-info-${new Date().toISOString()}.pdf`);
1034
+ toast.success('Debug information exported as PDF');
1035
+ } catch (error) {
1036
+ console.error('Failed to export PDF:', error);
1037
+ toast.error('Failed to export debug information as PDF');
1038
+ }
1039
+ };
1040
+
1041
+ const exportAsText = () => {
1042
+ try {
1043
+ const debugData = {
1044
+ system: systemInfo,
1045
+ webApp: webAppInfo,
1046
+ errors: logStore.getLogs().filter((log: LogEntry) => log.level === 'error'),
1047
+ performance: {
1048
+ memory: (performance as any).memory || {},
1049
+ timing: performance.timing,
1050
+ navigation: performance.navigation,
1051
+ },
1052
+ };
1053
+
1054
+ const textContent = Object.entries(debugData)
1055
+ .map(([category, data]) => {
1056
+ return `${category.toUpperCase()}\n${'-'.repeat(30)}\n${JSON.stringify(data, null, 2)}\n\n`;
1057
+ })
1058
+ .join('\n');
1059
+
1060
+ const blob = new Blob([textContent], { type: 'text/plain' });
1061
+ const url = window.URL.createObjectURL(blob);
1062
+ const a = document.createElement('a');
1063
+ a.href = url;
1064
+ a.download = `bolt-debug-info-${new Date().toISOString()}.txt`;
1065
+ document.body.appendChild(a);
1066
+ a.click();
1067
+ window.URL.revokeObjectURL(url);
1068
+ document.body.removeChild(a);
1069
+ toast.success('Debug information exported as text file');
1070
+ } catch (error) {
1071
+ console.error('Failed to export text file:', error);
1072
+ toast.error('Failed to export debug information as text file');
1073
+ }
1074
+ };
1075
+
1076
+ const exportFormats: ExportFormat[] = [
1077
+ {
1078
+ id: 'json',
1079
+ label: 'Export as JSON',
1080
+ icon: 'i-ph:file-json',
1081
+ handler: exportDebugInfo,
1082
+ },
1083
+ {
1084
+ id: 'csv',
1085
+ label: 'Export as CSV',
1086
+ icon: 'i-ph:file-csv',
1087
+ handler: exportAsCSV,
1088
+ },
1089
+ {
1090
+ id: 'pdf',
1091
+ label: 'Export as PDF',
1092
+ icon: 'i-ph:file-pdf',
1093
+ handler: exportAsPDF,
1094
+ },
1095
+ {
1096
+ id: 'txt',
1097
+ label: 'Export as Text',
1098
+ icon: 'i-ph:file-text',
1099
+ handler: exportAsText,
1100
+ },
1101
+ ];
1102
+
1103
+ // Add Ollama health check function
1104
+ const checkOllamaStatus = useCallback(async () => {
1105
+ try {
1106
+ const ollamaProvider = providers?.Ollama;
1107
+ const baseUrl = ollamaProvider?.settings?.baseUrl || 'http://127.0.0.1:11434';
1108
+
1109
+ // First check if service is running
1110
+ const versionResponse = await fetch(`${baseUrl}/api/version`);
1111
+
1112
+ if (!versionResponse.ok) {
1113
+ throw new Error('Service not running');
1114
+ }
1115
+
1116
+ // Then fetch installed models
1117
+ const modelsResponse = await fetch(`${baseUrl}/api/tags`);
1118
+
1119
+ const modelsData = (await modelsResponse.json()) as {
1120
+ models: Array<{ name: string; size: string; quantization: string }>;
1121
+ };
1122
+
1123
+ setOllamaStatus({
1124
+ isRunning: true,
1125
+ lastChecked: new Date(),
1126
+ models: modelsData.models,
1127
+ });
1128
+ } catch {
1129
+ setOllamaStatus({
1130
+ isRunning: false,
1131
+ error: 'Connection failed',
1132
+ lastChecked: new Date(),
1133
+ models: undefined,
1134
+ });
1135
+ }
1136
+ }, [providers]);
1137
+
1138
+ // Monitor Ollama provider status and check periodically
1139
+ useEffect(() => {
1140
+ const ollamaProvider = providers?.Ollama;
1141
+
1142
+ if (ollamaProvider?.settings?.enabled) {
1143
+ // Check immediately when provider is enabled
1144
+ checkOllamaStatus();
1145
+
1146
+ // Set up periodic checks every 10 seconds
1147
+ const intervalId = setInterval(checkOllamaStatus, 10000);
1148
+
1149
+ return () => clearInterval(intervalId);
1150
+ }
1151
+
1152
+ return undefined;
1153
+ }, [providers, checkOllamaStatus]);
1154
+
1155
+ // Replace the existing export button with this new component
1156
+ const ExportButton = () => {
1157
+ const [isOpen, setIsOpen] = useState(false);
1158
+
1159
+ const handleOpenChange = useCallback((open: boolean) => {
1160
+ setIsOpen(open);
1161
+ }, []);
1162
+
1163
+ const handleFormatClick = useCallback((handler: () => void) => {
1164
+ handler();
1165
+ setIsOpen(false);
1166
+ }, []);
1167
+
1168
+ return (
1169
+ <DialogRoot open={isOpen} onOpenChange={handleOpenChange}>
1170
+ <button
1171
+ onClick={() => setIsOpen(true)}
1172
+ className={classNames(
1173
+ 'group flex items-center gap-2',
1174
+ 'rounded-lg px-3 py-1.5',
1175
+ 'text-sm text-gray-900 dark:text-white',
1176
+ 'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
1177
+ 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
1178
+ 'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
1179
+ 'transition-all duration-200',
1180
+ )}
1181
+ >
1182
+ <span className="i-ph:download text-lg text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
1183
+ Export
1184
+ </button>
1185
+
1186
+ <Dialog showCloseButton>
1187
+ <div className="p-6">
1188
+ <DialogTitle className="flex items-center gap-2">
1189
+ <div className="i-ph:download w-5 h-5" />
1190
+ Export Debug Information
1191
+ </DialogTitle>
1192
+
1193
+ <div className="mt-4 flex flex-col gap-2">
1194
+ {exportFormats.map((format) => (
1195
+ <button
1196
+ key={format.id}
1197
+ onClick={() => handleFormatClick(format.handler)}
1198
+ className={classNames(
1199
+ 'flex items-center gap-3 px-4 py-3 text-sm rounded-lg transition-colors w-full text-left',
1200
+ 'bg-white dark:bg-[#0A0A0A]',
1201
+ 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
1202
+ 'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
1203
+ 'hover:border-purple-200 dark:hover:border-purple-900/30',
1204
+ 'text-bolt-elements-textPrimary',
1205
+ )}
1206
+ >
1207
+ <div className={classNames(format.icon, 'w-5 h-5')} />
1208
+ <div>
1209
+ <div className="font-medium">{format.label}</div>
1210
+ <div className="text-xs text-bolt-elements-textSecondary mt-0.5">
1211
+ {format.id === 'json' && 'Export as a structured JSON file'}
1212
+ {format.id === 'csv' && 'Export as a CSV spreadsheet'}
1213
+ {format.id === 'pdf' && 'Export as a formatted PDF document'}
1214
+ {format.id === 'txt' && 'Export as a formatted text file'}
1215
+ </div>
1216
+ </div>
1217
+ </button>
1218
+ ))}
1219
+ </div>
1220
+ </div>
1221
+ </Dialog>
1222
+ </DialogRoot>
1223
+ );
1224
+ };
1225
+
1226
+ // Add helper function to get Ollama status text and color
1227
+ const getOllamaStatus = () => {
1228
+ const ollamaProvider = providers?.Ollama;
1229
+ const isOllamaEnabled = ollamaProvider?.settings?.enabled;
1230
+
1231
+ if (!isOllamaEnabled) {
1232
+ return {
1233
+ status: 'Disabled',
1234
+ color: 'text-red-500',
1235
+ bgColor: 'bg-red-500',
1236
+ message: 'Ollama provider is disabled in settings',
1237
+ };
1238
+ }
1239
+
1240
+ if (!ollamaStatus.isRunning) {
1241
+ return {
1242
+ status: 'Not Running',
1243
+ color: 'text-red-500',
1244
+ bgColor: 'bg-red-500',
1245
+ message: ollamaStatus.error || 'Ollama service is not running',
1246
+ };
1247
+ }
1248
+
1249
+ const modelCount = ollamaStatus.models?.length ?? 0;
1250
+
1251
+ return {
1252
+ status: 'Running',
1253
+ color: 'text-green-500',
1254
+ bgColor: 'bg-green-500',
1255
+ message: `Ollama service is running with ${modelCount} installed models (Provider: Enabled)`,
1256
+ };
1257
+ };
1258
+
1259
+ // Add type for status result
1260
+ type StatusResult = {
1261
+ status: string;
1262
+ color: string;
1263
+ bgColor: string;
1264
+ message: string;
1265
+ };
1266
+
1267
+ const status = getOllamaStatus() as StatusResult;
1268
+
1269
+ return (
1270
+ <div className="flex flex-col gap-6 max-w-7xl mx-auto p-4">
1271
+ {/* Quick Stats Banner */}
1272
+ <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
1273
+ {/* Errors Card */}
1274
+ <div className="p-4 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] hover:border-purple-500/30 transition-all duration-200 h-[180px] flex flex-col">
1275
+ <div className="flex items-center gap-2">
1276
+ <div className="i-ph:warning-octagon text-purple-500 w-4 h-4" />
1277
+ <div className="text-sm text-bolt-elements-textSecondary">Errors</div>
1278
+ </div>
1279
+ <div className="flex items-center gap-2 mt-2">
1280
+ <span
1281
+ className={classNames('text-2xl font-semibold', errorLogs.length > 0 ? 'text-red-500' : 'text-green-500')}
1282
+ >
1283
+ {errorLogs.length}
1284
+ </span>
1285
+ </div>
1286
+ <div className="text-xs text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
1287
+ <div
1288
+ className={classNames(
1289
+ 'w-3.5 h-3.5',
1290
+ errorLogs.length > 0 ? 'i-ph:warning text-red-500' : 'i-ph:check-circle text-green-500',
1291
+ )}
1292
+ />
1293
+ {errorLogs.length > 0 ? 'Errors detected' : 'No errors detected'}
1294
+ </div>
1295
+ </div>
1296
+
1297
+ {/* Memory Usage Card */}
1298
+ <div className="p-4 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] hover:border-purple-500/30 transition-all duration-200 h-[180px] flex flex-col">
1299
+ <div className="flex items-center gap-2">
1300
+ <div className="i-ph:cpu text-purple-500 w-4 h-4" />
1301
+ <div className="text-sm text-bolt-elements-textSecondary">Memory Usage</div>
1302
+ </div>
1303
+ <div className="flex items-center gap-2 mt-2">
1304
+ <span
1305
+ className={classNames(
1306
+ 'text-2xl font-semibold',
1307
+ (systemInfo?.memory?.percentage ?? 0) > 80
1308
+ ? 'text-red-500'
1309
+ : (systemInfo?.memory?.percentage ?? 0) > 60
1310
+ ? 'text-yellow-500'
1311
+ : 'text-green-500',
1312
+ )}
1313
+ >
1314
+ {systemInfo?.memory?.percentage ?? 0}%
1315
+ </span>
1316
+ </div>
1317
+ <Progress
1318
+ value={systemInfo?.memory?.percentage ?? 0}
1319
+ className={classNames(
1320
+ 'mt-2',
1321
+ (systemInfo?.memory?.percentage ?? 0) > 80
1322
+ ? '[&>div]:bg-red-500'
1323
+ : (systemInfo?.memory?.percentage ?? 0) > 60
1324
+ ? '[&>div]:bg-yellow-500'
1325
+ : '[&>div]:bg-green-500',
1326
+ )}
1327
+ />
1328
+ <div className="text-xs text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
1329
+ <div className="i-ph:info w-3.5 h-3.5 text-purple-500" />
1330
+ Used: {systemInfo?.memory.used ?? '0 GB'} / {systemInfo?.memory.total ?? '0 GB'}
1331
+ </div>
1332
+ </div>
1333
+
1334
+ {/* Page Load Time Card */}
1335
+ <div className="p-4 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] hover:border-purple-500/30 transition-all duration-200 h-[180px] flex flex-col">
1336
+ <div className="flex items-center gap-2">
1337
+ <div className="i-ph:timer text-purple-500 w-4 h-4" />
1338
+ <div className="text-sm text-bolt-elements-textSecondary">Page Load Time</div>
1339
+ </div>
1340
+ <div className="flex items-center gap-2 mt-2">
1341
+ <span
1342
+ className={classNames(
1343
+ 'text-2xl font-semibold',
1344
+ (systemInfo?.performance.timing.loadTime ?? 0) > 2000
1345
+ ? 'text-red-500'
1346
+ : (systemInfo?.performance.timing.loadTime ?? 0) > 1000
1347
+ ? 'text-yellow-500'
1348
+ : 'text-green-500',
1349
+ )}
1350
+ >
1351
+ {systemInfo ? (systemInfo.performance.timing.loadTime / 1000).toFixed(2) : '-'}s
1352
+ </span>
1353
+ </div>
1354
+ <div className="text-xs text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
1355
+ <div className="i-ph:code w-3.5 h-3.5 text-purple-500" />
1356
+ DOM Ready: {systemInfo ? (systemInfo.performance.timing.domReadyTime / 1000).toFixed(2) : '-'}s
1357
+ </div>
1358
+ </div>
1359
+
1360
+ {/* Network Speed Card */}
1361
+ <div className="p-4 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] hover:border-purple-500/30 transition-all duration-200 h-[180px] flex flex-col">
1362
+ <div className="flex items-center gap-2">
1363
+ <div className="i-ph:wifi-high text-purple-500 w-4 h-4" />
1364
+ <div className="text-sm text-bolt-elements-textSecondary">Network Speed</div>
1365
+ </div>
1366
+ <div className="flex items-center gap-2 mt-2">
1367
+ <span
1368
+ className={classNames(
1369
+ 'text-2xl font-semibold',
1370
+ (systemInfo?.network.downlink ?? 0) < 5
1371
+ ? 'text-red-500'
1372
+ : (systemInfo?.network.downlink ?? 0) < 10
1373
+ ? 'text-yellow-500'
1374
+ : 'text-green-500',
1375
+ )}
1376
+ >
1377
+ {systemInfo?.network.downlink ?? '-'} Mbps
1378
+ </span>
1379
+ </div>
1380
+ <div className="text-xs text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
1381
+ <div className="i-ph:activity w-3.5 h-3.5 text-purple-500" />
1382
+ RTT: {systemInfo?.network.rtt ?? '-'} ms
1383
+ </div>
1384
+ </div>
1385
+
1386
+ {/* Ollama Service Card - Now spans all 4 columns */}
1387
+ <div className="md:col-span-4 p-6 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] hover:border-purple-500/30 transition-all duration-200 h-[260px] flex flex-col">
1388
+ <div className="flex items-center justify-between">
1389
+ <div className="flex items-center gap-3">
1390
+ <div className="i-ph:robot text-purple-500 w-5 h-5" />
1391
+ <div>
1392
+ <div className="text-base font-medium text-bolt-elements-textPrimary">Ollama Service</div>
1393
+ <div className="text-xs text-bolt-elements-textSecondary mt-0.5">{status.message}</div>
1394
+ </div>
1395
+ </div>
1396
+ <div className="flex items-center gap-3">
1397
+ <div className="flex items-center gap-2 px-2.5 py-1 rounded-full bg-bolt-elements-background-depth-3">
1398
+ <div
1399
+ className={classNames('w-2 h-2 rounded-full animate-pulse', status.bgColor, {
1400
+ 'shadow-lg shadow-green-500/20': status.status === 'Running',
1401
+ 'shadow-lg shadow-red-500/20': status.status === 'Not Running',
1402
+ })}
1403
+ />
1404
+ <span className={classNames('text-xs font-medium flex items-center gap-1', status.color)}>
1405
+ {status.status}
1406
+ </span>
1407
+ </div>
1408
+ <div className="text-[10px] text-bolt-elements-textTertiary flex items-center gap-1.5">
1409
+ <div className="i-ph:clock w-3 h-3" />
1410
+ {ollamaStatus.lastChecked.toLocaleTimeString()}
1411
+ </div>
1412
+ </div>
1413
+ </div>
1414
+
1415
+ <div className="mt-6 flex-1 min-h-0 flex flex-col">
1416
+ {status.status === 'Running' && ollamaStatus.models && ollamaStatus.models.length > 0 ? (
1417
+ <>
1418
+ <div className="text-xs font-medium text-bolt-elements-textSecondary flex items-center justify-between mb-3">
1419
+ <div className="flex items-center gap-2">
1420
+ <div className="i-ph:cube-duotone w-4 h-4 text-purple-500" />
1421
+ <span>Installed Models</span>
1422
+ <Badge variant="secondary" className="ml-1">
1423
+ {ollamaStatus.models.length}
1424
+ </Badge>
1425
+ </div>
1426
+ </div>
1427
+ <div className="overflow-y-auto flex-1 scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-400 dark:hover:scrollbar-thumb-gray-600">
1428
+ <div className="grid grid-cols-2 gap-3 pr-2">
1429
+ {ollamaStatus.models.map((model) => (
1430
+ <div
1431
+ key={model.name}
1432
+ className="text-sm bg-bolt-elements-background-depth-3 hover:bg-bolt-elements-background-depth-4 rounded-lg px-4 py-3 flex items-center justify-between transition-colors group"
1433
+ >
1434
+ <div className="flex items-center gap-2 text-bolt-elements-textSecondary">
1435
+ <div className="i-ph:cube w-4 h-4 text-purple-500/70 group-hover:text-purple-500 transition-colors" />
1436
+ <span className="font-mono truncate">{model.name}</span>
1437
+ </div>
1438
+ <Badge variant="outline" className="ml-2 text-xs font-mono">
1439
+ {Math.round(parseInt(model.size) / 1024 / 1024)}MB
1440
+ </Badge>
1441
+ </div>
1442
+ ))}
1443
+ </div>
1444
+ </div>
1445
+ </>
1446
+ ) : (
1447
+ <div className="flex-1 flex items-center justify-center">
1448
+ <div className="flex flex-col items-center gap-3 max-w-[280px] text-center">
1449
+ <div
1450
+ className={classNames('w-12 h-12', {
1451
+ 'i-ph:warning-circle text-red-500/80':
1452
+ status.status === 'Not Running' || status.status === 'Disabled',
1453
+ 'i-ph:cube-duotone text-purple-500/80': status.status === 'Running',
1454
+ })}
1455
+ />
1456
+ <span className="text-sm text-bolt-elements-textSecondary">{status.message}</span>
1457
+ </div>
1458
+ </div>
1459
+ )}
1460
+ </div>
1461
+ </div>
1462
+ </div>
1463
+
1464
+ {/* Action Buttons */}
1465
+ <div className="flex flex-wrap gap-4">
1466
+ <button
1467
+ onClick={getSystemInfo}
1468
+ disabled={loading.systemInfo}
1469
+ className={classNames(
1470
+ 'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
1471
+ 'bg-white dark:bg-[#0A0A0A]',
1472
+ 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
1473
+ 'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
1474
+ 'hover:border-purple-200 dark:hover:border-purple-900/30',
1475
+ 'text-bolt-elements-textPrimary',
1476
+ { 'opacity-50 cursor-not-allowed': loading.systemInfo },
1477
+ )}
1478
+ >
1479
+ {loading.systemInfo ? (
1480
+ <div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
1481
+ ) : (
1482
+ <div className="i-ph:gear w-4 h-4" />
1483
+ )}
1484
+ Update System Info
1485
+ </button>
1486
+
1487
+ <button
1488
+ onClick={handleLogPerformance}
1489
+ disabled={loading.performance}
1490
+ className={classNames(
1491
+ 'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
1492
+ 'bg-white dark:bg-[#0A0A0A]',
1493
+ 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
1494
+ 'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
1495
+ 'hover:border-purple-200 dark:hover:border-purple-900/30',
1496
+ 'text-bolt-elements-textPrimary',
1497
+ { 'opacity-50 cursor-not-allowed': loading.performance },
1498
+ )}
1499
+ >
1500
+ {loading.performance ? (
1501
+ <div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
1502
+ ) : (
1503
+ <div className="i-ph:chart-bar w-4 h-4" />
1504
+ )}
1505
+ Log Performance
1506
+ </button>
1507
+
1508
+ <button
1509
+ onClick={checkErrors}
1510
+ disabled={loading.errors}
1511
+ className={classNames(
1512
+ 'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
1513
+ 'bg-white dark:bg-[#0A0A0A]',
1514
+ 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
1515
+ 'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
1516
+ 'hover:border-purple-200 dark:hover:border-purple-900/30',
1517
+ 'text-bolt-elements-textPrimary',
1518
+ { 'opacity-50 cursor-not-allowed': loading.errors },
1519
+ )}
1520
+ >
1521
+ {loading.errors ? (
1522
+ <div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
1523
+ ) : (
1524
+ <div className="i-ph:warning w-4 h-4" />
1525
+ )}
1526
+ Check Errors
1527
+ </button>
1528
+
1529
+ <button
1530
+ onClick={getWebAppInfo}
1531
+ disabled={loading.webAppInfo}
1532
+ className={classNames(
1533
+ 'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
1534
+ 'bg-white dark:bg-[#0A0A0A]',
1535
+ 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
1536
+ 'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
1537
+ 'hover:border-purple-200 dark:hover:border-purple-900/30',
1538
+ 'text-bolt-elements-textPrimary',
1539
+ { 'opacity-50 cursor-not-allowed': loading.webAppInfo },
1540
+ )}
1541
+ >
1542
+ {loading.webAppInfo ? (
1543
+ <div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
1544
+ ) : (
1545
+ <div className="i-ph:info w-4 h-4" />
1546
+ )}
1547
+ Fetch WebApp Info
1548
+ </button>
1549
+
1550
+ <ExportButton />
1551
+ </div>
1552
+
1553
+ {/* System Information */}
1554
+ <Collapsible
1555
+ open={openSections.system}
1556
+ onOpenChange={(open: boolean) => setOpenSections((prev) => ({ ...prev, system: open }))}
1557
+ className="w-full"
1558
+ >
1559
+ <CollapsibleTrigger className="w-full">
1560
+ <div className="flex items-center justify-between p-6 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
1561
+ <div className="flex items-center gap-3">
1562
+ <div className="i-ph:cpu text-purple-500 w-5 h-5" />
1563
+ <h3 className="text-base font-medium text-bolt-elements-textPrimary">System Information</h3>
1564
+ </div>
1565
+ <div
1566
+ className={classNames(
1567
+ 'i-ph:caret-down w-4 h-4 transform transition-transform duration-200',
1568
+ openSections.system ? 'rotate-180' : '',
1569
+ )}
1570
+ />
1571
+ </div>
1572
+ </CollapsibleTrigger>
1573
+
1574
+ <CollapsibleContent>
1575
+ <div className="p-6 mt-2 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
1576
+ {systemInfo ? (
1577
+ <div className="grid grid-cols-2 gap-6">
1578
+ <div className="space-y-2">
1579
+ <div className="text-sm flex items-center gap-2">
1580
+ <div className="i-ph:desktop text-bolt-elements-textSecondary w-4 h-4" />
1581
+ <span className="text-bolt-elements-textSecondary">OS: </span>
1582
+ <span className="text-bolt-elements-textPrimary">{systemInfo.os}</span>
1583
+ </div>
1584
+ <div className="text-sm flex items-center gap-2">
1585
+ <div className="i-ph:device-mobile text-bolt-elements-textSecondary w-4 h-4" />
1586
+ <span className="text-bolt-elements-textSecondary">Platform: </span>
1587
+ <span className="text-bolt-elements-textPrimary">{systemInfo.platform}</span>
1588
+ </div>
1589
+ <div className="text-sm flex items-center gap-2">
1590
+ <div className="i-ph:microchip text-bolt-elements-textSecondary w-4 h-4" />
1591
+ <span className="text-bolt-elements-textSecondary">Architecture: </span>
1592
+ <span className="text-bolt-elements-textPrimary">{systemInfo.arch}</span>
1593
+ </div>
1594
+ <div className="text-sm flex items-center gap-2">
1595
+ <div className="i-ph:cpu text-bolt-elements-textSecondary w-4 h-4" />
1596
+ <span className="text-bolt-elements-textSecondary">CPU Cores: </span>
1597
+ <span className="text-bolt-elements-textPrimary">{systemInfo.cpus}</span>
1598
+ </div>
1599
+ <div className="text-sm flex items-center gap-2">
1600
+ <div className="i-ph:node text-bolt-elements-textSecondary w-4 h-4" />
1601
+ <span className="text-bolt-elements-textSecondary">Node Version: </span>
1602
+ <span className="text-bolt-elements-textPrimary">{systemInfo.node}</span>
1603
+ </div>
1604
+ <div className="text-sm flex items-center gap-2">
1605
+ <div className="i-ph:wifi-high text-bolt-elements-textSecondary w-4 h-4" />
1606
+ <span className="text-bolt-elements-textSecondary">Network Type: </span>
1607
+ <span className="text-bolt-elements-textPrimary">
1608
+ {systemInfo.network.type} ({systemInfo.network.effectiveType})
1609
+ </span>
1610
+ </div>
1611
+ <div className="text-sm flex items-center gap-2">
1612
+ <div className="i-ph:gauge text-bolt-elements-textSecondary w-4 h-4" />
1613
+ <span className="text-bolt-elements-textSecondary">Network Speed: </span>
1614
+ <span className="text-bolt-elements-textPrimary">
1615
+ {systemInfo.network.downlink}Mbps (RTT: {systemInfo.network.rtt}ms)
1616
+ </span>
1617
+ </div>
1618
+ {systemInfo.battery && (
1619
+ <div className="text-sm flex items-center gap-2">
1620
+ <div className="i-ph:battery-charging text-bolt-elements-textSecondary w-4 h-4" />
1621
+ <span className="text-bolt-elements-textSecondary">Battery: </span>
1622
+ <span className="text-bolt-elements-textPrimary">
1623
+ {systemInfo.battery.level.toFixed(1)}% {systemInfo.battery.charging ? '(Charging)' : ''}
1624
+ </span>
1625
+ </div>
1626
+ )}
1627
+ <div className="text-sm flex items-center gap-2">
1628
+ <div className="i-ph:hard-drive text-bolt-elements-textSecondary w-4 h-4" />
1629
+ <span className="text-bolt-elements-textSecondary">Storage: </span>
1630
+ <span className="text-bolt-elements-textPrimary">
1631
+ {(systemInfo.storage.usage / (1024 * 1024 * 1024)).toFixed(2)}GB /{' '}
1632
+ {(systemInfo.storage.quota / (1024 * 1024 * 1024)).toFixed(2)}GB
1633
+ </span>
1634
+ </div>
1635
+ </div>
1636
+ <div className="space-y-2">
1637
+ <div className="text-sm flex items-center gap-2">
1638
+ <div className="i-ph:database text-bolt-elements-textSecondary w-4 h-4" />
1639
+ <span className="text-bolt-elements-textSecondary">Memory Usage: </span>
1640
+ <span className="text-bolt-elements-textPrimary">
1641
+ {systemInfo.memory.used} / {systemInfo.memory.total} ({systemInfo.memory.percentage}%)
1642
+ </span>
1643
+ </div>
1644
+ <div className="text-sm flex items-center gap-2">
1645
+ <div className="i-ph:browser text-bolt-elements-textSecondary w-4 h-4" />
1646
+ <span className="text-bolt-elements-textSecondary">Browser: </span>
1647
+ <span className="text-bolt-elements-textPrimary">
1648
+ {systemInfo.browser.name} {systemInfo.browser.version}
1649
+ </span>
1650
+ </div>
1651
+ <div className="text-sm flex items-center gap-2">
1652
+ <div className="i-ph:monitor text-bolt-elements-textSecondary w-4 h-4" />
1653
+ <span className="text-bolt-elements-textSecondary">Screen: </span>
1654
+ <span className="text-bolt-elements-textPrimary">
1655
+ {systemInfo.screen.width}x{systemInfo.screen.height} ({systemInfo.screen.pixelRatio}x)
1656
+ </span>
1657
+ </div>
1658
+ <div className="text-sm flex items-center gap-2">
1659
+ <div className="i-ph:clock text-bolt-elements-textSecondary w-4 h-4" />
1660
+ <span className="text-bolt-elements-textSecondary">Timezone: </span>
1661
+ <span className="text-bolt-elements-textPrimary">{systemInfo.time.timezone}</span>
1662
+ </div>
1663
+ <div className="text-sm flex items-center gap-2">
1664
+ <div className="i-ph:translate text-bolt-elements-textSecondary w-4 h-4" />
1665
+ <span className="text-bolt-elements-textSecondary">Language: </span>
1666
+ <span className="text-bolt-elements-textPrimary">{systemInfo.browser.language}</span>
1667
+ </div>
1668
+ <div className="text-sm flex items-center gap-2">
1669
+ <div className="i-ph:chart-pie text-bolt-elements-textSecondary w-4 h-4" />
1670
+ <span className="text-bolt-elements-textSecondary">JS Heap: </span>
1671
+ <span className="text-bolt-elements-textPrimary">
1672
+ {(systemInfo.performance.memory.usedJSHeapSize / (1024 * 1024)).toFixed(1)}MB /{' '}
1673
+ {(systemInfo.performance.memory.totalJSHeapSize / (1024 * 1024)).toFixed(1)}MB (
1674
+ {systemInfo.performance.memory.usagePercentage.toFixed(1)}%)
1675
+ </span>
1676
+ </div>
1677
+ <div className="text-sm flex items-center gap-2">
1678
+ <div className="i-ph:timer text-bolt-elements-textSecondary w-4 h-4" />
1679
+ <span className="text-bolt-elements-textSecondary">Page Load: </span>
1680
+ <span className="text-bolt-elements-textPrimary">
1681
+ {(systemInfo.performance.timing.loadTime / 1000).toFixed(2)}s
1682
+ </span>
1683
+ </div>
1684
+ <div className="text-sm flex items-center gap-2">
1685
+ <div className="i-ph:code text-bolt-elements-textSecondary w-4 h-4" />
1686
+ <span className="text-bolt-elements-textSecondary">DOM Ready: </span>
1687
+ <span className="text-bolt-elements-textPrimary">
1688
+ {(systemInfo.performance.timing.domReadyTime / 1000).toFixed(2)}s
1689
+ </span>
1690
+ </div>
1691
+ </div>
1692
+ </div>
1693
+ ) : (
1694
+ <div className="text-sm text-bolt-elements-textSecondary">Loading system information...</div>
1695
+ )}
1696
+ </div>
1697
+ </CollapsibleContent>
1698
+ </Collapsible>
1699
+
1700
+ {/* Performance Metrics */}
1701
+ <Collapsible
1702
+ open={openSections.performance}
1703
+ onOpenChange={(open: boolean) => setOpenSections((prev) => ({ ...prev, performance: open }))}
1704
+ className="w-full"
1705
+ >
1706
+ <CollapsibleTrigger className="w-full">
1707
+ <div className="flex items-center justify-between p-6 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
1708
+ <div className="flex items-center gap-3">
1709
+ <div className="i-ph:chart-line text-purple-500 w-5 h-5" />
1710
+ <h3 className="text-base font-medium text-bolt-elements-textPrimary">Performance Metrics</h3>
1711
+ </div>
1712
+ <div
1713
+ className={classNames(
1714
+ 'i-ph:caret-down w-4 h-4 transform transition-transform duration-200',
1715
+ openSections.performance ? 'rotate-180' : '',
1716
+ )}
1717
+ />
1718
+ </div>
1719
+ </CollapsibleTrigger>
1720
+
1721
+ <CollapsibleContent>
1722
+ <div className="p-6 mt-2 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
1723
+ {systemInfo && (
1724
+ <div className="grid grid-cols-2 gap-4">
1725
+ <div className="space-y-2">
1726
+ <div className="text-sm">
1727
+ <span className="text-bolt-elements-textSecondary">Page Load Time: </span>
1728
+ <span className="text-bolt-elements-textPrimary">
1729
+ {(systemInfo.performance.timing.loadTime / 1000).toFixed(2)}s
1730
+ </span>
1731
+ </div>
1732
+ <div className="text-sm">
1733
+ <span className="text-bolt-elements-textSecondary">DOM Ready Time: </span>
1734
+ <span className="text-bolt-elements-textPrimary">
1735
+ {(systemInfo.performance.timing.domReadyTime / 1000).toFixed(2)}s
1736
+ </span>
1737
+ </div>
1738
+ <div className="text-sm">
1739
+ <span className="text-bolt-elements-textSecondary">Request Time: </span>
1740
+ <span className="text-bolt-elements-textPrimary">
1741
+ {(systemInfo.performance.timing.requestTime / 1000).toFixed(2)}s
1742
+ </span>
1743
+ </div>
1744
+ <div className="text-sm">
1745
+ <span className="text-bolt-elements-textSecondary">Redirect Time: </span>
1746
+ <span className="text-bolt-elements-textPrimary">
1747
+ {(systemInfo.performance.timing.redirectTime / 1000).toFixed(2)}s
1748
+ </span>
1749
+ </div>
1750
+ </div>
1751
+ <div className="space-y-2">
1752
+ <div className="text-sm">
1753
+ <span className="text-bolt-elements-textSecondary">JS Heap Usage: </span>
1754
+ <span className="text-bolt-elements-textPrimary">
1755
+ {(systemInfo.performance.memory.usedJSHeapSize / (1024 * 1024)).toFixed(1)}MB /{' '}
1756
+ {(systemInfo.performance.memory.totalJSHeapSize / (1024 * 1024)).toFixed(1)}MB
1757
+ </span>
1758
+ </div>
1759
+ <div className="text-sm">
1760
+ <span className="text-bolt-elements-textSecondary">Heap Utilization: </span>
1761
+ <span className="text-bolt-elements-textPrimary">
1762
+ {systemInfo.performance.memory.usagePercentage.toFixed(1)}%
1763
+ </span>
1764
+ </div>
1765
+ <div className="text-sm">
1766
+ <span className="text-bolt-elements-textSecondary">Navigation Type: </span>
1767
+ <span className="text-bolt-elements-textPrimary">
1768
+ {systemInfo.performance.navigation.type === 0
1769
+ ? 'Navigate'
1770
+ : systemInfo.performance.navigation.type === 1
1771
+ ? 'Reload'
1772
+ : systemInfo.performance.navigation.type === 2
1773
+ ? 'Back/Forward'
1774
+ : 'Other'}
1775
+ </span>
1776
+ </div>
1777
+ <div className="text-sm">
1778
+ <span className="text-bolt-elements-textSecondary">Redirects: </span>
1779
+ <span className="text-bolt-elements-textPrimary">
1780
+ {systemInfo.performance.navigation.redirectCount}
1781
+ </span>
1782
+ </div>
1783
+ </div>
1784
+ </div>
1785
+ )}
1786
+ </div>
1787
+ </CollapsibleContent>
1788
+ </Collapsible>
1789
+
1790
+ {/* WebApp Information */}
1791
+ <Collapsible
1792
+ open={openSections.webapp}
1793
+ onOpenChange={(open) => setOpenSections((prev) => ({ ...prev, webapp: open }))}
1794
+ className="w-full"
1795
+ >
1796
+ <CollapsibleTrigger className="w-full">
1797
+ <div className="flex items-center justify-between p-6 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
1798
+ <div className="flex items-center gap-3">
1799
+ <div className="i-ph:info text-blue-500 w-5 h-5" />
1800
+ <h3 className="text-base font-medium text-bolt-elements-textPrimary">WebApp Information</h3>
1801
+ {loading.webAppInfo && <span className="loading loading-spinner loading-sm" />}
1802
+ </div>
1803
+ <div
1804
+ className={classNames(
1805
+ 'i-ph:caret-down w-4 h-4 transform transition-transform duration-200',
1806
+ openSections.webapp ? 'rotate-180' : '',
1807
+ )}
1808
+ />
1809
+ </div>
1810
+ </CollapsibleTrigger>
1811
+
1812
+ <CollapsibleContent>
1813
+ <div className="p-6 mt-2 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
1814
+ {loading.webAppInfo ? (
1815
+ <div className="flex items-center justify-center p-8">
1816
+ <span className="loading loading-spinner loading-lg" />
1817
+ </div>
1818
+ ) : !webAppInfo ? (
1819
+ <div className="flex flex-col items-center justify-center p-8 text-bolt-elements-textSecondary">
1820
+ <div className="i-ph:warning-circle w-8 h-8 mb-2" />
1821
+ <p>Failed to load WebApp information</p>
1822
+ <button
1823
+ onClick={() => getWebAppInfo()}
1824
+ className="mt-4 px-4 py-2 text-sm bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
1825
+ >
1826
+ Retry
1827
+ </button>
1828
+ </div>
1829
+ ) : (
1830
+ <div className="grid grid-cols-2 gap-6">
1831
+ <div>
1832
+ <h3 className="mb-4 text-base font-medium text-bolt-elements-textPrimary">Basic Information</h3>
1833
+ <div className="space-y-3">
1834
+ <div className="text-sm flex items-center gap-2">
1835
+ <div className="i-ph:app-window text-bolt-elements-textSecondary w-4 h-4" />
1836
+ <span className="text-bolt-elements-textSecondary">Name:</span>
1837
+ <span className="text-bolt-elements-textPrimary">{webAppInfo.name}</span>
1838
+ </div>
1839
+ <div className="text-sm flex items-center gap-2">
1840
+ <div className="i-ph:tag text-bolt-elements-textSecondary w-4 h-4" />
1841
+ <span className="text-bolt-elements-textSecondary">Version:</span>
1842
+ <span className="text-bolt-elements-textPrimary">{webAppInfo.version}</span>
1843
+ </div>
1844
+ <div className="text-sm flex items-center gap-2">
1845
+ <div className="i-ph:certificate text-bolt-elements-textSecondary w-4 h-4" />
1846
+ <span className="text-bolt-elements-textSecondary">License:</span>
1847
+ <span className="text-bolt-elements-textPrimary">{webAppInfo.license}</span>
1848
+ </div>
1849
+ <div className="text-sm flex items-center gap-2">
1850
+ <div className="i-ph:cloud text-bolt-elements-textSecondary w-4 h-4" />
1851
+ <span className="text-bolt-elements-textSecondary">Environment:</span>
1852
+ <span className="text-bolt-elements-textPrimary">{webAppInfo.environment}</span>
1853
+ </div>
1854
+ <div className="text-sm flex items-center gap-2">
1855
+ <div className="i-ph:node text-bolt-elements-textSecondary w-4 h-4" />
1856
+ <span className="text-bolt-elements-textSecondary">Node Version:</span>
1857
+ <span className="text-bolt-elements-textPrimary">{webAppInfo.runtimeInfo.nodeVersion}</span>
1858
+ </div>
1859
+ </div>
1860
+ </div>
1861
+
1862
+ <div>
1863
+ <h3 className="mb-4 text-base font-medium text-bolt-elements-textPrimary">Git Information</h3>
1864
+ <div className="space-y-3">
1865
+ <div className="text-sm flex items-center gap-2">
1866
+ <div className="i-ph:git-branch text-bolt-elements-textSecondary w-4 h-4" />
1867
+ <span className="text-bolt-elements-textSecondary">Branch:</span>
1868
+ <span className="text-bolt-elements-textPrimary">{webAppInfo.gitInfo.local.branch}</span>
1869
+ </div>
1870
+ <div className="text-sm flex items-center gap-2">
1871
+ <div className="i-ph:git-commit text-bolt-elements-textSecondary w-4 h-4" />
1872
+ <span className="text-bolt-elements-textSecondary">Commit:</span>
1873
+ <span className="text-bolt-elements-textPrimary">{webAppInfo.gitInfo.local.commitHash}</span>
1874
+ </div>
1875
+ <div className="text-sm flex items-center gap-2">
1876
+ <div className="i-ph:user text-bolt-elements-textSecondary w-4 h-4" />
1877
+ <span className="text-bolt-elements-textSecondary">Author:</span>
1878
+ <span className="text-bolt-elements-textPrimary">{webAppInfo.gitInfo.local.author}</span>
1879
+ </div>
1880
+ <div className="text-sm flex items-center gap-2">
1881
+ <div className="i-ph:clock text-bolt-elements-textSecondary w-4 h-4" />
1882
+ <span className="text-bolt-elements-textSecondary">Commit Time:</span>
1883
+ <span className="text-bolt-elements-textPrimary">{webAppInfo.gitInfo.local.commitTime}</span>
1884
+ </div>
1885
+
1886
+ {webAppInfo.gitInfo.github && (
1887
+ <>
1888
+ <div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-800">
1889
+ <div className="text-sm flex items-center gap-2">
1890
+ <div className="i-ph:git-repository text-bolt-elements-textSecondary w-4 h-4" />
1891
+ <span className="text-bolt-elements-textSecondary">Repository:</span>
1892
+ <span className="text-bolt-elements-textPrimary">
1893
+ {webAppInfo.gitInfo.github.currentRepo.fullName}
1894
+ {webAppInfo.gitInfo.isForked && ' (fork)'}
1895
+ </span>
1896
+ </div>
1897
+
1898
+ <div className="mt-2 flex items-center gap-4 text-sm">
1899
+ <div className="flex items-center gap-1">
1900
+ <div className="i-ph:star text-yellow-500 w-4 h-4" />
1901
+ <span className="text-bolt-elements-textSecondary">
1902
+ {webAppInfo.gitInfo.github.currentRepo.stars}
1903
+ </span>
1904
+ </div>
1905
+ <div className="flex items-center gap-1">
1906
+ <div className="i-ph:git-fork text-blue-500 w-4 h-4" />
1907
+ <span className="text-bolt-elements-textSecondary">
1908
+ {webAppInfo.gitInfo.github.currentRepo.forks}
1909
+ </span>
1910
+ </div>
1911
+ <div className="flex items-center gap-1">
1912
+ <div className="i-ph:warning-circle text-red-500 w-4 h-4" />
1913
+ <span className="text-bolt-elements-textSecondary">
1914
+ {webAppInfo.gitInfo.github.currentRepo.openIssues}
1915
+ </span>
1916
+ </div>
1917
+ </div>
1918
+ </div>
1919
+
1920
+ {webAppInfo.gitInfo.github.upstream && (
1921
+ <div className="mt-2">
1922
+ <div className="text-sm flex items-center gap-2">
1923
+ <div className="i-ph:git-fork text-bolt-elements-textSecondary w-4 h-4" />
1924
+ <span className="text-bolt-elements-textSecondary">Upstream:</span>
1925
+ <span className="text-bolt-elements-textPrimary">
1926
+ {webAppInfo.gitInfo.github.upstream.fullName}
1927
+ </span>
1928
+ </div>
1929
+
1930
+ <div className="mt-2 flex items-center gap-4 text-sm">
1931
+ <div className="flex items-center gap-1">
1932
+ <div className="i-ph:star text-yellow-500 w-4 h-4" />
1933
+ <span className="text-bolt-elements-textSecondary">
1934
+ {webAppInfo.gitInfo.github.upstream.stars}
1935
+ </span>
1936
+ </div>
1937
+ <div className="flex items-center gap-1">
1938
+ <div className="i-ph:git-fork text-blue-500 w-4 h-4" />
1939
+ <span className="text-bolt-elements-textSecondary">
1940
+ {webAppInfo.gitInfo.github.upstream.forks}
1941
+ </span>
1942
+ </div>
1943
+ </div>
1944
+ </div>
1945
+ )}
1946
+ </>
1947
+ )}
1948
+ </div>
1949
+ </div>
1950
+ </div>
1951
+ )}
1952
+
1953
+ {webAppInfo && (
1954
+ <div className="mt-6">
1955
+ <h3 className="mb-4 text-base font-medium text-bolt-elements-textPrimary">Dependencies</h3>
1956
+ <div className="bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] rounded-lg divide-y divide-[#E5E5E5] dark:divide-[#1A1A1A]">
1957
+ <DependencySection title="Production" deps={webAppInfo.dependencies.production} />
1958
+ <DependencySection title="Development" deps={webAppInfo.dependencies.development} />
1959
+ <DependencySection title="Peer" deps={webAppInfo.dependencies.peer} />
1960
+ <DependencySection title="Optional" deps={webAppInfo.dependencies.optional} />
1961
+ </div>
1962
+ </div>
1963
+ )}
1964
+ </div>
1965
+ </CollapsibleContent>
1966
+ </Collapsible>
1967
+
1968
+ {/* Error Check */}
1969
+ <Collapsible
1970
+ open={openSections.errors}
1971
+ onOpenChange={(open) => setOpenSections((prev) => ({ ...prev, errors: open }))}
1972
+ className="w-full"
1973
+ >
1974
+ <CollapsibleTrigger className="w-full">
1975
+ <div className="flex items-center justify-between p-6 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
1976
+ <div className="flex items-center gap-3">
1977
+ <div className="i-ph:warning text-red-500 w-5 h-5" />
1978
+ <h3 className="text-base font-medium text-bolt-elements-textPrimary">Error Check</h3>
1979
+ {errorLogs.length > 0 && (
1980
+ <Badge variant="destructive" className="ml-2">
1981
+ {errorLogs.length} Errors
1982
+ </Badge>
1983
+ )}
1984
+ </div>
1985
+ <div
1986
+ className={classNames(
1987
+ 'i-ph:caret-down w-4 h-4 transform transition-transform duration-200',
1988
+ openSections.errors ? 'rotate-180' : '',
1989
+ )}
1990
+ />
1991
+ </div>
1992
+ </CollapsibleTrigger>
1993
+
1994
+ <CollapsibleContent>
1995
+ <div className="p-6 mt-2 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
1996
+ <ScrollArea className="h-[300px]">
1997
+ <div className="space-y-4">
1998
+ <div className="text-sm text-bolt-elements-textSecondary">
1999
+ Checks for:
2000
+ <ul className="list-disc list-inside mt-2 space-y-1">
2001
+ <li>Unhandled JavaScript errors</li>
2002
+ <li>Unhandled Promise rejections</li>
2003
+ <li>Runtime exceptions</li>
2004
+ <li>Network errors</li>
2005
+ </ul>
2006
+ </div>
2007
+ <div className="text-sm">
2008
+ <span className="text-bolt-elements-textSecondary">Status: </span>
2009
+ <span className="text-bolt-elements-textPrimary">
2010
+ {loading.errors
2011
+ ? 'Checking...'
2012
+ : errorLogs.length > 0
2013
+ ? `${errorLogs.length} errors found`
2014
+ : 'No errors found'}
2015
+ </span>
2016
+ </div>
2017
+ {errorLogs.length > 0 && (
2018
+ <div className="mt-4">
2019
+ <div className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Recent Errors:</div>
2020
+ <div className="space-y-2">
2021
+ {errorLogs.map((error) => (
2022
+ <div key={error.id} className="text-sm text-red-500 dark:text-red-400 p-2 rounded bg-red-500/5">
2023
+ <div className="font-medium">{error.message}</div>
2024
+ {error.source && (
2025
+ <div className="text-xs mt-1 text-red-400">
2026
+ Source: {error.source}
2027
+ {error.details?.lineNumber && `:${error.details.lineNumber}`}
2028
+ </div>
2029
+ )}
2030
+ {error.stack && (
2031
+ <div className="text-xs mt-1 text-red-400 font-mono whitespace-pre-wrap">{error.stack}</div>
2032
+ )}
2033
+ </div>
2034
+ ))}
2035
+ </div>
2036
+ </div>
2037
+ )}
2038
+ </div>
2039
+ </ScrollArea>
2040
+ </div>
2041
+ </CollapsibleContent>
2042
+ </Collapsible>
2043
+ </div>
2044
+ );
2045
+ }
app/components/@settings/tabs/event-logs/EventLogsTab.tsx ADDED
@@ -0,0 +1,1013 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { motion } from 'framer-motion';
3
+ import { Switch } from '~/components/ui/Switch';
4
+ import { logStore, type LogEntry } from '~/lib/stores/logs';
5
+ import { useStore } from '@nanostores/react';
6
+ import { classNames } from '~/utils/classNames';
7
+ import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
8
+ import { Dialog, DialogRoot, DialogTitle } from '~/components/ui/Dialog';
9
+ import { jsPDF } from 'jspdf';
10
+ import { toast } from 'react-toastify';
11
+
12
+ interface SelectOption {
13
+ value: string;
14
+ label: string;
15
+ icon?: string;
16
+ color?: string;
17
+ }
18
+
19
+ const logLevelOptions: SelectOption[] = [
20
+ {
21
+ value: 'all',
22
+ label: 'All Types',
23
+ icon: 'i-ph:funnel',
24
+ color: '#9333ea',
25
+ },
26
+ {
27
+ value: 'provider',
28
+ label: 'LLM',
29
+ icon: 'i-ph:robot',
30
+ color: '#10b981',
31
+ },
32
+ {
33
+ value: 'api',
34
+ label: 'API',
35
+ icon: 'i-ph:cloud',
36
+ color: '#3b82f6',
37
+ },
38
+ {
39
+ value: 'error',
40
+ label: 'Errors',
41
+ icon: 'i-ph:warning-circle',
42
+ color: '#ef4444',
43
+ },
44
+ {
45
+ value: 'warning',
46
+ label: 'Warnings',
47
+ icon: 'i-ph:warning',
48
+ color: '#f59e0b',
49
+ },
50
+ {
51
+ value: 'info',
52
+ label: 'Info',
53
+ icon: 'i-ph:info',
54
+ color: '#3b82f6',
55
+ },
56
+ {
57
+ value: 'debug',
58
+ label: 'Debug',
59
+ icon: 'i-ph:bug',
60
+ color: '#6b7280',
61
+ },
62
+ ];
63
+
64
+ interface LogEntryItemProps {
65
+ log: LogEntry;
66
+ isExpanded: boolean;
67
+ use24Hour: boolean;
68
+ showTimestamp: boolean;
69
+ }
70
+
71
+ const LogEntryItem = ({ log, isExpanded: forceExpanded, use24Hour, showTimestamp }: LogEntryItemProps) => {
72
+ const [localExpanded, setLocalExpanded] = useState(forceExpanded);
73
+
74
+ useEffect(() => {
75
+ setLocalExpanded(forceExpanded);
76
+ }, [forceExpanded]);
77
+
78
+ const timestamp = useMemo(() => {
79
+ const date = new Date(log.timestamp);
80
+ return date.toLocaleTimeString('en-US', { hour12: !use24Hour });
81
+ }, [log.timestamp, use24Hour]);
82
+
83
+ const style = useMemo(() => {
84
+ if (log.category === 'provider') {
85
+ return {
86
+ icon: 'i-ph:robot',
87
+ color: 'text-emerald-500 dark:text-emerald-400',
88
+ bg: 'hover:bg-emerald-500/10 dark:hover:bg-emerald-500/20',
89
+ badge: 'text-emerald-500 bg-emerald-50 dark:bg-emerald-500/10',
90
+ };
91
+ }
92
+
93
+ if (log.category === 'api') {
94
+ return {
95
+ icon: 'i-ph:cloud',
96
+ color: 'text-blue-500 dark:text-blue-400',
97
+ bg: 'hover:bg-blue-500/10 dark:hover:bg-blue-500/20',
98
+ badge: 'text-blue-500 bg-blue-50 dark:bg-blue-500/10',
99
+ };
100
+ }
101
+
102
+ switch (log.level) {
103
+ case 'error':
104
+ return {
105
+ icon: 'i-ph:warning-circle',
106
+ color: 'text-red-500 dark:text-red-400',
107
+ bg: 'hover:bg-red-500/10 dark:hover:bg-red-500/20',
108
+ badge: 'text-red-500 bg-red-50 dark:bg-red-500/10',
109
+ };
110
+ case 'warning':
111
+ return {
112
+ icon: 'i-ph:warning',
113
+ color: 'text-yellow-500 dark:text-yellow-400',
114
+ bg: 'hover:bg-yellow-500/10 dark:hover:bg-yellow-500/20',
115
+ badge: 'text-yellow-500 bg-yellow-50 dark:bg-yellow-500/10',
116
+ };
117
+ case 'debug':
118
+ return {
119
+ icon: 'i-ph:bug',
120
+ color: 'text-gray-500 dark:text-gray-400',
121
+ bg: 'hover:bg-gray-500/10 dark:hover:bg-gray-500/20',
122
+ badge: 'text-gray-500 bg-gray-50 dark:bg-gray-500/10',
123
+ };
124
+ default:
125
+ return {
126
+ icon: 'i-ph:info',
127
+ color: 'text-blue-500 dark:text-blue-400',
128
+ bg: 'hover:bg-blue-500/10 dark:hover:bg-blue-500/20',
129
+ badge: 'text-blue-500 bg-blue-50 dark:bg-blue-500/10',
130
+ };
131
+ }
132
+ }, [log.level, log.category]);
133
+
134
+ const renderDetails = (details: any) => {
135
+ if (log.category === 'provider') {
136
+ return (
137
+ <div className="flex flex-col gap-2">
138
+ <div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
139
+ <span>Model: {details.model}</span>
140
+ <span>•</span>
141
+ <span>Tokens: {details.totalTokens}</span>
142
+ <span>•</span>
143
+ <span>Duration: {details.duration}ms</span>
144
+ </div>
145
+ {details.prompt && (
146
+ <div className="flex flex-col gap-1">
147
+ <div className="text-xs font-medium text-gray-700 dark:text-gray-300">Prompt:</div>
148
+ <pre className="text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded p-2 whitespace-pre-wrap">
149
+ {details.prompt}
150
+ </pre>
151
+ </div>
152
+ )}
153
+ {details.response && (
154
+ <div className="flex flex-col gap-1">
155
+ <div className="text-xs font-medium text-gray-700 dark:text-gray-300">Response:</div>
156
+ <pre className="text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded p-2 whitespace-pre-wrap">
157
+ {details.response}
158
+ </pre>
159
+ </div>
160
+ )}
161
+ </div>
162
+ );
163
+ }
164
+
165
+ if (log.category === 'api') {
166
+ return (
167
+ <div className="flex flex-col gap-2">
168
+ <div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
169
+ <span className={details.method === 'GET' ? 'text-green-500' : 'text-blue-500'}>{details.method}</span>
170
+ <span>•</span>
171
+ <span>Status: {details.statusCode}</span>
172
+ <span>•</span>
173
+ <span>Duration: {details.duration}ms</span>
174
+ </div>
175
+ <div className="text-xs text-gray-600 dark:text-gray-400 break-all">{details.url}</div>
176
+ {details.request && (
177
+ <div className="flex flex-col gap-1">
178
+ <div className="text-xs font-medium text-gray-700 dark:text-gray-300">Request:</div>
179
+ <pre className="text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded p-2 whitespace-pre-wrap">
180
+ {JSON.stringify(details.request, null, 2)}
181
+ </pre>
182
+ </div>
183
+ )}
184
+ {details.response && (
185
+ <div className="flex flex-col gap-1">
186
+ <div className="text-xs font-medium text-gray-700 dark:text-gray-300">Response:</div>
187
+ <pre className="text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded p-2 whitespace-pre-wrap">
188
+ {JSON.stringify(details.response, null, 2)}
189
+ </pre>
190
+ </div>
191
+ )}
192
+ {details.error && (
193
+ <div className="flex flex-col gap-1">
194
+ <div className="text-xs font-medium text-red-500">Error:</div>
195
+ <pre className="text-xs text-red-400 bg-red-50 dark:bg-red-500/10 rounded p-2 whitespace-pre-wrap">
196
+ {JSON.stringify(details.error, null, 2)}
197
+ </pre>
198
+ </div>
199
+ )}
200
+ </div>
201
+ );
202
+ }
203
+
204
+ return (
205
+ <pre className="text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded whitespace-pre-wrap">
206
+ {JSON.stringify(details, null, 2)}
207
+ </pre>
208
+ );
209
+ };
210
+
211
+ return (
212
+ <motion.div
213
+ initial={{ opacity: 0, y: 20 }}
214
+ animate={{ opacity: 1, y: 0 }}
215
+ className={classNames(
216
+ 'flex flex-col gap-2',
217
+ 'rounded-lg p-4',
218
+ 'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
219
+ 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
220
+ style.bg,
221
+ 'transition-all duration-200',
222
+ )}
223
+ >
224
+ <div className="flex items-start justify-between gap-4">
225
+ <div className="flex items-start gap-3">
226
+ <span className={classNames('text-lg', style.icon, style.color)} />
227
+ <div className="flex flex-col gap-1">
228
+ <div className="text-sm font-medium text-gray-900 dark:text-white">{log.message}</div>
229
+ {log.details && (
230
+ <>
231
+ <button
232
+ onClick={() => setLocalExpanded(!localExpanded)}
233
+ className="text-xs text-gray-500 dark:text-gray-400 hover:text-purple-500 dark:hover:text-purple-400 transition-colors"
234
+ >
235
+ {localExpanded ? 'Hide' : 'Show'} Details
236
+ </button>
237
+ {localExpanded && renderDetails(log.details)}
238
+ </>
239
+ )}
240
+ <div className="flex items-center gap-2">
241
+ <div className={classNames('px-2 py-0.5 rounded text-xs font-medium uppercase', style.badge)}>
242
+ {log.level}
243
+ </div>
244
+ {log.category && (
245
+ <div className="px-2 py-0.5 rounded-full text-xs bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400">
246
+ {log.category}
247
+ </div>
248
+ )}
249
+ </div>
250
+ </div>
251
+ </div>
252
+ {showTimestamp && <time className="shrink-0 text-xs text-gray-500 dark:text-gray-400">{timestamp}</time>}
253
+ </div>
254
+ </motion.div>
255
+ );
256
+ };
257
+
258
+ interface ExportFormat {
259
+ id: string;
260
+ label: string;
261
+ icon: string;
262
+ handler: () => void;
263
+ }
264
+
265
+ export function EventLogsTab() {
266
+ const logs = useStore(logStore.logs);
267
+ const [selectedLevel, setSelectedLevel] = useState<'all' | string>('all');
268
+ const [searchQuery, setSearchQuery] = useState('');
269
+ const [use24Hour, setUse24Hour] = useState(false);
270
+ const [autoExpand, setAutoExpand] = useState(false);
271
+ const [showTimestamps, setShowTimestamps] = useState(true);
272
+ const [showLevelFilter, setShowLevelFilter] = useState(false);
273
+ const [isRefreshing, setIsRefreshing] = useState(false);
274
+ const levelFilterRef = useRef<HTMLDivElement>(null);
275
+
276
+ const filteredLogs = useMemo(() => {
277
+ const allLogs = Object.values(logs);
278
+
279
+ if (selectedLevel === 'all') {
280
+ return allLogs.filter((log) =>
281
+ searchQuery ? log.message.toLowerCase().includes(searchQuery.toLowerCase()) : true,
282
+ );
283
+ }
284
+
285
+ return allLogs.filter((log) => {
286
+ const matchesType = log.category === selectedLevel || log.level === selectedLevel;
287
+ const matchesSearch = searchQuery ? log.message.toLowerCase().includes(searchQuery.toLowerCase()) : true;
288
+
289
+ return matchesType && matchesSearch;
290
+ });
291
+ }, [logs, selectedLevel, searchQuery]);
292
+
293
+ // Add performance tracking on mount
294
+ useEffect(() => {
295
+ const startTime = performance.now();
296
+
297
+ logStore.logInfo('Event Logs tab mounted', {
298
+ type: 'component_mount',
299
+ message: 'Event Logs tab component mounted',
300
+ component: 'EventLogsTab',
301
+ });
302
+
303
+ return () => {
304
+ const duration = performance.now() - startTime;
305
+ logStore.logPerformanceMetric('EventLogsTab', 'mount-duration', duration);
306
+ };
307
+ }, []);
308
+
309
+ // Log filter changes
310
+ const handleLevelFilterChange = useCallback(
311
+ (newLevel: string) => {
312
+ logStore.logInfo('Log level filter changed', {
313
+ type: 'filter_change',
314
+ message: `Log level filter changed from ${selectedLevel} to ${newLevel}`,
315
+ component: 'EventLogsTab',
316
+ previousLevel: selectedLevel,
317
+ newLevel,
318
+ });
319
+ setSelectedLevel(newLevel as string);
320
+ setShowLevelFilter(false);
321
+ },
322
+ [selectedLevel],
323
+ );
324
+
325
+ // Log search changes with debounce
326
+ useEffect(() => {
327
+ const timeoutId = setTimeout(() => {
328
+ if (searchQuery) {
329
+ logStore.logInfo('Log search performed', {
330
+ type: 'search',
331
+ message: `Search performed with query "${searchQuery}" (${filteredLogs.length} results)`,
332
+ component: 'EventLogsTab',
333
+ query: searchQuery,
334
+ resultsCount: filteredLogs.length,
335
+ });
336
+ }
337
+ }, 1000);
338
+
339
+ return () => clearTimeout(timeoutId);
340
+ }, [searchQuery, filteredLogs.length]);
341
+
342
+ // Enhanced refresh handler
343
+ const handleRefresh = useCallback(async () => {
344
+ const startTime = performance.now();
345
+ setIsRefreshing(true);
346
+
347
+ try {
348
+ await logStore.refreshLogs();
349
+
350
+ const duration = performance.now() - startTime;
351
+
352
+ logStore.logSuccess('Logs refreshed successfully', {
353
+ type: 'refresh',
354
+ message: `Successfully refreshed ${Object.keys(logs).length} logs`,
355
+ component: 'EventLogsTab',
356
+ duration,
357
+ logsCount: Object.keys(logs).length,
358
+ });
359
+ } catch (error) {
360
+ logStore.logError('Failed to refresh logs', error, {
361
+ type: 'refresh_error',
362
+ message: 'Failed to refresh logs',
363
+ component: 'EventLogsTab',
364
+ });
365
+ } finally {
366
+ setTimeout(() => setIsRefreshing(false), 500);
367
+ }
368
+ }, [logs]);
369
+
370
+ // Log preference changes
371
+ const handlePreferenceChange = useCallback((type: string, value: boolean) => {
372
+ logStore.logInfo('Log preference changed', {
373
+ type: 'preference_change',
374
+ message: `Log preference "${type}" changed to ${value}`,
375
+ component: 'EventLogsTab',
376
+ preference: type,
377
+ value,
378
+ });
379
+
380
+ switch (type) {
381
+ case 'timestamps':
382
+ setShowTimestamps(value);
383
+ break;
384
+ case '24hour':
385
+ setUse24Hour(value);
386
+ break;
387
+ case 'autoExpand':
388
+ setAutoExpand(value);
389
+ break;
390
+ }
391
+ }, []);
392
+
393
+ // Close filters when clicking outside
394
+ useEffect(() => {
395
+ const handleClickOutside = (event: MouseEvent) => {
396
+ if (levelFilterRef.current && !levelFilterRef.current.contains(event.target as Node)) {
397
+ setShowLevelFilter(false);
398
+ }
399
+ };
400
+
401
+ document.addEventListener('mousedown', handleClickOutside);
402
+
403
+ return () => {
404
+ document.removeEventListener('mousedown', handleClickOutside);
405
+ };
406
+ }, []);
407
+
408
+ const selectedLevelOption = logLevelOptions.find((opt) => opt.value === selectedLevel);
409
+
410
+ // Export functions
411
+ const exportAsJSON = () => {
412
+ try {
413
+ const exportData = {
414
+ timestamp: new Date().toISOString(),
415
+ logs: filteredLogs,
416
+ filters: {
417
+ level: selectedLevel,
418
+ searchQuery,
419
+ },
420
+ preferences: {
421
+ use24Hour,
422
+ showTimestamps,
423
+ autoExpand,
424
+ },
425
+ };
426
+
427
+ const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
428
+ const url = window.URL.createObjectURL(blob);
429
+ const a = document.createElement('a');
430
+ a.href = url;
431
+ a.download = `bolt-event-logs-${new Date().toISOString()}.json`;
432
+ document.body.appendChild(a);
433
+ a.click();
434
+ window.URL.revokeObjectURL(url);
435
+ document.body.removeChild(a);
436
+ toast.success('Event logs exported successfully as JSON');
437
+ } catch (error) {
438
+ console.error('Failed to export JSON:', error);
439
+ toast.error('Failed to export event logs as JSON');
440
+ }
441
+ };
442
+
443
+ const exportAsCSV = () => {
444
+ try {
445
+ // Convert logs to CSV format
446
+ const headers = ['Timestamp', 'Level', 'Category', 'Message', 'Details'];
447
+ const csvData = [
448
+ headers,
449
+ ...filteredLogs.map((log) => [
450
+ new Date(log.timestamp).toISOString(),
451
+ log.level,
452
+ log.category || '',
453
+ log.message,
454
+ log.details ? JSON.stringify(log.details) : '',
455
+ ]),
456
+ ];
457
+
458
+ const csvContent = csvData
459
+ .map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
460
+ .join('\n');
461
+ const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
462
+ const url = window.URL.createObjectURL(blob);
463
+ const a = document.createElement('a');
464
+ a.href = url;
465
+ a.download = `bolt-event-logs-${new Date().toISOString()}.csv`;
466
+ document.body.appendChild(a);
467
+ a.click();
468
+ window.URL.revokeObjectURL(url);
469
+ document.body.removeChild(a);
470
+ toast.success('Event logs exported successfully as CSV');
471
+ } catch (error) {
472
+ console.error('Failed to export CSV:', error);
473
+ toast.error('Failed to export event logs as CSV');
474
+ }
475
+ };
476
+
477
+ const exportAsPDF = () => {
478
+ try {
479
+ // Create new PDF document
480
+ const doc = new jsPDF();
481
+ const lineHeight = 7;
482
+ let yPos = 20;
483
+ const margin = 20;
484
+ const pageWidth = doc.internal.pageSize.getWidth();
485
+ const maxLineWidth = pageWidth - 2 * margin;
486
+
487
+ // Helper function to add section header
488
+ const addSectionHeader = (title: string) => {
489
+ // Check if we need a new page
490
+ if (yPos > doc.internal.pageSize.getHeight() - 30) {
491
+ doc.addPage();
492
+ yPos = margin;
493
+ }
494
+
495
+ doc.setFillColor('#F3F4F6');
496
+ doc.rect(margin - 2, yPos - 5, pageWidth - 2 * (margin - 2), lineHeight + 6, 'F');
497
+ doc.setFont('helvetica', 'bold');
498
+ doc.setTextColor('#111827');
499
+ doc.setFontSize(12);
500
+ doc.text(title.toUpperCase(), margin, yPos);
501
+ yPos += lineHeight * 2;
502
+ };
503
+
504
+ // Add title and header
505
+ doc.setFillColor('#6366F1');
506
+ doc.rect(0, 0, pageWidth, 50, 'F');
507
+ doc.setTextColor('#FFFFFF');
508
+ doc.setFontSize(24);
509
+ doc.setFont('helvetica', 'bold');
510
+ doc.text('Event Logs Report', margin, 35);
511
+
512
+ // Add subtitle with bolt.diy
513
+ doc.setFontSize(12);
514
+ doc.setFont('helvetica', 'normal');
515
+ doc.text('bolt.diy - AI Development Platform', margin, 45);
516
+ yPos = 70;
517
+
518
+ // Add report summary section
519
+ addSectionHeader('Report Summary');
520
+
521
+ doc.setFontSize(10);
522
+ doc.setFont('helvetica', 'normal');
523
+ doc.setTextColor('#374151');
524
+
525
+ const summaryItems = [
526
+ { label: 'Generated', value: new Date().toLocaleString() },
527
+ { label: 'Total Logs', value: filteredLogs.length.toString() },
528
+ { label: 'Filter Applied', value: selectedLevel === 'all' ? 'All Types' : selectedLevel },
529
+ { label: 'Search Query', value: searchQuery || 'None' },
530
+ { label: 'Time Format', value: use24Hour ? '24-hour' : '12-hour' },
531
+ ];
532
+
533
+ summaryItems.forEach((item) => {
534
+ doc.setFont('helvetica', 'bold');
535
+ doc.text(`${item.label}:`, margin, yPos);
536
+ doc.setFont('helvetica', 'normal');
537
+ doc.text(item.value, margin + 60, yPos);
538
+ yPos += lineHeight;
539
+ });
540
+
541
+ yPos += lineHeight * 2;
542
+
543
+ // Add statistics section
544
+ addSectionHeader('Log Statistics');
545
+
546
+ // Calculate statistics
547
+ const stats = {
548
+ error: filteredLogs.filter((log) => log.level === 'error').length,
549
+ warning: filteredLogs.filter((log) => log.level === 'warning').length,
550
+ info: filteredLogs.filter((log) => log.level === 'info').length,
551
+ debug: filteredLogs.filter((log) => log.level === 'debug').length,
552
+ provider: filteredLogs.filter((log) => log.category === 'provider').length,
553
+ api: filteredLogs.filter((log) => log.category === 'api').length,
554
+ };
555
+
556
+ // Create two columns for statistics
557
+ const leftStats = [
558
+ { label: 'Error Logs', value: stats.error, color: '#DC2626' },
559
+ { label: 'Warning Logs', value: stats.warning, color: '#F59E0B' },
560
+ { label: 'Info Logs', value: stats.info, color: '#3B82F6' },
561
+ ];
562
+
563
+ const rightStats = [
564
+ { label: 'Debug Logs', value: stats.debug, color: '#6B7280' },
565
+ { label: 'LLM Logs', value: stats.provider, color: '#10B981' },
566
+ { label: 'API Logs', value: stats.api, color: '#3B82F6' },
567
+ ];
568
+
569
+ const colWidth = (pageWidth - 2 * margin) / 2;
570
+
571
+ // Draw statistics in two columns
572
+ leftStats.forEach((stat, index) => {
573
+ doc.setTextColor(stat.color);
574
+ doc.setFont('helvetica', 'bold');
575
+ doc.text(stat.value.toString(), margin, yPos);
576
+ doc.setTextColor('#374151');
577
+ doc.setFont('helvetica', 'normal');
578
+ doc.text(stat.label, margin + 20, yPos);
579
+
580
+ if (rightStats[index]) {
581
+ doc.setTextColor(rightStats[index].color);
582
+ doc.setFont('helvetica', 'bold');
583
+ doc.text(rightStats[index].value.toString(), margin + colWidth, yPos);
584
+ doc.setTextColor('#374151');
585
+ doc.setFont('helvetica', 'normal');
586
+ doc.text(rightStats[index].label, margin + colWidth + 20, yPos);
587
+ }
588
+
589
+ yPos += lineHeight;
590
+ });
591
+
592
+ yPos += lineHeight * 2;
593
+
594
+ // Add logs section
595
+ addSectionHeader('Event Logs');
596
+
597
+ // Helper function to add a log entry with improved formatting
598
+ const addLogEntry = (log: LogEntry) => {
599
+ const entryHeight = 20 + (log.details ? 40 : 0); // Estimate entry height
600
+
601
+ // Check if we need a new page
602
+ if (yPos + entryHeight > doc.internal.pageSize.getHeight() - 20) {
603
+ doc.addPage();
604
+ yPos = margin;
605
+ }
606
+
607
+ // Add timestamp and level
608
+ const timestamp = new Date(log.timestamp).toLocaleString(undefined, {
609
+ year: 'numeric',
610
+ month: '2-digit',
611
+ day: '2-digit',
612
+ hour: '2-digit',
613
+ minute: '2-digit',
614
+ second: '2-digit',
615
+ hour12: !use24Hour,
616
+ });
617
+
618
+ // Draw log level badge background
619
+ const levelColors: Record<string, string> = {
620
+ error: '#FEE2E2',
621
+ warning: '#FEF3C7',
622
+ info: '#DBEAFE',
623
+ debug: '#F3F4F6',
624
+ };
625
+
626
+ const textColors: Record<string, string> = {
627
+ error: '#DC2626',
628
+ warning: '#F59E0B',
629
+ info: '#3B82F6',
630
+ debug: '#6B7280',
631
+ };
632
+
633
+ const levelWidth = doc.getTextWidth(log.level.toUpperCase()) + 10;
634
+ doc.setFillColor(levelColors[log.level] || '#F3F4F6');
635
+ doc.roundedRect(margin, yPos - 4, levelWidth, lineHeight + 4, 1, 1, 'F');
636
+
637
+ // Add log level text
638
+ doc.setTextColor(textColors[log.level] || '#6B7280');
639
+ doc.setFont('helvetica', 'bold');
640
+ doc.setFontSize(8);
641
+ doc.text(log.level.toUpperCase(), margin + 5, yPos);
642
+
643
+ // Add timestamp
644
+ doc.setTextColor('#6B7280');
645
+ doc.setFont('helvetica', 'normal');
646
+ doc.setFontSize(9);
647
+ doc.text(timestamp, margin + levelWidth + 10, yPos);
648
+
649
+ // Add category if present
650
+ if (log.category) {
651
+ const categoryX = margin + levelWidth + doc.getTextWidth(timestamp) + 20;
652
+ doc.setFillColor('#F3F4F6');
653
+
654
+ const categoryWidth = doc.getTextWidth(log.category) + 10;
655
+ doc.roundedRect(categoryX, yPos - 4, categoryWidth, lineHeight + 4, 2, 2, 'F');
656
+ doc.setTextColor('#6B7280');
657
+ doc.text(log.category, categoryX + 5, yPos);
658
+ }
659
+
660
+ yPos += lineHeight * 1.5;
661
+
662
+ // Add message
663
+ doc.setTextColor('#111827');
664
+ doc.setFontSize(10);
665
+
666
+ const messageLines = doc.splitTextToSize(log.message, maxLineWidth - 10);
667
+ doc.text(messageLines, margin + 5, yPos);
668
+ yPos += messageLines.length * lineHeight;
669
+
670
+ // Add details if present
671
+ if (log.details) {
672
+ doc.setTextColor('#6B7280');
673
+ doc.setFontSize(8);
674
+
675
+ const detailsStr = JSON.stringify(log.details, null, 2);
676
+ const detailsLines = doc.splitTextToSize(detailsStr, maxLineWidth - 15);
677
+
678
+ // Add details background
679
+ doc.setFillColor('#F9FAFB');
680
+ doc.roundedRect(margin + 5, yPos - 2, maxLineWidth - 10, detailsLines.length * lineHeight + 8, 1, 1, 'F');
681
+
682
+ doc.text(detailsLines, margin + 10, yPos + 4);
683
+ yPos += detailsLines.length * lineHeight + 10;
684
+ }
685
+
686
+ // Add separator line
687
+ doc.setDrawColor('#E5E7EB');
688
+ doc.setLineWidth(0.1);
689
+ doc.line(margin, yPos, pageWidth - margin, yPos);
690
+ yPos += lineHeight * 1.5;
691
+ };
692
+
693
+ // Add all logs
694
+ filteredLogs.forEach((log) => {
695
+ addLogEntry(log);
696
+ });
697
+
698
+ // Add footer to all pages
699
+ const totalPages = doc.internal.pages.length - 1;
700
+
701
+ for (let i = 1; i <= totalPages; i++) {
702
+ doc.setPage(i);
703
+ doc.setFontSize(8);
704
+ doc.setTextColor('#9CA3AF');
705
+
706
+ // Add page numbers
707
+ doc.text(`Page ${i} of ${totalPages}`, pageWidth / 2, doc.internal.pageSize.getHeight() - 10, {
708
+ align: 'center',
709
+ });
710
+
711
+ // Add footer text
712
+ doc.text('Generated by bolt.diy', margin, doc.internal.pageSize.getHeight() - 10);
713
+
714
+ const dateStr = new Date().toLocaleDateString();
715
+ doc.text(dateStr, pageWidth - margin, doc.internal.pageSize.getHeight() - 10, { align: 'right' });
716
+ }
717
+
718
+ // Save the PDF
719
+ doc.save(`bolt-event-logs-${new Date().toISOString()}.pdf`);
720
+ toast.success('Event logs exported successfully as PDF');
721
+ } catch (error) {
722
+ console.error('Failed to export PDF:', error);
723
+ toast.error('Failed to export event logs as PDF');
724
+ }
725
+ };
726
+
727
+ const exportAsText = () => {
728
+ try {
729
+ const textContent = filteredLogs
730
+ .map((log) => {
731
+ const timestamp = new Date(log.timestamp).toLocaleString();
732
+ let content = `[${timestamp}] ${log.level.toUpperCase()}: ${log.message}\n`;
733
+
734
+ if (log.category) {
735
+ content += `Category: ${log.category}\n`;
736
+ }
737
+
738
+ if (log.details) {
739
+ content += `Details:\n${JSON.stringify(log.details, null, 2)}\n`;
740
+ }
741
+
742
+ return content + '-'.repeat(80) + '\n';
743
+ })
744
+ .join('\n');
745
+
746
+ const blob = new Blob([textContent], { type: 'text/plain' });
747
+ const url = window.URL.createObjectURL(blob);
748
+ const a = document.createElement('a');
749
+ a.href = url;
750
+ a.download = `bolt-event-logs-${new Date().toISOString()}.txt`;
751
+ document.body.appendChild(a);
752
+ a.click();
753
+ window.URL.revokeObjectURL(url);
754
+ document.body.removeChild(a);
755
+ toast.success('Event logs exported successfully as text file');
756
+ } catch (error) {
757
+ console.error('Failed to export text file:', error);
758
+ toast.error('Failed to export event logs as text file');
759
+ }
760
+ };
761
+
762
+ const exportFormats: ExportFormat[] = [
763
+ {
764
+ id: 'json',
765
+ label: 'Export as JSON',
766
+ icon: 'i-ph:file-json',
767
+ handler: exportAsJSON,
768
+ },
769
+ {
770
+ id: 'csv',
771
+ label: 'Export as CSV',
772
+ icon: 'i-ph:file-csv',
773
+ handler: exportAsCSV,
774
+ },
775
+ {
776
+ id: 'pdf',
777
+ label: 'Export as PDF',
778
+ icon: 'i-ph:file-pdf',
779
+ handler: exportAsPDF,
780
+ },
781
+ {
782
+ id: 'txt',
783
+ label: 'Export as Text',
784
+ icon: 'i-ph:file-text',
785
+ handler: exportAsText,
786
+ },
787
+ ];
788
+
789
+ const ExportButton = () => {
790
+ const [isOpen, setIsOpen] = useState(false);
791
+
792
+ const handleOpenChange = useCallback((open: boolean) => {
793
+ setIsOpen(open);
794
+ }, []);
795
+
796
+ const handleFormatClick = useCallback((handler: () => void) => {
797
+ handler();
798
+ setIsOpen(false);
799
+ }, []);
800
+
801
+ return (
802
+ <DialogRoot open={isOpen} onOpenChange={handleOpenChange}>
803
+ <button
804
+ onClick={() => setIsOpen(true)}
805
+ className={classNames(
806
+ 'group flex items-center gap-2',
807
+ 'rounded-lg px-3 py-1.5',
808
+ 'text-sm text-gray-900 dark:text-white',
809
+ 'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
810
+ 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
811
+ 'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
812
+ 'transition-all duration-200',
813
+ )}
814
+ >
815
+ <span className="i-ph:download text-lg text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
816
+ Export
817
+ </button>
818
+
819
+ <Dialog showCloseButton>
820
+ <div className="p-6">
821
+ <DialogTitle className="flex items-center gap-2">
822
+ <div className="i-ph:download w-5 h-5" />
823
+ Export Event Logs
824
+ </DialogTitle>
825
+
826
+ <div className="mt-4 flex flex-col gap-2">
827
+ {exportFormats.map((format) => (
828
+ <button
829
+ key={format.id}
830
+ onClick={() => handleFormatClick(format.handler)}
831
+ className={classNames(
832
+ 'flex items-center gap-3 px-4 py-3 text-sm rounded-lg transition-colors w-full text-left',
833
+ 'bg-white dark:bg-[#0A0A0A]',
834
+ 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
835
+ 'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
836
+ 'hover:border-purple-200 dark:hover:border-purple-900/30',
837
+ 'text-bolt-elements-textPrimary',
838
+ )}
839
+ >
840
+ <div className={classNames(format.icon, 'w-5 h-5')} />
841
+ <div>
842
+ <div className="font-medium">{format.label}</div>
843
+ <div className="text-xs text-bolt-elements-textSecondary mt-0.5">
844
+ {format.id === 'json' && 'Export as a structured JSON file'}
845
+ {format.id === 'csv' && 'Export as a CSV spreadsheet'}
846
+ {format.id === 'pdf' && 'Export as a formatted PDF document'}
847
+ {format.id === 'txt' && 'Export as a formatted text file'}
848
+ </div>
849
+ </div>
850
+ </button>
851
+ ))}
852
+ </div>
853
+ </div>
854
+ </Dialog>
855
+ </DialogRoot>
856
+ );
857
+ };
858
+
859
+ return (
860
+ <div className="flex h-full flex-col gap-6">
861
+ <div className="flex items-center justify-between">
862
+ <DropdownMenu.Root open={showLevelFilter} onOpenChange={setShowLevelFilter}>
863
+ <DropdownMenu.Trigger asChild>
864
+ <button
865
+ className={classNames(
866
+ 'flex items-center gap-2',
867
+ 'rounded-lg px-3 py-1.5',
868
+ 'text-sm text-gray-900 dark:text-white',
869
+ 'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
870
+ 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
871
+ 'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
872
+ 'transition-all duration-200',
873
+ )}
874
+ >
875
+ <span
876
+ className={classNames('text-lg', selectedLevelOption?.icon || 'i-ph:funnel')}
877
+ style={{ color: selectedLevelOption?.color }}
878
+ />
879
+ {selectedLevelOption?.label || 'All Types'}
880
+ <span className="i-ph:caret-down text-lg text-gray-500 dark:text-gray-400" />
881
+ </button>
882
+ </DropdownMenu.Trigger>
883
+
884
+ <DropdownMenu.Portal>
885
+ <DropdownMenu.Content
886
+ className="min-w-[200px] bg-white dark:bg-[#0A0A0A] rounded-lg shadow-lg py-1 z-[250] animate-in fade-in-0 zoom-in-95 border border-[#E5E5E5] dark:border-[#1A1A1A]"
887
+ sideOffset={5}
888
+ align="start"
889
+ side="bottom"
890
+ >
891
+ {logLevelOptions.map((option) => (
892
+ <DropdownMenu.Item
893
+ key={option.value}
894
+ className="group flex items-center px-4 py-2.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-purple-500/10 dark:hover:bg-purple-500/20 cursor-pointer transition-colors"
895
+ onClick={() => handleLevelFilterChange(option.value)}
896
+ >
897
+ <div className="mr-3 flex h-5 w-5 items-center justify-center">
898
+ <div
899
+ className={classNames(option.icon, 'text-lg group-hover:text-purple-500 transition-colors')}
900
+ style={{ color: option.color }}
901
+ />
902
+ </div>
903
+ <span className="group-hover:text-purple-500 transition-colors">{option.label}</span>
904
+ </DropdownMenu.Item>
905
+ ))}
906
+ </DropdownMenu.Content>
907
+ </DropdownMenu.Portal>
908
+ </DropdownMenu.Root>
909
+
910
+ <div className="flex items-center gap-4">
911
+ <div className="flex items-center gap-2">
912
+ <Switch
913
+ checked={showTimestamps}
914
+ onCheckedChange={(value) => handlePreferenceChange('timestamps', value)}
915
+ className="data-[state=checked]:bg-purple-500"
916
+ />
917
+ <span className="text-sm text-gray-500 dark:text-gray-400">Show Timestamps</span>
918
+ </div>
919
+
920
+ <div className="flex items-center gap-2">
921
+ <Switch
922
+ checked={use24Hour}
923
+ onCheckedChange={(value) => handlePreferenceChange('24hour', value)}
924
+ className="data-[state=checked]:bg-purple-500"
925
+ />
926
+ <span className="text-sm text-gray-500 dark:text-gray-400">24h Time</span>
927
+ </div>
928
+
929
+ <div className="flex items-center gap-2">
930
+ <Switch
931
+ checked={autoExpand}
932
+ onCheckedChange={(value) => handlePreferenceChange('autoExpand', value)}
933
+ className="data-[state=checked]:bg-purple-500"
934
+ />
935
+ <span className="text-sm text-gray-500 dark:text-gray-400">Auto Expand</span>
936
+ </div>
937
+
938
+ <div className="w-px h-4 bg-gray-200 dark:bg-gray-700" />
939
+
940
+ <button
941
+ onClick={handleRefresh}
942
+ className={classNames(
943
+ 'group flex items-center gap-2',
944
+ 'rounded-lg px-3 py-1.5',
945
+ 'text-sm text-gray-900 dark:text-white',
946
+ 'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
947
+ 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
948
+ 'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
949
+ 'transition-all duration-200',
950
+ { 'animate-spin': isRefreshing },
951
+ )}
952
+ >
953
+ <span className="i-ph:arrows-clockwise text-lg text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
954
+ Refresh
955
+ </button>
956
+
957
+ <ExportButton />
958
+ </div>
959
+ </div>
960
+
961
+ <div className="flex flex-col gap-4">
962
+ <div className="relative">
963
+ <input
964
+ type="text"
965
+ placeholder="Search logs..."
966
+ value={searchQuery}
967
+ onChange={(e) => setSearchQuery(e.target.value)}
968
+ className={classNames(
969
+ 'w-full px-4 py-2 pl-10 rounded-lg',
970
+ 'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
971
+ 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
972
+ 'text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400',
973
+ 'focus:outline-none focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500',
974
+ 'transition-all duration-200',
975
+ )}
976
+ />
977
+ <div className="absolute left-3 top-1/2 -translate-y-1/2">
978
+ <div className="i-ph:magnifying-glass text-lg text-gray-500 dark:text-gray-400" />
979
+ </div>
980
+ </div>
981
+
982
+ {filteredLogs.length === 0 ? (
983
+ <motion.div
984
+ initial={{ opacity: 0, y: 20 }}
985
+ animate={{ opacity: 1, y: 0 }}
986
+ className={classNames(
987
+ 'flex flex-col items-center justify-center gap-4',
988
+ 'rounded-lg p-8 text-center',
989
+ 'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
990
+ 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
991
+ )}
992
+ >
993
+ <span className="i-ph:clipboard-text text-4xl text-gray-400 dark:text-gray-600" />
994
+ <div className="flex flex-col gap-1">
995
+ <h3 className="text-sm font-medium text-gray-900 dark:text-white">No Logs Found</h3>
996
+ <p className="text-sm text-gray-500 dark:text-gray-400">Try adjusting your search or filters</p>
997
+ </div>
998
+ </motion.div>
999
+ ) : (
1000
+ filteredLogs.map((log) => (
1001
+ <LogEntryItem
1002
+ key={log.id}
1003
+ log={log}
1004
+ isExpanded={autoExpand}
1005
+ use24Hour={use24Hour}
1006
+ showTimestamp={showTimestamps}
1007
+ />
1008
+ ))
1009
+ )}
1010
+ </div>
1011
+ </div>
1012
+ );
1013
+ }
app/components/@settings/tabs/features/FeaturesTab.tsx ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Remove unused imports
2
+ import React, { memo, useCallback } from 'react';
3
+ import { motion } from 'framer-motion';
4
+ import { Switch } from '~/components/ui/Switch';
5
+ import { useSettings } from '~/lib/hooks/useSettings';
6
+ import { classNames } from '~/utils/classNames';
7
+ import { toast } from 'react-toastify';
8
+ import { PromptLibrary } from '~/lib/common/prompt-library';
9
+
10
+ interface FeatureToggle {
11
+ id: string;
12
+ title: string;
13
+ description: string;
14
+ icon: string;
15
+ enabled: boolean;
16
+ beta?: boolean;
17
+ experimental?: boolean;
18
+ tooltip?: string;
19
+ }
20
+
21
+ const FeatureCard = memo(
22
+ ({
23
+ feature,
24
+ index,
25
+ onToggle,
26
+ }: {
27
+ feature: FeatureToggle;
28
+ index: number;
29
+ onToggle: (id: string, enabled: boolean) => void;
30
+ }) => (
31
+ <motion.div
32
+ key={feature.id}
33
+ layoutId={feature.id}
34
+ className={classNames(
35
+ 'relative group cursor-pointer',
36
+ 'bg-bolt-elements-background-depth-2',
37
+ 'hover:bg-bolt-elements-background-depth-3',
38
+ 'transition-colors duration-200',
39
+ 'rounded-lg overflow-hidden',
40
+ )}
41
+ initial={{ opacity: 0, y: 20 }}
42
+ animate={{ opacity: 1, y: 0 }}
43
+ transition={{ delay: index * 0.1 }}
44
+ >
45
+ <div className="p-4">
46
+ <div className="flex items-center justify-between">
47
+ <div className="flex items-center gap-3">
48
+ <div className={classNames(feature.icon, 'w-5 h-5 text-bolt-elements-textSecondary')} />
49
+ <div className="flex items-center gap-2">
50
+ <h4 className="font-medium text-bolt-elements-textPrimary">{feature.title}</h4>
51
+ {feature.beta && (
52
+ <span className="px-2 py-0.5 text-xs rounded-full bg-blue-500/10 text-blue-500 font-medium">Beta</span>
53
+ )}
54
+ {feature.experimental && (
55
+ <span className="px-2 py-0.5 text-xs rounded-full bg-orange-500/10 text-orange-500 font-medium">
56
+ Experimental
57
+ </span>
58
+ )}
59
+ </div>
60
+ </div>
61
+ <Switch checked={feature.enabled} onCheckedChange={(checked) => onToggle(feature.id, checked)} />
62
+ </div>
63
+ <p className="mt-2 text-sm text-bolt-elements-textSecondary">{feature.description}</p>
64
+ {feature.tooltip && <p className="mt-1 text-xs text-bolt-elements-textTertiary">{feature.tooltip}</p>}
65
+ </div>
66
+ </motion.div>
67
+ ),
68
+ );
69
+
70
+ const FeatureSection = memo(
71
+ ({
72
+ title,
73
+ features,
74
+ icon,
75
+ description,
76
+ onToggleFeature,
77
+ }: {
78
+ title: string;
79
+ features: FeatureToggle[];
80
+ icon: string;
81
+ description: string;
82
+ onToggleFeature: (id: string, enabled: boolean) => void;
83
+ }) => (
84
+ <motion.div
85
+ layout
86
+ className="flex flex-col gap-4"
87
+ initial={{ opacity: 0, y: 20 }}
88
+ animate={{ opacity: 1, y: 0 }}
89
+ transition={{ duration: 0.3 }}
90
+ >
91
+ <div className="flex items-center gap-3">
92
+ <div className={classNames(icon, 'text-xl text-purple-500')} />
93
+ <div>
94
+ <h3 className="text-lg font-medium text-bolt-elements-textPrimary">{title}</h3>
95
+ <p className="text-sm text-bolt-elements-textSecondary">{description}</p>
96
+ </div>
97
+ </div>
98
+
99
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
100
+ {features.map((feature, index) => (
101
+ <FeatureCard key={feature.id} feature={feature} index={index} onToggle={onToggleFeature} />
102
+ ))}
103
+ </div>
104
+ </motion.div>
105
+ ),
106
+ );
107
+
108
+ export default function FeaturesTab() {
109
+ const {
110
+ autoSelectTemplate,
111
+ isLatestBranch,
112
+ contextOptimizationEnabled,
113
+ eventLogs,
114
+ setAutoSelectTemplate,
115
+ enableLatestBranch,
116
+ enableContextOptimization,
117
+ setEventLogs,
118
+ setPromptId,
119
+ promptId,
120
+ } = useSettings();
121
+
122
+ // Enable features by default on first load
123
+ React.useEffect(() => {
124
+ // Only set defaults if values are undefined
125
+ if (isLatestBranch === undefined) {
126
+ enableLatestBranch(false); // Default: OFF - Don't auto-update from main branch
127
+ }
128
+
129
+ if (contextOptimizationEnabled === undefined) {
130
+ enableContextOptimization(true); // Default: ON - Enable context optimization
131
+ }
132
+
133
+ if (autoSelectTemplate === undefined) {
134
+ setAutoSelectTemplate(true); // Default: ON - Enable auto-select templates
135
+ }
136
+
137
+ if (promptId === undefined) {
138
+ setPromptId('default'); // Default: 'default'
139
+ }
140
+
141
+ if (eventLogs === undefined) {
142
+ setEventLogs(true); // Default: ON - Enable event logging
143
+ }
144
+ }, []); // Only run once on component mount
145
+
146
+ const handleToggleFeature = useCallback(
147
+ (id: string, enabled: boolean) => {
148
+ switch (id) {
149
+ case 'latestBranch': {
150
+ enableLatestBranch(enabled);
151
+ toast.success(`Main branch updates ${enabled ? 'enabled' : 'disabled'}`);
152
+ break;
153
+ }
154
+
155
+ case 'autoSelectTemplate': {
156
+ setAutoSelectTemplate(enabled);
157
+ toast.success(`Auto select template ${enabled ? 'enabled' : 'disabled'}`);
158
+ break;
159
+ }
160
+
161
+ case 'contextOptimization': {
162
+ enableContextOptimization(enabled);
163
+ toast.success(`Context optimization ${enabled ? 'enabled' : 'disabled'}`);
164
+ break;
165
+ }
166
+
167
+ case 'eventLogs': {
168
+ setEventLogs(enabled);
169
+ toast.success(`Event logging ${enabled ? 'enabled' : 'disabled'}`);
170
+ break;
171
+ }
172
+
173
+ default:
174
+ break;
175
+ }
176
+ },
177
+ [enableLatestBranch, setAutoSelectTemplate, enableContextOptimization, setEventLogs],
178
+ );
179
+
180
+ const features = {
181
+ stable: [
182
+ {
183
+ id: 'latestBranch',
184
+ title: 'Main Branch Updates',
185
+ description: 'Get the latest updates from the main branch',
186
+ icon: 'i-ph:git-branch',
187
+ enabled: isLatestBranch,
188
+ tooltip: 'Enabled by default to receive updates from the main development branch',
189
+ },
190
+ {
191
+ id: 'autoSelectTemplate',
192
+ title: 'Auto Select Template',
193
+ description: 'Automatically select starter template',
194
+ icon: 'i-ph:selection',
195
+ enabled: autoSelectTemplate,
196
+ tooltip: 'Enabled by default to automatically select the most appropriate starter template',
197
+ },
198
+ {
199
+ id: 'contextOptimization',
200
+ title: 'Context Optimization',
201
+ description: 'Optimize context for better responses',
202
+ icon: 'i-ph:brain',
203
+ enabled: contextOptimizationEnabled,
204
+ tooltip: 'Enabled by default for improved AI responses',
205
+ },
206
+ {
207
+ id: 'eventLogs',
208
+ title: 'Event Logging',
209
+ description: 'Enable detailed event logging and history',
210
+ icon: 'i-ph:list-bullets',
211
+ enabled: eventLogs,
212
+ tooltip: 'Enabled by default to record detailed logs of system events and user actions',
213
+ },
214
+ ],
215
+ beta: [],
216
+ };
217
+
218
+ return (
219
+ <div className="flex flex-col gap-8">
220
+ <FeatureSection
221
+ title="Core Features"
222
+ features={features.stable}
223
+ icon="i-ph:check-circle"
224
+ description="Essential features that are enabled by default for optimal performance"
225
+ onToggleFeature={handleToggleFeature}
226
+ />
227
+
228
+ {features.beta.length > 0 && (
229
+ <FeatureSection
230
+ title="Beta Features"
231
+ features={features.beta}
232
+ icon="i-ph:test-tube"
233
+ description="New features that are ready for testing but may have some rough edges"
234
+ onToggleFeature={handleToggleFeature}
235
+ />
236
+ )}
237
+
238
+ <motion.div
239
+ layout
240
+ className={classNames(
241
+ 'bg-bolt-elements-background-depth-2',
242
+ 'hover:bg-bolt-elements-background-depth-3',
243
+ 'transition-all duration-200',
244
+ 'rounded-lg p-4',
245
+ 'group',
246
+ )}
247
+ initial={{ opacity: 0, y: 20 }}
248
+ animate={{ opacity: 1, y: 0 }}
249
+ transition={{ delay: 0.3 }}
250
+ >
251
+ <div className="flex items-center gap-4">
252
+ <div
253
+ className={classNames(
254
+ 'p-2 rounded-lg text-xl',
255
+ 'bg-bolt-elements-background-depth-3 group-hover:bg-bolt-elements-background-depth-4',
256
+ 'transition-colors duration-200',
257
+ 'text-purple-500',
258
+ )}
259
+ >
260
+ <div className="i-ph:book" />
261
+ </div>
262
+ <div className="flex-1">
263
+ <h4 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-purple-500 transition-colors">
264
+ Prompt Library
265
+ </h4>
266
+ <p className="text-xs text-bolt-elements-textSecondary mt-0.5">
267
+ Choose a prompt from the library to use as the system prompt
268
+ </p>
269
+ </div>
270
+ <select
271
+ value={promptId}
272
+ onChange={(e) => {
273
+ setPromptId(e.target.value);
274
+ toast.success('Prompt template updated');
275
+ }}
276
+ className={classNames(
277
+ 'p-2 rounded-lg text-sm min-w-[200px]',
278
+ 'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
279
+ 'text-bolt-elements-textPrimary',
280
+ 'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
281
+ 'group-hover:border-purple-500/30',
282
+ 'transition-all duration-200',
283
+ )}
284
+ >
285
+ {PromptLibrary.getList().map((x) => (
286
+ <option key={x.id} value={x.id}>
287
+ {x.label}
288
+ </option>
289
+ ))}
290
+ </select>
291
+ </div>
292
+ </motion.div>
293
+ </div>
294
+ );
295
+ }
app/components/@settings/tabs/notifications/NotificationsTab.tsx ADDED
@@ -0,0 +1,300 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react';
2
+ import { motion } from 'framer-motion';
3
+ import { logStore } from '~/lib/stores/logs';
4
+ import { useStore } from '@nanostores/react';
5
+ import { formatDistanceToNow } from 'date-fns';
6
+ import { classNames } from '~/utils/classNames';
7
+ import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
8
+
9
+ interface NotificationDetails {
10
+ type?: string;
11
+ message?: string;
12
+ currentVersion?: string;
13
+ latestVersion?: string;
14
+ branch?: string;
15
+ updateUrl?: string;
16
+ }
17
+
18
+ type FilterType = 'all' | 'system' | 'error' | 'warning' | 'update' | 'info' | 'provider' | 'network';
19
+
20
+ const NotificationsTab = () => {
21
+ const [filter, setFilter] = useState<FilterType>('all');
22
+ const logs = useStore(logStore.logs);
23
+
24
+ useEffect(() => {
25
+ const startTime = performance.now();
26
+
27
+ return () => {
28
+ const duration = performance.now() - startTime;
29
+ logStore.logPerformanceMetric('NotificationsTab', 'mount-duration', duration);
30
+ };
31
+ }, []);
32
+
33
+ const handleClearNotifications = () => {
34
+ const count = Object.keys(logs).length;
35
+ logStore.logInfo('Cleared notifications', {
36
+ type: 'notification_clear',
37
+ message: `Cleared ${count} notifications`,
38
+ clearedCount: count,
39
+ component: 'notifications',
40
+ });
41
+ logStore.clearLogs();
42
+ };
43
+
44
+ const handleUpdateAction = (updateUrl: string) => {
45
+ logStore.logInfo('Update link clicked', {
46
+ type: 'update_click',
47
+ message: 'User clicked update link',
48
+ updateUrl,
49
+ component: 'notifications',
50
+ });
51
+ window.open(updateUrl, '_blank');
52
+ };
53
+
54
+ const handleFilterChange = (newFilter: FilterType) => {
55
+ logStore.logInfo('Notification filter changed', {
56
+ type: 'filter_change',
57
+ message: `Filter changed to ${newFilter}`,
58
+ previousFilter: filter,
59
+ newFilter,
60
+ component: 'notifications',
61
+ });
62
+ setFilter(newFilter);
63
+ };
64
+
65
+ const filteredLogs = Object.values(logs)
66
+ .filter((log) => {
67
+ if (filter === 'all') {
68
+ return true;
69
+ }
70
+
71
+ if (filter === 'update') {
72
+ return log.details?.type === 'update';
73
+ }
74
+
75
+ if (filter === 'system') {
76
+ return log.category === 'system';
77
+ }
78
+
79
+ if (filter === 'provider') {
80
+ return log.category === 'provider';
81
+ }
82
+
83
+ if (filter === 'network') {
84
+ return log.category === 'network';
85
+ }
86
+
87
+ return log.level === filter;
88
+ })
89
+ .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
90
+
91
+ const getNotificationStyle = (level: string, type?: string) => {
92
+ if (type === 'update') {
93
+ return {
94
+ icon: 'i-ph:arrow-circle-up',
95
+ color: 'text-purple-500 dark:text-purple-400',
96
+ bg: 'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
97
+ };
98
+ }
99
+
100
+ switch (level) {
101
+ case 'error':
102
+ return {
103
+ icon: 'i-ph:warning-circle',
104
+ color: 'text-red-500 dark:text-red-400',
105
+ bg: 'hover:bg-red-500/10 dark:hover:bg-red-500/20',
106
+ };
107
+ case 'warning':
108
+ return {
109
+ icon: 'i-ph:warning',
110
+ color: 'text-yellow-500 dark:text-yellow-400',
111
+ bg: 'hover:bg-yellow-500/10 dark:hover:bg-yellow-500/20',
112
+ };
113
+ case 'info':
114
+ return {
115
+ icon: 'i-ph:info',
116
+ color: 'text-blue-500 dark:text-blue-400',
117
+ bg: 'hover:bg-blue-500/10 dark:hover:bg-blue-500/20',
118
+ };
119
+ default:
120
+ return {
121
+ icon: 'i-ph:bell',
122
+ color: 'text-gray-500 dark:text-gray-400',
123
+ bg: 'hover:bg-gray-500/10 dark:hover:bg-gray-500/20',
124
+ };
125
+ }
126
+ };
127
+
128
+ const renderNotificationDetails = (details: NotificationDetails) => {
129
+ if (details.type === 'update') {
130
+ return (
131
+ <div className="flex flex-col gap-2">
132
+ <p className="text-sm text-gray-600 dark:text-gray-400">{details.message}</p>
133
+ <div className="flex flex-col gap-1 text-xs text-gray-500 dark:text-gray-500">
134
+ <p>Current Version: {details.currentVersion}</p>
135
+ <p>Latest Version: {details.latestVersion}</p>
136
+ <p>Branch: {details.branch}</p>
137
+ </div>
138
+ <button
139
+ onClick={() => details.updateUrl && handleUpdateAction(details.updateUrl)}
140
+ className={classNames(
141
+ 'mt-2 inline-flex items-center gap-2',
142
+ 'rounded-lg px-3 py-1.5',
143
+ 'text-sm font-medium',
144
+ 'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
145
+ 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
146
+ 'text-gray-900 dark:text-white',
147
+ 'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
148
+ 'transition-all duration-200',
149
+ )}
150
+ >
151
+ <span className="i-ph:git-branch text-lg" />
152
+ View Changes
153
+ </button>
154
+ </div>
155
+ );
156
+ }
157
+
158
+ return details.message ? <p className="text-sm text-gray-600 dark:text-gray-400">{details.message}</p> : null;
159
+ };
160
+
161
+ const filterOptions: { id: FilterType; label: string; icon: string; color: string }[] = [
162
+ { id: 'all', label: 'All Notifications', icon: 'i-ph:bell', color: '#9333ea' },
163
+ { id: 'system', label: 'System', icon: 'i-ph:gear', color: '#6b7280' },
164
+ { id: 'update', label: 'Updates', icon: 'i-ph:arrow-circle-up', color: '#9333ea' },
165
+ { id: 'error', label: 'Errors', icon: 'i-ph:warning-circle', color: '#ef4444' },
166
+ { id: 'warning', label: 'Warnings', icon: 'i-ph:warning', color: '#f59e0b' },
167
+ { id: 'info', label: 'Information', icon: 'i-ph:info', color: '#3b82f6' },
168
+ { id: 'provider', label: 'Providers', icon: 'i-ph:robot', color: '#10b981' },
169
+ { id: 'network', label: 'Network', icon: 'i-ph:wifi-high', color: '#6366f1' },
170
+ ];
171
+
172
+ return (
173
+ <div className="flex h-full flex-col gap-6">
174
+ <div className="flex items-center justify-between">
175
+ <DropdownMenu.Root>
176
+ <DropdownMenu.Trigger asChild>
177
+ <button
178
+ className={classNames(
179
+ 'flex items-center gap-2',
180
+ 'rounded-lg px-3 py-1.5',
181
+ 'text-sm text-gray-900 dark:text-white',
182
+ 'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
183
+ 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
184
+ 'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
185
+ 'transition-all duration-200',
186
+ )}
187
+ >
188
+ <span
189
+ className={classNames('text-lg', filterOptions.find((opt) => opt.id === filter)?.icon || 'i-ph:funnel')}
190
+ style={{ color: filterOptions.find((opt) => opt.id === filter)?.color }}
191
+ />
192
+ {filterOptions.find((opt) => opt.id === filter)?.label || 'Filter Notifications'}
193
+ <span className="i-ph:caret-down text-lg text-gray-500 dark:text-gray-400" />
194
+ </button>
195
+ </DropdownMenu.Trigger>
196
+
197
+ <DropdownMenu.Portal>
198
+ <DropdownMenu.Content
199
+ className="min-w-[200px] bg-white dark:bg-[#0A0A0A] rounded-lg shadow-lg py-1 z-[250] animate-in fade-in-0 zoom-in-95 border border-[#E5E5E5] dark:border-[#1A1A1A]"
200
+ sideOffset={5}
201
+ align="start"
202
+ side="bottom"
203
+ >
204
+ {filterOptions.map((option) => (
205
+ <DropdownMenu.Item
206
+ key={option.id}
207
+ className="group flex items-center px-4 py-2.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-purple-500/10 dark:hover:bg-purple-500/20 cursor-pointer transition-colors"
208
+ onClick={() => handleFilterChange(option.id)}
209
+ >
210
+ <div className="mr-3 flex h-5 w-5 items-center justify-center">
211
+ <div
212
+ className={classNames(option.icon, 'text-lg group-hover:text-purple-500 transition-colors')}
213
+ style={{ color: option.color }}
214
+ />
215
+ </div>
216
+ <span className="group-hover:text-purple-500 transition-colors">{option.label}</span>
217
+ </DropdownMenu.Item>
218
+ ))}
219
+ </DropdownMenu.Content>
220
+ </DropdownMenu.Portal>
221
+ </DropdownMenu.Root>
222
+
223
+ <button
224
+ onClick={handleClearNotifications}
225
+ className={classNames(
226
+ 'group flex items-center gap-2',
227
+ 'rounded-lg px-3 py-1.5',
228
+ 'text-sm text-gray-900 dark:text-white',
229
+ 'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
230
+ 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
231
+ 'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
232
+ 'transition-all duration-200',
233
+ )}
234
+ >
235
+ <span className="i-ph:trash text-lg text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
236
+ Clear All
237
+ </button>
238
+ </div>
239
+
240
+ <div className="flex flex-col gap-4">
241
+ {filteredLogs.length === 0 ? (
242
+ <motion.div
243
+ initial={{ opacity: 0, y: 20 }}
244
+ animate={{ opacity: 1, y: 0 }}
245
+ className={classNames(
246
+ 'flex flex-col items-center justify-center gap-4',
247
+ 'rounded-lg p-8 text-center',
248
+ 'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
249
+ 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
250
+ )}
251
+ >
252
+ <span className="i-ph:bell-slash text-4xl text-gray-400 dark:text-gray-600" />
253
+ <div className="flex flex-col gap-1">
254
+ <h3 className="text-sm font-medium text-gray-900 dark:text-white">No Notifications</h3>
255
+ <p className="text-sm text-gray-500 dark:text-gray-400">You're all caught up!</p>
256
+ </div>
257
+ </motion.div>
258
+ ) : (
259
+ filteredLogs.map((log) => {
260
+ const style = getNotificationStyle(log.level, log.details?.type);
261
+ return (
262
+ <motion.div
263
+ key={log.id}
264
+ initial={{ opacity: 0, y: 20 }}
265
+ animate={{ opacity: 1, y: 0 }}
266
+ className={classNames(
267
+ 'flex flex-col gap-2',
268
+ 'rounded-lg p-4',
269
+ 'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
270
+ 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
271
+ style.bg,
272
+ 'transition-all duration-200',
273
+ )}
274
+ >
275
+ <div className="flex items-start justify-between gap-4">
276
+ <div className="flex items-start gap-3">
277
+ <span className={classNames('text-lg', style.icon, style.color)} />
278
+ <div className="flex flex-col gap-1">
279
+ <h3 className="text-sm font-medium text-gray-900 dark:text-white">{log.message}</h3>
280
+ {log.details && renderNotificationDetails(log.details as NotificationDetails)}
281
+ <p className="text-xs text-gray-500 dark:text-gray-400">
282
+ Category: {log.category}
283
+ {log.subCategory ? ` > ${log.subCategory}` : ''}
284
+ </p>
285
+ </div>
286
+ </div>
287
+ <time className="shrink-0 text-xs text-gray-500 dark:text-gray-400">
288
+ {formatDistanceToNow(new Date(log.timestamp), { addSuffix: true })}
289
+ </time>
290
+ </div>
291
+ </motion.div>
292
+ );
293
+ })
294
+ )}
295
+ </div>
296
+ </div>
297
+ );
298
+ };
299
+
300
+ export default NotificationsTab;
app/components/@settings/tabs/profile/ProfileTab.tsx ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useCallback } from 'react';
2
+ import { useStore } from '@nanostores/react';
3
+ import { classNames } from '~/utils/classNames';
4
+ import { profileStore, updateProfile } from '~/lib/stores/profile';
5
+ import { toast } from 'react-toastify';
6
+ import { debounce } from '~/utils/debounce';
7
+
8
+ export default function ProfileTab() {
9
+ const profile = useStore(profileStore);
10
+ const [isUploading, setIsUploading] = useState(false);
11
+
12
+ // Create debounced update functions
13
+ const debouncedUpdate = useCallback(
14
+ debounce((field: 'username' | 'bio', value: string) => {
15
+ updateProfile({ [field]: value });
16
+ toast.success(`${field.charAt(0).toUpperCase() + field.slice(1)} updated`);
17
+ }, 1000),
18
+ [],
19
+ );
20
+
21
+ const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
22
+ const file = e.target.files?.[0];
23
+
24
+ if (!file) {
25
+ return;
26
+ }
27
+
28
+ try {
29
+ setIsUploading(true);
30
+
31
+ // Convert the file to base64
32
+ const reader = new FileReader();
33
+
34
+ reader.onloadend = () => {
35
+ const base64String = reader.result as string;
36
+ updateProfile({ avatar: base64String });
37
+ setIsUploading(false);
38
+ toast.success('Profile picture updated');
39
+ };
40
+
41
+ reader.onerror = () => {
42
+ console.error('Error reading file:', reader.error);
43
+ setIsUploading(false);
44
+ toast.error('Failed to update profile picture');
45
+ };
46
+ reader.readAsDataURL(file);
47
+ } catch (error) {
48
+ console.error('Error uploading avatar:', error);
49
+ setIsUploading(false);
50
+ toast.error('Failed to update profile picture');
51
+ }
52
+ };
53
+
54
+ const handleProfileUpdate = (field: 'username' | 'bio', value: string) => {
55
+ // Update the store immediately for UI responsiveness
56
+ updateProfile({ [field]: value });
57
+
58
+ // Debounce the toast notification
59
+ debouncedUpdate(field, value);
60
+ };
61
+
62
+ return (
63
+ <div className="max-w-2xl mx-auto">
64
+ <div className="space-y-6">
65
+ {/* Personal Information Section */}
66
+ <div>
67
+ {/* Avatar Upload */}
68
+ <div className="flex items-start gap-6 mb-8">
69
+ <div
70
+ className={classNames(
71
+ 'w-24 h-24 rounded-full overflow-hidden',
72
+ 'bg-gray-100 dark:bg-gray-800/50',
73
+ 'flex items-center justify-center',
74
+ 'ring-1 ring-gray-200 dark:ring-gray-700',
75
+ 'relative group',
76
+ 'transition-all duration-300 ease-out',
77
+ 'hover:ring-purple-500/30 dark:hover:ring-purple-500/30',
78
+ 'hover:shadow-lg hover:shadow-purple-500/10',
79
+ )}
80
+ >
81
+ {profile.avatar ? (
82
+ <img
83
+ src={profile.avatar}
84
+ alt="Profile"
85
+ className={classNames(
86
+ 'w-full h-full object-cover',
87
+ 'transition-all duration-300 ease-out',
88
+ 'group-hover:scale-105 group-hover:brightness-90',
89
+ )}
90
+ />
91
+ ) : (
92
+ <div className="i-ph:robot-fill w-16 h-16 text-gray-400 dark:text-gray-500 transition-colors group-hover:text-purple-500/70 transform -translate-y-1" />
93
+ )}
94
+
95
+ <label
96
+ className={classNames(
97
+ 'absolute inset-0',
98
+ 'flex items-center justify-center',
99
+ 'bg-black/0 group-hover:bg-black/40',
100
+ 'cursor-pointer transition-all duration-300 ease-out',
101
+ isUploading ? 'cursor-wait' : '',
102
+ )}
103
+ >
104
+ <input
105
+ type="file"
106
+ accept="image/*"
107
+ className="hidden"
108
+ onChange={handleAvatarUpload}
109
+ disabled={isUploading}
110
+ />
111
+ {isUploading ? (
112
+ <div className="i-ph:spinner-gap w-6 h-6 text-white animate-spin" />
113
+ ) : (
114
+ <div className="i-ph:camera-plus w-6 h-6 text-white opacity-0 group-hover:opacity-100 transition-all duration-300 ease-out transform group-hover:scale-110" />
115
+ )}
116
+ </label>
117
+ </div>
118
+
119
+ <div className="flex-1 pt-1">
120
+ <label className="block text-base font-medium text-gray-900 dark:text-gray-100 mb-1">
121
+ Profile Picture
122
+ </label>
123
+ <p className="text-sm text-gray-500 dark:text-gray-400">Upload a profile picture or avatar</p>
124
+ </div>
125
+ </div>
126
+
127
+ {/* Username Input */}
128
+ <div className="mb-6">
129
+ <label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Username</label>
130
+ <div className="relative group">
131
+ <div className="absolute left-3.5 top-1/2 -translate-y-1/2">
132
+ <div className="i-ph:user-circle-fill w-5 h-5 text-gray-400 dark:text-gray-500 transition-colors group-focus-within:text-purple-500" />
133
+ </div>
134
+ <input
135
+ type="text"
136
+ value={profile.username}
137
+ onChange={(e) => handleProfileUpdate('username', e.target.value)}
138
+ className={classNames(
139
+ 'w-full pl-11 pr-4 py-2.5 rounded-xl',
140
+ 'bg-white dark:bg-gray-800/50',
141
+ 'border border-gray-200 dark:border-gray-700/50',
142
+ 'text-gray-900 dark:text-white',
143
+ 'placeholder-gray-400 dark:placeholder-gray-500',
144
+ 'focus:outline-none focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500/50',
145
+ 'transition-all duration-300 ease-out',
146
+ )}
147
+ placeholder="Enter your username"
148
+ />
149
+ </div>
150
+ </div>
151
+
152
+ {/* Bio Input */}
153
+ <div className="mb-8">
154
+ <label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Bio</label>
155
+ <div className="relative group">
156
+ <div className="absolute left-3.5 top-3">
157
+ <div className="i-ph:text-aa w-5 h-5 text-gray-400 dark:text-gray-500 transition-colors group-focus-within:text-purple-500" />
158
+ </div>
159
+ <textarea
160
+ value={profile.bio}
161
+ onChange={(e) => handleProfileUpdate('bio', e.target.value)}
162
+ className={classNames(
163
+ 'w-full pl-11 pr-4 py-2.5 rounded-xl',
164
+ 'bg-white dark:bg-gray-800/50',
165
+ 'border border-gray-200 dark:border-gray-700/50',
166
+ 'text-gray-900 dark:text-white',
167
+ 'placeholder-gray-400 dark:placeholder-gray-500',
168
+ 'focus:outline-none focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500/50',
169
+ 'transition-all duration-300 ease-out',
170
+ 'resize-none',
171
+ 'h-32',
172
+ )}
173
+ placeholder="Tell us about yourself"
174
+ />
175
+ </div>
176
+ </div>
177
+ </div>
178
+ </div>
179
+ </div>
180
+ );
181
+ }
app/components/@settings/tabs/providers/cloud/CloudProvidersTab.tsx ADDED
@@ -0,0 +1,305 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState, useCallback } from 'react';
2
+ import { Switch } from '~/components/ui/Switch';
3
+ import { useSettings } from '~/lib/hooks/useSettings';
4
+ import { URL_CONFIGURABLE_PROVIDERS } from '~/lib/stores/settings';
5
+ import type { IProviderConfig } from '~/types/model';
6
+ import { logStore } from '~/lib/stores/logs';
7
+ import { motion } from 'framer-motion';
8
+ import { classNames } from '~/utils/classNames';
9
+ import { toast } from 'react-toastify';
10
+ import { providerBaseUrlEnvKeys } from '~/utils/constants';
11
+ import { SiAmazon, SiGoogle, SiHuggingface, SiPerplexity, SiOpenai } from 'react-icons/si';
12
+ import { BsRobot, BsCloud } from 'react-icons/bs';
13
+ import { TbBrain, TbCloudComputing } from 'react-icons/tb';
14
+ import { BiCodeBlock, BiChip } from 'react-icons/bi';
15
+ import { FaCloud, FaBrain } from 'react-icons/fa';
16
+ import type { IconType } from 'react-icons';
17
+
18
+ // Add type for provider names to ensure type safety
19
+ type ProviderName =
20
+ | 'AmazonBedrock'
21
+ | 'Anthropic'
22
+ | 'Cohere'
23
+ | 'Deepseek'
24
+ | 'Google'
25
+ | 'Groq'
26
+ | 'HuggingFace'
27
+ | 'Hyperbolic'
28
+ | 'Mistral'
29
+ | 'OpenAI'
30
+ | 'OpenRouter'
31
+ | 'Perplexity'
32
+ | 'Together'
33
+ | 'XAI';
34
+
35
+ // Update the PROVIDER_ICONS type to use the ProviderName type
36
+ const PROVIDER_ICONS: Record<ProviderName, IconType> = {
37
+ AmazonBedrock: SiAmazon,
38
+ Anthropic: FaBrain,
39
+ Cohere: BiChip,
40
+ Deepseek: BiCodeBlock,
41
+ Google: SiGoogle,
42
+ Groq: BsCloud,
43
+ HuggingFace: SiHuggingface,
44
+ Hyperbolic: TbCloudComputing,
45
+ Mistral: TbBrain,
46
+ OpenAI: SiOpenai,
47
+ OpenRouter: FaCloud,
48
+ Perplexity: SiPerplexity,
49
+ Together: BsCloud,
50
+ XAI: BsRobot,
51
+ };
52
+
53
+ // Update PROVIDER_DESCRIPTIONS to use the same type
54
+ const PROVIDER_DESCRIPTIONS: Partial<Record<ProviderName, string>> = {
55
+ Anthropic: 'Access Claude and other Anthropic models',
56
+ OpenAI: 'Use GPT-4, GPT-3.5, and other OpenAI models',
57
+ };
58
+
59
+ const CloudProvidersTab = () => {
60
+ const settings = useSettings();
61
+ const [editingProvider, setEditingProvider] = useState<string | null>(null);
62
+ const [filteredProviders, setFilteredProviders] = useState<IProviderConfig[]>([]);
63
+ const [categoryEnabled, setCategoryEnabled] = useState<boolean>(false);
64
+
65
+ // Load and filter providers
66
+ useEffect(() => {
67
+ const newFilteredProviders = Object.entries(settings.providers || {})
68
+ .filter(([key]) => !['Ollama', 'LMStudio', 'OpenAILike'].includes(key))
69
+ .map(([key, value]) => ({
70
+ name: key,
71
+ settings: value.settings,
72
+ staticModels: value.staticModels || [],
73
+ getDynamicModels: value.getDynamicModels,
74
+ getApiKeyLink: value.getApiKeyLink,
75
+ labelForGetApiKey: value.labelForGetApiKey,
76
+ icon: value.icon,
77
+ }));
78
+
79
+ const sorted = newFilteredProviders.sort((a, b) => a.name.localeCompare(b.name));
80
+ setFilteredProviders(sorted);
81
+
82
+ // Update category enabled state
83
+ const allEnabled = newFilteredProviders.every((p) => p.settings.enabled);
84
+ setCategoryEnabled(allEnabled);
85
+ }, [settings.providers]);
86
+
87
+ const handleToggleCategory = useCallback(
88
+ (enabled: boolean) => {
89
+ // Update all providers
90
+ filteredProviders.forEach((provider) => {
91
+ settings.updateProviderSettings(provider.name, { ...provider.settings, enabled });
92
+ });
93
+
94
+ setCategoryEnabled(enabled);
95
+ toast.success(enabled ? 'All cloud providers enabled' : 'All cloud providers disabled');
96
+ },
97
+ [filteredProviders, settings],
98
+ );
99
+
100
+ const handleToggleProvider = useCallback(
101
+ (provider: IProviderConfig, enabled: boolean) => {
102
+ // Update the provider settings in the store
103
+ settings.updateProviderSettings(provider.name, { ...provider.settings, enabled });
104
+
105
+ if (enabled) {
106
+ logStore.logProvider(`Provider ${provider.name} enabled`, { provider: provider.name });
107
+ toast.success(`${provider.name} enabled`);
108
+ } else {
109
+ logStore.logProvider(`Provider ${provider.name} disabled`, { provider: provider.name });
110
+ toast.success(`${provider.name} disabled`);
111
+ }
112
+ },
113
+ [settings],
114
+ );
115
+
116
+ const handleUpdateBaseUrl = useCallback(
117
+ (provider: IProviderConfig, baseUrl: string) => {
118
+ const newBaseUrl: string | undefined = baseUrl.trim() || undefined;
119
+
120
+ // Update the provider settings in the store
121
+ settings.updateProviderSettings(provider.name, { ...provider.settings, baseUrl: newBaseUrl });
122
+
123
+ logStore.logProvider(`Base URL updated for ${provider.name}`, {
124
+ provider: provider.name,
125
+ baseUrl: newBaseUrl,
126
+ });
127
+ toast.success(`${provider.name} base URL updated`);
128
+ setEditingProvider(null);
129
+ },
130
+ [settings],
131
+ );
132
+
133
+ return (
134
+ <div className="space-y-6">
135
+ <motion.div
136
+ className="space-y-4"
137
+ initial={{ opacity: 0, y: 20 }}
138
+ animate={{ opacity: 1, y: 0 }}
139
+ transition={{ duration: 0.3 }}
140
+ >
141
+ <div className="flex items-center justify-between gap-4 mt-8 mb-4">
142
+ <div className="flex items-center gap-2">
143
+ <div
144
+ className={classNames(
145
+ 'w-8 h-8 flex items-center justify-center rounded-lg',
146
+ 'bg-bolt-elements-background-depth-3',
147
+ 'text-purple-500',
148
+ )}
149
+ >
150
+ <TbCloudComputing className="w-5 h-5" />
151
+ </div>
152
+ <div>
153
+ <h4 className="text-md font-medium text-bolt-elements-textPrimary">Cloud Providers</h4>
154
+ <p className="text-sm text-bolt-elements-textSecondary">Connect to cloud-based AI models and services</p>
155
+ </div>
156
+ </div>
157
+
158
+ <div className="flex items-center gap-2">
159
+ <span className="text-sm text-bolt-elements-textSecondary">Enable All Cloud</span>
160
+ <Switch checked={categoryEnabled} onCheckedChange={handleToggleCategory} />
161
+ </div>
162
+ </div>
163
+
164
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
165
+ {filteredProviders.map((provider, index) => (
166
+ <motion.div
167
+ key={provider.name}
168
+ className={classNames(
169
+ 'rounded-lg border bg-bolt-elements-background text-bolt-elements-textPrimary shadow-sm',
170
+ 'bg-bolt-elements-background-depth-2',
171
+ 'hover:bg-bolt-elements-background-depth-3',
172
+ 'transition-all duration-200',
173
+ 'relative overflow-hidden group',
174
+ 'flex flex-col',
175
+ )}
176
+ initial={{ opacity: 0, y: 20 }}
177
+ animate={{ opacity: 1, y: 0 }}
178
+ transition={{ delay: index * 0.1 }}
179
+ whileHover={{ scale: 1.02 }}
180
+ >
181
+ <div className="absolute top-0 right-0 p-2 flex gap-1">
182
+ {URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
183
+ <motion.span
184
+ className="px-2 py-0.5 text-xs rounded-full bg-purple-500/10 text-purple-500 font-medium"
185
+ whileHover={{ scale: 1.05 }}
186
+ whileTap={{ scale: 0.95 }}
187
+ >
188
+ Configurable
189
+ </motion.span>
190
+ )}
191
+ </div>
192
+
193
+ <div className="flex items-start gap-4 p-4">
194
+ <motion.div
195
+ className={classNames(
196
+ 'w-10 h-10 flex items-center justify-center rounded-xl',
197
+ 'bg-bolt-elements-background-depth-3 group-hover:bg-bolt-elements-background-depth-4',
198
+ 'transition-all duration-200',
199
+ provider.settings.enabled ? 'text-purple-500' : 'text-bolt-elements-textSecondary',
200
+ )}
201
+ whileHover={{ scale: 1.1 }}
202
+ whileTap={{ scale: 0.9 }}
203
+ >
204
+ <div className={classNames('w-6 h-6', 'transition-transform duration-200', 'group-hover:rotate-12')}>
205
+ {React.createElement(PROVIDER_ICONS[provider.name as ProviderName] || BsRobot, {
206
+ className: 'w-full h-full',
207
+ 'aria-label': `${provider.name} logo`,
208
+ })}
209
+ </div>
210
+ </motion.div>
211
+
212
+ <div className="flex-1 min-w-0">
213
+ <div className="flex items-center justify-between gap-4 mb-2">
214
+ <div>
215
+ <h4 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-purple-500 transition-colors">
216
+ {provider.name}
217
+ </h4>
218
+ <p className="text-xs text-bolt-elements-textSecondary mt-0.5">
219
+ {PROVIDER_DESCRIPTIONS[provider.name as keyof typeof PROVIDER_DESCRIPTIONS] ||
220
+ (URL_CONFIGURABLE_PROVIDERS.includes(provider.name)
221
+ ? 'Configure custom endpoint for this provider'
222
+ : 'Standard AI provider integration')}
223
+ </p>
224
+ </div>
225
+ <Switch
226
+ checked={provider.settings.enabled}
227
+ onCheckedChange={(checked) => handleToggleProvider(provider, checked)}
228
+ />
229
+ </div>
230
+
231
+ {provider.settings.enabled && URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
232
+ <motion.div
233
+ initial={{ opacity: 0, height: 0 }}
234
+ animate={{ opacity: 1, height: 'auto' }}
235
+ exit={{ opacity: 0, height: 0 }}
236
+ transition={{ duration: 0.2 }}
237
+ >
238
+ <div className="flex items-center gap-2 mt-4">
239
+ {editingProvider === provider.name ? (
240
+ <input
241
+ type="text"
242
+ defaultValue={provider.settings.baseUrl}
243
+ placeholder={`Enter ${provider.name} base URL`}
244
+ className={classNames(
245
+ 'flex-1 px-3 py-1.5 rounded-lg text-sm',
246
+ 'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
247
+ 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
248
+ 'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
249
+ 'transition-all duration-200',
250
+ )}
251
+ onKeyDown={(e) => {
252
+ if (e.key === 'Enter') {
253
+ handleUpdateBaseUrl(provider, e.currentTarget.value);
254
+ } else if (e.key === 'Escape') {
255
+ setEditingProvider(null);
256
+ }
257
+ }}
258
+ onBlur={(e) => handleUpdateBaseUrl(provider, e.target.value)}
259
+ autoFocus
260
+ />
261
+ ) : (
262
+ <div
263
+ className="flex-1 px-3 py-1.5 rounded-lg text-sm cursor-pointer group/url"
264
+ onClick={() => setEditingProvider(provider.name)}
265
+ >
266
+ <div className="flex items-center gap-2 text-bolt-elements-textSecondary">
267
+ <div className="i-ph:link text-sm" />
268
+ <span className="group-hover/url:text-purple-500 transition-colors">
269
+ {provider.settings.baseUrl || 'Click to set base URL'}
270
+ </span>
271
+ </div>
272
+ </div>
273
+ )}
274
+ </div>
275
+
276
+ {providerBaseUrlEnvKeys[provider.name]?.baseUrlKey && (
277
+ <div className="mt-2 text-xs text-green-500">
278
+ <div className="flex items-center gap-1">
279
+ <div className="i-ph:info" />
280
+ <span>Environment URL set in .env file</span>
281
+ </div>
282
+ </div>
283
+ )}
284
+ </motion.div>
285
+ )}
286
+ </div>
287
+ </div>
288
+
289
+ <motion.div
290
+ className="absolute inset-0 border-2 border-purple-500/0 rounded-lg pointer-events-none"
291
+ animate={{
292
+ borderColor: provider.settings.enabled ? 'rgba(168, 85, 247, 0.2)' : 'rgba(168, 85, 247, 0)',
293
+ scale: provider.settings.enabled ? 1 : 0.98,
294
+ }}
295
+ transition={{ duration: 0.2 }}
296
+ />
297
+ </motion.div>
298
+ ))}
299
+ </div>
300
+ </motion.div>
301
+ </div>
302
+ );
303
+ };
304
+
305
+ export default CloudProvidersTab;
app/components/@settings/tabs/providers/local/LocalProvidersTab.tsx ADDED
@@ -0,0 +1,777 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState, useCallback } from 'react';
2
+ import { Switch } from '~/components/ui/Switch';
3
+ import { useSettings } from '~/lib/hooks/useSettings';
4
+ import { LOCAL_PROVIDERS, URL_CONFIGURABLE_PROVIDERS } from '~/lib/stores/settings';
5
+ import type { IProviderConfig } from '~/types/model';
6
+ import { logStore } from '~/lib/stores/logs';
7
+ import { motion, AnimatePresence } from 'framer-motion';
8
+ import { classNames } from '~/utils/classNames';
9
+ import { BsRobot } from 'react-icons/bs';
10
+ import type { IconType } from 'react-icons';
11
+ import { BiChip } from 'react-icons/bi';
12
+ import { TbBrandOpenai } from 'react-icons/tb';
13
+ import { providerBaseUrlEnvKeys } from '~/utils/constants';
14
+ import { useToast } from '~/components/ui/use-toast';
15
+ import { Progress } from '~/components/ui/Progress';
16
+ import OllamaModelInstaller from './OllamaModelInstaller';
17
+
18
+ // Add type for provider names to ensure type safety
19
+ type ProviderName = 'Ollama' | 'LMStudio' | 'OpenAILike';
20
+
21
+ // Update the PROVIDER_ICONS type to use the ProviderName type
22
+ const PROVIDER_ICONS: Record<ProviderName, IconType> = {
23
+ Ollama: BsRobot,
24
+ LMStudio: BsRobot,
25
+ OpenAILike: TbBrandOpenai,
26
+ };
27
+
28
+ // Update PROVIDER_DESCRIPTIONS to use the same type
29
+ const PROVIDER_DESCRIPTIONS: Record<ProviderName, string> = {
30
+ Ollama: 'Run open-source models locally on your machine',
31
+ LMStudio: 'Local model inference with LM Studio',
32
+ OpenAILike: 'Connect to OpenAI-compatible API endpoints',
33
+ };
34
+
35
+ // Add a constant for the Ollama API base URL
36
+ const OLLAMA_API_URL = 'http://127.0.0.1:11434';
37
+
38
+ interface OllamaModel {
39
+ name: string;
40
+ digest: string;
41
+ size: number;
42
+ modified_at: string;
43
+ details?: {
44
+ family: string;
45
+ parameter_size: string;
46
+ quantization_level: string;
47
+ };
48
+ status?: 'idle' | 'updating' | 'updated' | 'error' | 'checking';
49
+ error?: string;
50
+ newDigest?: string;
51
+ progress?: {
52
+ current: number;
53
+ total: number;
54
+ status: string;
55
+ };
56
+ }
57
+
58
+ interface OllamaPullResponse {
59
+ status: string;
60
+ completed?: number;
61
+ total?: number;
62
+ digest?: string;
63
+ }
64
+
65
+ const isOllamaPullResponse = (data: unknown): data is OllamaPullResponse => {
66
+ return (
67
+ typeof data === 'object' &&
68
+ data !== null &&
69
+ 'status' in data &&
70
+ typeof (data as OllamaPullResponse).status === 'string'
71
+ );
72
+ };
73
+
74
+ export default function LocalProvidersTab() {
75
+ const { providers, updateProviderSettings } = useSettings();
76
+ const [filteredProviders, setFilteredProviders] = useState<IProviderConfig[]>([]);
77
+ const [categoryEnabled, setCategoryEnabled] = useState(false);
78
+ const [ollamaModels, setOllamaModels] = useState<OllamaModel[]>([]);
79
+ const [isLoadingModels, setIsLoadingModels] = useState(false);
80
+ const [editingProvider, setEditingProvider] = useState<string | null>(null);
81
+ const { toast } = useToast();
82
+
83
+ // Effect to filter and sort providers
84
+ useEffect(() => {
85
+ const newFilteredProviders = Object.entries(providers || {})
86
+ .filter(([key]) => [...LOCAL_PROVIDERS, 'OpenAILike'].includes(key))
87
+ .map(([key, value]) => {
88
+ const provider = value as IProviderConfig;
89
+ const envKey = providerBaseUrlEnvKeys[key]?.baseUrlKey;
90
+ const envUrl = envKey ? (import.meta.env[envKey] as string | undefined) : undefined;
91
+
92
+ // Set base URL if provided by environment
93
+ if (envUrl && !provider.settings.baseUrl) {
94
+ updateProviderSettings(key, {
95
+ ...provider.settings,
96
+ baseUrl: envUrl,
97
+ });
98
+ }
99
+
100
+ return {
101
+ name: key,
102
+ settings: {
103
+ ...provider.settings,
104
+ baseUrl: provider.settings.baseUrl || envUrl,
105
+ },
106
+ staticModels: provider.staticModels || [],
107
+ getDynamicModels: provider.getDynamicModels,
108
+ getApiKeyLink: provider.getApiKeyLink,
109
+ labelForGetApiKey: provider.labelForGetApiKey,
110
+ icon: provider.icon,
111
+ } as IProviderConfig;
112
+ });
113
+
114
+ // Custom sort function to ensure LMStudio appears before OpenAILike
115
+ const sorted = newFilteredProviders.sort((a, b) => {
116
+ if (a.name === 'LMStudio') {
117
+ return -1;
118
+ }
119
+
120
+ if (b.name === 'LMStudio') {
121
+ return 1;
122
+ }
123
+
124
+ if (a.name === 'OpenAILike') {
125
+ return 1;
126
+ }
127
+
128
+ if (b.name === 'OpenAILike') {
129
+ return -1;
130
+ }
131
+
132
+ return a.name.localeCompare(b.name);
133
+ });
134
+ setFilteredProviders(sorted);
135
+ }, [providers, updateProviderSettings]);
136
+
137
+ // Add effect to update category toggle state based on provider states
138
+ useEffect(() => {
139
+ const newCategoryState = filteredProviders.every((p) => p.settings.enabled);
140
+ setCategoryEnabled(newCategoryState);
141
+ }, [filteredProviders]);
142
+
143
+ // Fetch Ollama models when enabled
144
+ useEffect(() => {
145
+ const ollamaProvider = filteredProviders.find((p) => p.name === 'Ollama');
146
+
147
+ if (ollamaProvider?.settings.enabled) {
148
+ fetchOllamaModels();
149
+ }
150
+ }, [filteredProviders]);
151
+
152
+ const fetchOllamaModels = async () => {
153
+ try {
154
+ setIsLoadingModels(true);
155
+
156
+ const response = await fetch('http://127.0.0.1:11434/api/tags');
157
+ const data = (await response.json()) as { models: OllamaModel[] };
158
+
159
+ setOllamaModels(
160
+ data.models.map((model) => ({
161
+ ...model,
162
+ status: 'idle' as const,
163
+ })),
164
+ );
165
+ } catch (error) {
166
+ console.error('Error fetching Ollama models:', error);
167
+ } finally {
168
+ setIsLoadingModels(false);
169
+ }
170
+ };
171
+
172
+ const updateOllamaModel = async (modelName: string): Promise<boolean> => {
173
+ try {
174
+ const response = await fetch(`${OLLAMA_API_URL}/api/pull`, {
175
+ method: 'POST',
176
+ headers: { 'Content-Type': 'application/json' },
177
+ body: JSON.stringify({ name: modelName }),
178
+ });
179
+
180
+ if (!response.ok) {
181
+ throw new Error(`Failed to update ${modelName}`);
182
+ }
183
+
184
+ const reader = response.body?.getReader();
185
+
186
+ if (!reader) {
187
+ throw new Error('No response reader available');
188
+ }
189
+
190
+ while (true) {
191
+ const { done, value } = await reader.read();
192
+
193
+ if (done) {
194
+ break;
195
+ }
196
+
197
+ const text = new TextDecoder().decode(value);
198
+ const lines = text.split('\n').filter(Boolean);
199
+
200
+ for (const line of lines) {
201
+ const rawData = JSON.parse(line);
202
+
203
+ if (!isOllamaPullResponse(rawData)) {
204
+ console.error('Invalid response format:', rawData);
205
+ continue;
206
+ }
207
+
208
+ setOllamaModels((current) =>
209
+ current.map((m) =>
210
+ m.name === modelName
211
+ ? {
212
+ ...m,
213
+ progress: {
214
+ current: rawData.completed || 0,
215
+ total: rawData.total || 0,
216
+ status: rawData.status,
217
+ },
218
+ newDigest: rawData.digest,
219
+ }
220
+ : m,
221
+ ),
222
+ );
223
+ }
224
+ }
225
+
226
+ const updatedResponse = await fetch('http://127.0.0.1:11434/api/tags');
227
+ const updatedData = (await updatedResponse.json()) as { models: OllamaModel[] };
228
+ const updatedModel = updatedData.models.find((m) => m.name === modelName);
229
+
230
+ return updatedModel !== undefined;
231
+ } catch (error) {
232
+ console.error(`Error updating ${modelName}:`, error);
233
+ return false;
234
+ }
235
+ };
236
+
237
+ const handleToggleCategory = useCallback(
238
+ async (enabled: boolean) => {
239
+ filteredProviders.forEach((provider) => {
240
+ updateProviderSettings(provider.name, { ...provider.settings, enabled });
241
+ });
242
+ toast(enabled ? 'All local providers enabled' : 'All local providers disabled');
243
+ },
244
+ [filteredProviders, updateProviderSettings],
245
+ );
246
+
247
+ const handleToggleProvider = (provider: IProviderConfig, enabled: boolean) => {
248
+ updateProviderSettings(provider.name, {
249
+ ...provider.settings,
250
+ enabled,
251
+ });
252
+
253
+ if (enabled) {
254
+ logStore.logProvider(`Provider ${provider.name} enabled`, { provider: provider.name });
255
+ toast(`${provider.name} enabled`);
256
+ } else {
257
+ logStore.logProvider(`Provider ${provider.name} disabled`, { provider: provider.name });
258
+ toast(`${provider.name} disabled`);
259
+ }
260
+ };
261
+
262
+ const handleUpdateBaseUrl = (provider: IProviderConfig, newBaseUrl: string) => {
263
+ updateProviderSettings(provider.name, {
264
+ ...provider.settings,
265
+ baseUrl: newBaseUrl,
266
+ });
267
+ toast(`${provider.name} base URL updated`);
268
+ setEditingProvider(null);
269
+ };
270
+
271
+ const handleUpdateOllamaModel = async (modelName: string) => {
272
+ const updateSuccess = await updateOllamaModel(modelName);
273
+
274
+ if (updateSuccess) {
275
+ toast(`Updated ${modelName}`);
276
+ } else {
277
+ toast(`Failed to update ${modelName}`);
278
+ }
279
+ };
280
+
281
+ const handleDeleteOllamaModel = async (modelName: string) => {
282
+ try {
283
+ const response = await fetch(`${OLLAMA_API_URL}/api/delete`, {
284
+ method: 'DELETE',
285
+ headers: {
286
+ 'Content-Type': 'application/json',
287
+ },
288
+ body: JSON.stringify({ name: modelName }),
289
+ });
290
+
291
+ if (!response.ok) {
292
+ throw new Error(`Failed to delete ${modelName}`);
293
+ }
294
+
295
+ setOllamaModels((current) => current.filter((m) => m.name !== modelName));
296
+ toast(`Deleted ${modelName}`);
297
+ } catch (err) {
298
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
299
+ console.error(`Error deleting ${modelName}:`, errorMessage);
300
+ toast(`Failed to delete ${modelName}`);
301
+ }
302
+ };
303
+
304
+ // Update model details display
305
+ const ModelDetails = ({ model }: { model: OllamaModel }) => (
306
+ <div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
307
+ <div className="flex items-center gap-1">
308
+ <div className="i-ph:code text-purple-500" />
309
+ <span>{model.digest.substring(0, 7)}</span>
310
+ </div>
311
+ {model.details && (
312
+ <>
313
+ <div className="flex items-center gap-1">
314
+ <div className="i-ph:database text-purple-500" />
315
+ <span>{model.details.parameter_size}</span>
316
+ </div>
317
+ <div className="flex items-center gap-1">
318
+ <div className="i-ph:cube text-purple-500" />
319
+ <span>{model.details.quantization_level}</span>
320
+ </div>
321
+ </>
322
+ )}
323
+ </div>
324
+ );
325
+
326
+ // Update model actions to not use Tooltip
327
+ const ModelActions = ({
328
+ model,
329
+ onUpdate,
330
+ onDelete,
331
+ }: {
332
+ model: OllamaModel;
333
+ onUpdate: () => void;
334
+ onDelete: () => void;
335
+ }) => (
336
+ <div className="flex items-center gap-2">
337
+ <motion.button
338
+ onClick={onUpdate}
339
+ disabled={model.status === 'updating'}
340
+ className={classNames(
341
+ 'rounded-lg p-2',
342
+ 'bg-purple-500/10 text-purple-500',
343
+ 'hover:bg-purple-500/20',
344
+ 'transition-all duration-200',
345
+ { 'opacity-50 cursor-not-allowed': model.status === 'updating' },
346
+ )}
347
+ whileHover={{ scale: 1.05 }}
348
+ whileTap={{ scale: 0.95 }}
349
+ title="Update model"
350
+ >
351
+ {model.status === 'updating' ? (
352
+ <div className="flex items-center gap-2">
353
+ <div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
354
+ <span className="text-sm">Updating...</span>
355
+ </div>
356
+ ) : (
357
+ <div className="i-ph:arrows-clockwise text-lg" />
358
+ )}
359
+ </motion.button>
360
+ <motion.button
361
+ onClick={onDelete}
362
+ disabled={model.status === 'updating'}
363
+ className={classNames(
364
+ 'rounded-lg p-2',
365
+ 'bg-red-500/10 text-red-500',
366
+ 'hover:bg-red-500/20',
367
+ 'transition-all duration-200',
368
+ { 'opacity-50 cursor-not-allowed': model.status === 'updating' },
369
+ )}
370
+ whileHover={{ scale: 1.05 }}
371
+ whileTap={{ scale: 0.95 }}
372
+ title="Delete model"
373
+ >
374
+ <div className="i-ph:trash text-lg" />
375
+ </motion.button>
376
+ </div>
377
+ );
378
+
379
+ return (
380
+ <div
381
+ className={classNames(
382
+ 'rounded-lg bg-bolt-elements-background text-bolt-elements-textPrimary shadow-sm p-4',
383
+ 'hover:bg-bolt-elements-background-depth-2',
384
+ 'transition-all duration-200',
385
+ )}
386
+ role="region"
387
+ aria-label="Local Providers Configuration"
388
+ >
389
+ <motion.div
390
+ className="space-y-6"
391
+ initial={{ opacity: 0, y: 20 }}
392
+ animate={{ opacity: 1, y: 0 }}
393
+ transition={{ duration: 0.3 }}
394
+ >
395
+ {/* Header section */}
396
+ <div className="flex items-center justify-between gap-4 border-b border-bolt-elements-borderColor pb-4">
397
+ <div className="flex items-center gap-3">
398
+ <motion.div
399
+ className={classNames(
400
+ 'w-10 h-10 flex items-center justify-center rounded-xl',
401
+ 'bg-purple-500/10 text-purple-500',
402
+ )}
403
+ whileHover={{ scale: 1.05 }}
404
+ >
405
+ <BiChip className="w-6 h-6" />
406
+ </motion.div>
407
+ <div>
408
+ <div className="flex items-center gap-2">
409
+ <h2 className="text-lg font-semibold text-bolt-elements-textPrimary">Local AI Models</h2>
410
+ </div>
411
+ <p className="text-sm text-bolt-elements-textSecondary">Configure and manage your local AI providers</p>
412
+ </div>
413
+ </div>
414
+
415
+ <div className="flex items-center gap-2">
416
+ <span className="text-sm text-bolt-elements-textSecondary">Enable All</span>
417
+ <Switch
418
+ checked={categoryEnabled}
419
+ onCheckedChange={handleToggleCategory}
420
+ aria-label="Toggle all local providers"
421
+ />
422
+ </div>
423
+ </div>
424
+
425
+ {/* Ollama Section */}
426
+ {filteredProviders
427
+ .filter((provider) => provider.name === 'Ollama')
428
+ .map((provider) => (
429
+ <motion.div
430
+ key={provider.name}
431
+ className={classNames(
432
+ 'bg-bolt-elements-background-depth-2 rounded-xl',
433
+ 'hover:bg-bolt-elements-background-depth-3',
434
+ 'transition-all duration-200 p-5',
435
+ 'relative overflow-hidden group',
436
+ )}
437
+ initial={{ opacity: 0, y: 20 }}
438
+ animate={{ opacity: 1, y: 0 }}
439
+ whileHover={{ scale: 1.01 }}
440
+ >
441
+ {/* Provider Header */}
442
+ <div className="flex items-start justify-between gap-4">
443
+ <div className="flex items-start gap-4">
444
+ <motion.div
445
+ className={classNames(
446
+ 'w-12 h-12 flex items-center justify-center rounded-xl',
447
+ 'bg-bolt-elements-background-depth-3',
448
+ provider.settings.enabled ? 'text-purple-500' : 'text-bolt-elements-textSecondary',
449
+ )}
450
+ whileHover={{ scale: 1.1, rotate: 5 }}
451
+ >
452
+ {React.createElement(PROVIDER_ICONS[provider.name as ProviderName] || BsRobot, {
453
+ className: 'w-7 h-7',
454
+ 'aria-label': `${provider.name} icon`,
455
+ })}
456
+ </motion.div>
457
+ <div>
458
+ <div className="flex items-center gap-2">
459
+ <h3 className="text-md font-semibold text-bolt-elements-textPrimary">{provider.name}</h3>
460
+ <span className="px-2 py-0.5 text-xs rounded-full bg-green-500/10 text-green-500">Local</span>
461
+ </div>
462
+ <p className="text-sm text-bolt-elements-textSecondary mt-1">
463
+ {PROVIDER_DESCRIPTIONS[provider.name as ProviderName]}
464
+ </p>
465
+ </div>
466
+ </div>
467
+ <Switch
468
+ checked={provider.settings.enabled}
469
+ onCheckedChange={(checked) => handleToggleProvider(provider, checked)}
470
+ aria-label={`Toggle ${provider.name} provider`}
471
+ />
472
+ </div>
473
+
474
+ {/* URL Configuration Section */}
475
+ <AnimatePresence>
476
+ {provider.settings.enabled && (
477
+ <motion.div
478
+ initial={{ opacity: 0, height: 0 }}
479
+ animate={{ opacity: 1, height: 'auto' }}
480
+ exit={{ opacity: 0, height: 0 }}
481
+ className="mt-4"
482
+ >
483
+ <div className="flex flex-col gap-2">
484
+ <label className="text-sm text-bolt-elements-textSecondary">API Endpoint</label>
485
+ {editingProvider === provider.name ? (
486
+ <input
487
+ type="text"
488
+ defaultValue={provider.settings.baseUrl || OLLAMA_API_URL}
489
+ placeholder="Enter Ollama base URL"
490
+ className={classNames(
491
+ 'w-full px-3 py-2 rounded-lg text-sm',
492
+ 'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
493
+ 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
494
+ 'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
495
+ 'transition-all duration-200',
496
+ )}
497
+ onKeyDown={(e) => {
498
+ if (e.key === 'Enter') {
499
+ handleUpdateBaseUrl(provider, e.currentTarget.value);
500
+ } else if (e.key === 'Escape') {
501
+ setEditingProvider(null);
502
+ }
503
+ }}
504
+ onBlur={(e) => handleUpdateBaseUrl(provider, e.target.value)}
505
+ autoFocus
506
+ />
507
+ ) : (
508
+ <div
509
+ onClick={() => setEditingProvider(provider.name)}
510
+ className={classNames(
511
+ 'w-full px-3 py-2 rounded-lg text-sm cursor-pointer',
512
+ 'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
513
+ 'hover:border-purple-500/30 hover:bg-bolt-elements-background-depth-4',
514
+ 'transition-all duration-200',
515
+ )}
516
+ >
517
+ <div className="flex items-center gap-2 text-bolt-elements-textSecondary">
518
+ <div className="i-ph:link text-sm" />
519
+ <span>{provider.settings.baseUrl || OLLAMA_API_URL}</span>
520
+ </div>
521
+ </div>
522
+ )}
523
+ </div>
524
+ </motion.div>
525
+ )}
526
+ </AnimatePresence>
527
+
528
+ {/* Ollama Models Section */}
529
+ {provider.settings.enabled && (
530
+ <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="mt-6 space-y-4">
531
+ <div className="flex items-center justify-between">
532
+ <div className="flex items-center gap-2">
533
+ <div className="i-ph:cube-duotone text-purple-500" />
534
+ <h4 className="text-sm font-medium text-bolt-elements-textPrimary">Installed Models</h4>
535
+ </div>
536
+ {isLoadingModels ? (
537
+ <div className="flex items-center gap-2">
538
+ <div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
539
+ <span className="text-sm text-bolt-elements-textSecondary">Loading models...</span>
540
+ </div>
541
+ ) : (
542
+ <span className="text-sm text-bolt-elements-textSecondary">
543
+ {ollamaModels.length} models available
544
+ </span>
545
+ )}
546
+ </div>
547
+
548
+ <div className="space-y-3">
549
+ {isLoadingModels ? (
550
+ <div className="space-y-3">
551
+ {Array.from({ length: 3 }).map((_, i) => (
552
+ <div
553
+ key={i}
554
+ className="h-20 w-full bg-bolt-elements-background-depth-3 rounded-lg animate-pulse"
555
+ />
556
+ ))}
557
+ </div>
558
+ ) : ollamaModels.length === 0 ? (
559
+ <div className="text-center py-8 text-bolt-elements-textSecondary">
560
+ <div className="i-ph:cube-transparent text-4xl mx-auto mb-2" />
561
+ <p>No models installed yet</p>
562
+ <p className="text-sm text-bolt-elements-textTertiary px-1">
563
+ Browse models at{' '}
564
+ <a
565
+ href="https://ollama.com/library"
566
+ target="_blank"
567
+ rel="noopener noreferrer"
568
+ className="text-purple-500 hover:underline inline-flex items-center gap-0.5 text-base font-medium"
569
+ >
570
+ ollama.com/library
571
+ <div className="i-ph:arrow-square-out text-xs" />
572
+ </a>{' '}
573
+ and copy model names to install
574
+ </p>
575
+ </div>
576
+ ) : (
577
+ ollamaModels.map((model) => (
578
+ <motion.div
579
+ key={model.name}
580
+ className={classNames(
581
+ 'p-4 rounded-xl',
582
+ 'bg-bolt-elements-background-depth-3',
583
+ 'hover:bg-bolt-elements-background-depth-4',
584
+ 'transition-all duration-200',
585
+ )}
586
+ whileHover={{ scale: 1.01 }}
587
+ >
588
+ <div className="flex items-center justify-between">
589
+ <div className="space-y-2">
590
+ <div className="flex items-center gap-2">
591
+ <h5 className="text-sm font-medium text-bolt-elements-textPrimary">{model.name}</h5>
592
+ <ModelStatusBadge status={model.status} />
593
+ </div>
594
+ <ModelDetails model={model} />
595
+ </div>
596
+ <ModelActions
597
+ model={model}
598
+ onUpdate={() => handleUpdateOllamaModel(model.name)}
599
+ onDelete={() => {
600
+ if (window.confirm(`Are you sure you want to delete ${model.name}?`)) {
601
+ handleDeleteOllamaModel(model.name);
602
+ }
603
+ }}
604
+ />
605
+ </div>
606
+ {model.progress && (
607
+ <div className="mt-3">
608
+ <Progress
609
+ value={Math.round((model.progress.current / model.progress.total) * 100)}
610
+ className="h-1"
611
+ />
612
+ <div className="flex justify-between mt-1 text-xs text-bolt-elements-textSecondary">
613
+ <span>{model.progress.status}</span>
614
+ <span>{Math.round((model.progress.current / model.progress.total) * 100)}%</span>
615
+ </div>
616
+ </div>
617
+ )}
618
+ </motion.div>
619
+ ))
620
+ )}
621
+ </div>
622
+
623
+ {/* Model Installation Section */}
624
+ <OllamaModelInstaller onModelInstalled={fetchOllamaModels} />
625
+ </motion.div>
626
+ )}
627
+ </motion.div>
628
+ ))}
629
+
630
+ {/* Other Providers Section */}
631
+ <div className="border-t border-bolt-elements-borderColor pt-6 mt-8">
632
+ <h3 className="text-lg font-semibold text-bolt-elements-textPrimary mb-4">Other Local Providers</h3>
633
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
634
+ {filteredProviders
635
+ .filter((provider) => provider.name !== 'Ollama')
636
+ .map((provider, index) => (
637
+ <motion.div
638
+ key={provider.name}
639
+ className={classNames(
640
+ 'bg-bolt-elements-background-depth-2 rounded-xl',
641
+ 'hover:bg-bolt-elements-background-depth-3',
642
+ 'transition-all duration-200 p-5',
643
+ 'relative overflow-hidden group',
644
+ )}
645
+ initial={{ opacity: 0, y: 20 }}
646
+ animate={{ opacity: 1, y: 0 }}
647
+ transition={{ delay: index * 0.1 }}
648
+ whileHover={{ scale: 1.01 }}
649
+ >
650
+ {/* Provider Header */}
651
+ <div className="flex items-start justify-between gap-4">
652
+ <div className="flex items-start gap-4">
653
+ <motion.div
654
+ className={classNames(
655
+ 'w-12 h-12 flex items-center justify-center rounded-xl',
656
+ 'bg-bolt-elements-background-depth-3',
657
+ provider.settings.enabled ? 'text-purple-500' : 'text-bolt-elements-textSecondary',
658
+ )}
659
+ whileHover={{ scale: 1.1, rotate: 5 }}
660
+ >
661
+ {React.createElement(PROVIDER_ICONS[provider.name as ProviderName] || BsRobot, {
662
+ className: 'w-7 h-7',
663
+ 'aria-label': `${provider.name} icon`,
664
+ })}
665
+ </motion.div>
666
+ <div>
667
+ <div className="flex items-center gap-2">
668
+ <h3 className="text-md font-semibold text-bolt-elements-textPrimary">{provider.name}</h3>
669
+ <div className="flex gap-1">
670
+ <span className="px-2 py-0.5 text-xs rounded-full bg-green-500/10 text-green-500">
671
+ Local
672
+ </span>
673
+ {URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
674
+ <span className="px-2 py-0.5 text-xs rounded-full bg-purple-500/10 text-purple-500">
675
+ Configurable
676
+ </span>
677
+ )}
678
+ </div>
679
+ </div>
680
+ <p className="text-sm text-bolt-elements-textSecondary mt-1">
681
+ {PROVIDER_DESCRIPTIONS[provider.name as ProviderName]}
682
+ </p>
683
+ </div>
684
+ </div>
685
+ <Switch
686
+ checked={provider.settings.enabled}
687
+ onCheckedChange={(checked) => handleToggleProvider(provider, checked)}
688
+ aria-label={`Toggle ${provider.name} provider`}
689
+ />
690
+ </div>
691
+
692
+ {/* URL Configuration Section */}
693
+ <AnimatePresence>
694
+ {provider.settings.enabled && URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
695
+ <motion.div
696
+ initial={{ opacity: 0, height: 0 }}
697
+ animate={{ opacity: 1, height: 'auto' }}
698
+ exit={{ opacity: 0, height: 0 }}
699
+ className="mt-4"
700
+ >
701
+ <div className="flex flex-col gap-2">
702
+ <label className="text-sm text-bolt-elements-textSecondary">API Endpoint</label>
703
+ {editingProvider === provider.name ? (
704
+ <input
705
+ type="text"
706
+ defaultValue={provider.settings.baseUrl}
707
+ placeholder={`Enter ${provider.name} base URL`}
708
+ className={classNames(
709
+ 'w-full px-3 py-2 rounded-lg text-sm',
710
+ 'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
711
+ 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
712
+ 'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
713
+ 'transition-all duration-200',
714
+ )}
715
+ onKeyDown={(e) => {
716
+ if (e.key === 'Enter') {
717
+ handleUpdateBaseUrl(provider, e.currentTarget.value);
718
+ } else if (e.key === 'Escape') {
719
+ setEditingProvider(null);
720
+ }
721
+ }}
722
+ onBlur={(e) => handleUpdateBaseUrl(provider, e.target.value)}
723
+ autoFocus
724
+ />
725
+ ) : (
726
+ <div
727
+ onClick={() => setEditingProvider(provider.name)}
728
+ className={classNames(
729
+ 'w-full px-3 py-2 rounded-lg text-sm cursor-pointer',
730
+ 'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
731
+ 'hover:border-purple-500/30 hover:bg-bolt-elements-background-depth-4',
732
+ 'transition-all duration-200',
733
+ )}
734
+ >
735
+ <div className="flex items-center gap-2 text-bolt-elements-textSecondary">
736
+ <div className="i-ph:link text-sm" />
737
+ <span>{provider.settings.baseUrl || 'Click to set base URL'}</span>
738
+ </div>
739
+ </div>
740
+ )}
741
+ </div>
742
+ </motion.div>
743
+ )}
744
+ </AnimatePresence>
745
+ </motion.div>
746
+ ))}
747
+ </div>
748
+ </div>
749
+ </motion.div>
750
+ </div>
751
+ );
752
+ }
753
+
754
+ // Helper component for model status badge
755
+ function ModelStatusBadge({ status }: { status?: string }) {
756
+ if (!status || status === 'idle') {
757
+ return null;
758
+ }
759
+
760
+ const statusConfig = {
761
+ updating: { bg: 'bg-yellow-500/10', text: 'text-yellow-500', label: 'Updating' },
762
+ updated: { bg: 'bg-green-500/10', text: 'text-green-500', label: 'Updated' },
763
+ error: { bg: 'bg-red-500/10', text: 'text-red-500', label: 'Error' },
764
+ };
765
+
766
+ const config = statusConfig[status as keyof typeof statusConfig];
767
+
768
+ if (!config) {
769
+ return null;
770
+ }
771
+
772
+ return (
773
+ <span className={classNames('px-2 py-0.5 rounded-full text-xs font-medium', config.bg, config.text)}>
774
+ {config.label}
775
+ </span>
776
+ );
777
+ }
app/components/@settings/tabs/providers/local/OllamaModelInstaller.tsx ADDED
@@ -0,0 +1,603 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react';
2
+ import { motion } from 'framer-motion';
3
+ import { classNames } from '~/utils/classNames';
4
+ import { Progress } from '~/components/ui/Progress';
5
+ import { useToast } from '~/components/ui/use-toast';
6
+ import { useSettings } from '~/lib/hooks/useSettings';
7
+
8
+ interface OllamaModelInstallerProps {
9
+ onModelInstalled: () => void;
10
+ }
11
+
12
+ interface InstallProgress {
13
+ status: string;
14
+ progress: number;
15
+ downloadedSize?: string;
16
+ totalSize?: string;
17
+ speed?: string;
18
+ }
19
+
20
+ interface ModelInfo {
21
+ name: string;
22
+ desc: string;
23
+ size: string;
24
+ tags: string[];
25
+ installedVersion?: string;
26
+ latestVersion?: string;
27
+ needsUpdate?: boolean;
28
+ status?: 'idle' | 'installing' | 'updating' | 'updated' | 'error';
29
+ details?: {
30
+ family: string;
31
+ parameter_size: string;
32
+ quantization_level: string;
33
+ };
34
+ }
35
+
36
+ const POPULAR_MODELS: ModelInfo[] = [
37
+ {
38
+ name: 'deepseek-coder:6.7b',
39
+ desc: "DeepSeek's code generation model",
40
+ size: '4.1GB',
41
+ tags: ['coding', 'popular'],
42
+ },
43
+ {
44
+ name: 'llama2:7b',
45
+ desc: "Meta's Llama 2 (7B parameters)",
46
+ size: '3.8GB',
47
+ tags: ['general', 'popular'],
48
+ },
49
+ {
50
+ name: 'mistral:7b',
51
+ desc: "Mistral's 7B model",
52
+ size: '4.1GB',
53
+ tags: ['general', 'popular'],
54
+ },
55
+ {
56
+ name: 'gemma:7b',
57
+ desc: "Google's Gemma model",
58
+ size: '4.0GB',
59
+ tags: ['general', 'new'],
60
+ },
61
+ {
62
+ name: 'codellama:7b',
63
+ desc: "Meta's Code Llama model",
64
+ size: '4.1GB',
65
+ tags: ['coding', 'popular'],
66
+ },
67
+ {
68
+ name: 'neural-chat:7b',
69
+ desc: "Intel's Neural Chat model",
70
+ size: '4.1GB',
71
+ tags: ['chat', 'popular'],
72
+ },
73
+ {
74
+ name: 'phi:latest',
75
+ desc: "Microsoft's Phi-2 model",
76
+ size: '2.7GB',
77
+ tags: ['small', 'fast'],
78
+ },
79
+ {
80
+ name: 'qwen:7b',
81
+ desc: "Alibaba's Qwen model",
82
+ size: '4.1GB',
83
+ tags: ['general'],
84
+ },
85
+ {
86
+ name: 'solar:10.7b',
87
+ desc: "Upstage's Solar model",
88
+ size: '6.1GB',
89
+ tags: ['large', 'powerful'],
90
+ },
91
+ {
92
+ name: 'openchat:7b',
93
+ desc: 'Open-source chat model',
94
+ size: '4.1GB',
95
+ tags: ['chat', 'popular'],
96
+ },
97
+ {
98
+ name: 'dolphin-phi:2.7b',
99
+ desc: 'Lightweight chat model',
100
+ size: '1.6GB',
101
+ tags: ['small', 'fast'],
102
+ },
103
+ {
104
+ name: 'stable-code:3b',
105
+ desc: 'Lightweight coding model',
106
+ size: '1.8GB',
107
+ tags: ['coding', 'small'],
108
+ },
109
+ ];
110
+
111
+ function formatBytes(bytes: number): string {
112
+ if (bytes === 0) {
113
+ return '0 B';
114
+ }
115
+
116
+ const k = 1024;
117
+ const sizes = ['B', 'KB', 'MB', 'GB'];
118
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
119
+
120
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
121
+ }
122
+
123
+ function formatSpeed(bytesPerSecond: number): string {
124
+ return `${formatBytes(bytesPerSecond)}/s`;
125
+ }
126
+
127
+ // Add Ollama Icon SVG component
128
+ function OllamaIcon({ className }: { className?: string }) {
129
+ return (
130
+ <svg viewBox="0 0 1024 1024" className={className} fill="currentColor">
131
+ <path d="M684.3 322.2H339.8c-9.5.1-17.7 6.8-19.6 16.1-8.2 41.4-12.4 83.5-12.4 125.7 0 42.2 4.2 84.3 12.4 125.7 1.9 9.3 10.1 16 19.6 16.1h344.5c9.5-.1 17.7-6.8 19.6-16.1 8.2-41.4 12.4-83.5 12.4-125.7 0-42.2-4.2-84.3-12.4-125.7-1.9-9.3-10.1-16-19.6-16.1zM512 640c-176.7 0-320-143.3-320-320S335.3 0 512 0s320 143.3 320 320-143.3 320-320 320z" />
132
+ </svg>
133
+ );
134
+ }
135
+
136
+ export default function OllamaModelInstaller({ onModelInstalled }: OllamaModelInstallerProps) {
137
+ const [modelString, setModelString] = useState('');
138
+ const [searchQuery, setSearchQuery] = useState('');
139
+ const [isInstalling, setIsInstalling] = useState(false);
140
+ const [isChecking, setIsChecking] = useState(false);
141
+ const [installProgress, setInstallProgress] = useState<InstallProgress | null>(null);
142
+ const [selectedTags, setSelectedTags] = useState<string[]>([]);
143
+ const [models, setModels] = useState<ModelInfo[]>(POPULAR_MODELS);
144
+ const { toast } = useToast();
145
+ const { providers } = useSettings();
146
+
147
+ // Get base URL from provider settings
148
+ const baseUrl = providers?.Ollama?.settings?.baseUrl || 'http://127.0.0.1:11434';
149
+
150
+ // Function to check installed models and their versions
151
+ const checkInstalledModels = async () => {
152
+ try {
153
+ const response = await fetch(`${baseUrl}/api/tags`, {
154
+ method: 'GET',
155
+ });
156
+
157
+ if (!response.ok) {
158
+ throw new Error('Failed to fetch installed models');
159
+ }
160
+
161
+ const data = (await response.json()) as { models: Array<{ name: string; digest: string; latest: string }> };
162
+ const installedModels = data.models || [];
163
+
164
+ // Update models with installed versions
165
+ setModels((prevModels) =>
166
+ prevModels.map((model) => {
167
+ const installed = installedModels.find((m) => m.name.toLowerCase() === model.name.toLowerCase());
168
+
169
+ if (installed) {
170
+ return {
171
+ ...model,
172
+ installedVersion: installed.digest.substring(0, 8),
173
+ needsUpdate: installed.digest !== installed.latest,
174
+ latestVersion: installed.latest?.substring(0, 8),
175
+ };
176
+ }
177
+
178
+ return model;
179
+ }),
180
+ );
181
+ } catch (error) {
182
+ console.error('Error checking installed models:', error);
183
+ }
184
+ };
185
+
186
+ // Check installed models on mount and after installation
187
+ useEffect(() => {
188
+ checkInstalledModels();
189
+ }, [baseUrl]);
190
+
191
+ const handleCheckUpdates = async () => {
192
+ setIsChecking(true);
193
+
194
+ try {
195
+ await checkInstalledModels();
196
+ toast('Model versions checked');
197
+ } catch (err) {
198
+ console.error('Failed to check model versions:', err);
199
+ toast('Failed to check model versions');
200
+ } finally {
201
+ setIsChecking(false);
202
+ }
203
+ };
204
+
205
+ const filteredModels = models.filter((model) => {
206
+ const matchesSearch =
207
+ searchQuery === '' ||
208
+ model.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
209
+ model.desc.toLowerCase().includes(searchQuery.toLowerCase());
210
+ const matchesTags = selectedTags.length === 0 || selectedTags.some((tag) => model.tags.includes(tag));
211
+
212
+ return matchesSearch && matchesTags;
213
+ });
214
+
215
+ const handleInstallModel = async (modelToInstall: string) => {
216
+ if (!modelToInstall) {
217
+ return;
218
+ }
219
+
220
+ try {
221
+ setIsInstalling(true);
222
+ setInstallProgress({
223
+ status: 'Starting download...',
224
+ progress: 0,
225
+ downloadedSize: '0 B',
226
+ totalSize: 'Calculating...',
227
+ speed: '0 B/s',
228
+ });
229
+ setModelString('');
230
+ setSearchQuery('');
231
+
232
+ const response = await fetch(`${baseUrl}/api/pull`, {
233
+ method: 'POST',
234
+ headers: {
235
+ 'Content-Type': 'application/json',
236
+ },
237
+ body: JSON.stringify({ name: modelToInstall }),
238
+ });
239
+
240
+ if (!response.ok) {
241
+ throw new Error(`HTTP error! status: ${response.status}`);
242
+ }
243
+
244
+ const reader = response.body?.getReader();
245
+
246
+ if (!reader) {
247
+ throw new Error('Failed to get response reader');
248
+ }
249
+
250
+ let lastTime = Date.now();
251
+ let lastBytes = 0;
252
+
253
+ while (true) {
254
+ const { done, value } = await reader.read();
255
+
256
+ if (done) {
257
+ break;
258
+ }
259
+
260
+ const text = new TextDecoder().decode(value);
261
+ const lines = text.split('\n').filter(Boolean);
262
+
263
+ for (const line of lines) {
264
+ try {
265
+ const data = JSON.parse(line);
266
+
267
+ if ('status' in data) {
268
+ const currentTime = Date.now();
269
+ const timeDiff = (currentTime - lastTime) / 1000; // Convert to seconds
270
+ const bytesDiff = (data.completed || 0) - lastBytes;
271
+ const speed = bytesDiff / timeDiff;
272
+
273
+ setInstallProgress({
274
+ status: data.status,
275
+ progress: data.completed && data.total ? (data.completed / data.total) * 100 : 0,
276
+ downloadedSize: formatBytes(data.completed || 0),
277
+ totalSize: data.total ? formatBytes(data.total) : 'Calculating...',
278
+ speed: formatSpeed(speed),
279
+ });
280
+
281
+ lastTime = currentTime;
282
+ lastBytes = data.completed || 0;
283
+ }
284
+ } catch (err) {
285
+ console.error('Error parsing progress:', err);
286
+ }
287
+ }
288
+ }
289
+
290
+ toast('Successfully installed ' + modelToInstall + '. The model list will refresh automatically.');
291
+
292
+ // Ensure we call onModelInstalled after successful installation
293
+ setTimeout(() => {
294
+ onModelInstalled();
295
+ }, 1000);
296
+ } catch (err) {
297
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
298
+ console.error(`Error installing ${modelToInstall}:`, errorMessage);
299
+ toast(`Failed to install ${modelToInstall}. ${errorMessage}`);
300
+ } finally {
301
+ setIsInstalling(false);
302
+ setInstallProgress(null);
303
+ }
304
+ };
305
+
306
+ const handleUpdateModel = async (modelToUpdate: string) => {
307
+ try {
308
+ setModels((prev) => prev.map((m) => (m.name === modelToUpdate ? { ...m, status: 'updating' } : m)));
309
+
310
+ const response = await fetch(`${baseUrl}/api/pull`, {
311
+ method: 'POST',
312
+ headers: {
313
+ 'Content-Type': 'application/json',
314
+ },
315
+ body: JSON.stringify({ name: modelToUpdate }),
316
+ });
317
+
318
+ if (!response.ok) {
319
+ throw new Error(`HTTP error! status: ${response.status}`);
320
+ }
321
+
322
+ const reader = response.body?.getReader();
323
+
324
+ if (!reader) {
325
+ throw new Error('Failed to get response reader');
326
+ }
327
+
328
+ let lastTime = Date.now();
329
+ let lastBytes = 0;
330
+
331
+ while (true) {
332
+ const { done, value } = await reader.read();
333
+
334
+ if (done) {
335
+ break;
336
+ }
337
+
338
+ const text = new TextDecoder().decode(value);
339
+ const lines = text.split('\n').filter(Boolean);
340
+
341
+ for (const line of lines) {
342
+ try {
343
+ const data = JSON.parse(line);
344
+
345
+ if ('status' in data) {
346
+ const currentTime = Date.now();
347
+ const timeDiff = (currentTime - lastTime) / 1000;
348
+ const bytesDiff = (data.completed || 0) - lastBytes;
349
+ const speed = bytesDiff / timeDiff;
350
+
351
+ setInstallProgress({
352
+ status: data.status,
353
+ progress: data.completed && data.total ? (data.completed / data.total) * 100 : 0,
354
+ downloadedSize: formatBytes(data.completed || 0),
355
+ totalSize: data.total ? formatBytes(data.total) : 'Calculating...',
356
+ speed: formatSpeed(speed),
357
+ });
358
+
359
+ lastTime = currentTime;
360
+ lastBytes = data.completed || 0;
361
+ }
362
+ } catch (err) {
363
+ console.error('Error parsing progress:', err);
364
+ }
365
+ }
366
+ }
367
+
368
+ toast('Successfully updated ' + modelToUpdate);
369
+
370
+ // Refresh model list after update
371
+ await checkInstalledModels();
372
+ } catch (err) {
373
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
374
+ console.error(`Error updating ${modelToUpdate}:`, errorMessage);
375
+ toast(`Failed to update ${modelToUpdate}. ${errorMessage}`);
376
+ setModels((prev) => prev.map((m) => (m.name === modelToUpdate ? { ...m, status: 'error' } : m)));
377
+ } finally {
378
+ setInstallProgress(null);
379
+ }
380
+ };
381
+
382
+ const allTags = Array.from(new Set(POPULAR_MODELS.flatMap((model) => model.tags)));
383
+
384
+ return (
385
+ <div className="space-y-6">
386
+ <div className="flex items-center justify-between pt-6">
387
+ <div className="flex items-center gap-3">
388
+ <OllamaIcon className="w-8 h-8 text-purple-500" />
389
+ <div>
390
+ <h3 className="text-lg font-semibold text-bolt-elements-textPrimary">Ollama Models</h3>
391
+ <p className="text-sm text-bolt-elements-textSecondary mt-1">Install and manage your Ollama models</p>
392
+ </div>
393
+ </div>
394
+ <motion.button
395
+ onClick={handleCheckUpdates}
396
+ disabled={isChecking}
397
+ className={classNames(
398
+ 'px-4 py-2 rounded-lg',
399
+ 'bg-purple-500/10 text-purple-500',
400
+ 'hover:bg-purple-500/20',
401
+ 'transition-all duration-200',
402
+ 'flex items-center gap-2',
403
+ )}
404
+ whileHover={{ scale: 1.02 }}
405
+ whileTap={{ scale: 0.98 }}
406
+ >
407
+ {isChecking ? (
408
+ <div className="i-ph:spinner-gap-bold animate-spin" />
409
+ ) : (
410
+ <div className="i-ph:arrows-clockwise" />
411
+ )}
412
+ Check Updates
413
+ </motion.button>
414
+ </div>
415
+
416
+ <div className="flex gap-4">
417
+ <div className="flex-1">
418
+ <div className="space-y-1">
419
+ <input
420
+ type="text"
421
+ className={classNames(
422
+ 'w-full px-4 py-3 rounded-xl',
423
+ 'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor',
424
+ 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
425
+ 'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
426
+ 'transition-all duration-200',
427
+ )}
428
+ placeholder="Search models or enter custom model name..."
429
+ value={searchQuery || modelString}
430
+ onChange={(e) => {
431
+ const value = e.target.value;
432
+ setSearchQuery(value);
433
+ setModelString(value);
434
+ }}
435
+ disabled={isInstalling}
436
+ />
437
+ <p className="text-sm text-bolt-elements-textSecondary px-1">
438
+ Browse models at{' '}
439
+ <a
440
+ href="https://ollama.com/library"
441
+ target="_blank"
442
+ rel="noopener noreferrer"
443
+ className="text-purple-500 hover:underline inline-flex items-center gap-1 text-base font-medium"
444
+ >
445
+ ollama.com/library
446
+ <div className="i-ph:arrow-square-out text-sm" />
447
+ </a>{' '}
448
+ and copy model names to install
449
+ </p>
450
+ </div>
451
+ </div>
452
+ <motion.button
453
+ onClick={() => handleInstallModel(modelString)}
454
+ disabled={!modelString || isInstalling}
455
+ className={classNames(
456
+ 'rounded-lg px-4 py-2',
457
+ 'bg-purple-500 text-white text-sm',
458
+ 'hover:bg-purple-600',
459
+ 'transition-all duration-200',
460
+ 'flex items-center gap-2',
461
+ { 'opacity-50 cursor-not-allowed': !modelString || isInstalling },
462
+ )}
463
+ whileHover={{ scale: 1.02 }}
464
+ whileTap={{ scale: 0.98 }}
465
+ >
466
+ {isInstalling ? (
467
+ <div className="flex items-center gap-2">
468
+ <div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
469
+ <span>Installing...</span>
470
+ </div>
471
+ ) : (
472
+ <div className="flex items-center gap-2">
473
+ <OllamaIcon className="w-4 h-4" />
474
+ <span>Install Model</span>
475
+ </div>
476
+ )}
477
+ </motion.button>
478
+ </div>
479
+
480
+ <div className="flex flex-wrap gap-2">
481
+ {allTags.map((tag) => (
482
+ <button
483
+ key={tag}
484
+ onClick={() => {
485
+ setSelectedTags((prev) => (prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]));
486
+ }}
487
+ className={classNames(
488
+ 'px-3 py-1 rounded-full text-xs font-medium transition-all duration-200',
489
+ selectedTags.includes(tag)
490
+ ? 'bg-purple-500 text-white'
491
+ : 'bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary hover:bg-bolt-elements-background-depth-4',
492
+ )}
493
+ >
494
+ {tag}
495
+ </button>
496
+ ))}
497
+ </div>
498
+
499
+ <div className="grid grid-cols-1 gap-2">
500
+ {filteredModels.map((model) => (
501
+ <motion.div
502
+ key={model.name}
503
+ className={classNames(
504
+ 'flex items-start gap-2 p-3 rounded-lg',
505
+ 'bg-bolt-elements-background-depth-3',
506
+ 'hover:bg-bolt-elements-background-depth-4',
507
+ 'transition-all duration-200',
508
+ 'relative group',
509
+ )}
510
+ >
511
+ <OllamaIcon className="w-5 h-5 text-purple-500 mt-0.5 flex-shrink-0" />
512
+ <div className="flex-1 space-y-1.5">
513
+ <div className="flex items-start justify-between">
514
+ <div>
515
+ <p className="text-bolt-elements-textPrimary font-mono text-sm">{model.name}</p>
516
+ <p className="text-xs text-bolt-elements-textSecondary mt-0.5">{model.desc}</p>
517
+ </div>
518
+ <div className="text-right">
519
+ <span className="text-xs text-bolt-elements-textTertiary">{model.size}</span>
520
+ {model.installedVersion && (
521
+ <div className="mt-0.5 flex flex-col items-end gap-0.5">
522
+ <span className="text-xs text-bolt-elements-textTertiary">v{model.installedVersion}</span>
523
+ {model.needsUpdate && model.latestVersion && (
524
+ <span className="text-xs text-purple-500">v{model.latestVersion} available</span>
525
+ )}
526
+ </div>
527
+ )}
528
+ </div>
529
+ </div>
530
+ <div className="flex items-center justify-between">
531
+ <div className="flex flex-wrap gap-1">
532
+ {model.tags.map((tag) => (
533
+ <span
534
+ key={tag}
535
+ className="px-1.5 py-0.5 rounded-full text-[10px] bg-bolt-elements-background-depth-4 text-bolt-elements-textTertiary"
536
+ >
537
+ {tag}
538
+ </span>
539
+ ))}
540
+ </div>
541
+ <div className="flex gap-2">
542
+ {model.installedVersion ? (
543
+ model.needsUpdate ? (
544
+ <motion.button
545
+ onClick={() => handleUpdateModel(model.name)}
546
+ className={classNames(
547
+ 'px-2 py-0.5 rounded-lg text-xs',
548
+ 'bg-purple-500 text-white',
549
+ 'hover:bg-purple-600',
550
+ 'transition-all duration-200',
551
+ 'flex items-center gap-1',
552
+ )}
553
+ whileHover={{ scale: 1.02 }}
554
+ whileTap={{ scale: 0.98 }}
555
+ >
556
+ <div className="i-ph:arrows-clockwise text-xs" />
557
+ Update
558
+ </motion.button>
559
+ ) : (
560
+ <span className="px-2 py-0.5 rounded-lg text-xs text-green-500 bg-green-500/10">Up to date</span>
561
+ )
562
+ ) : (
563
+ <motion.button
564
+ onClick={() => handleInstallModel(model.name)}
565
+ className={classNames(
566
+ 'px-2 py-0.5 rounded-lg text-xs',
567
+ 'bg-purple-500 text-white',
568
+ 'hover:bg-purple-600',
569
+ 'transition-all duration-200',
570
+ 'flex items-center gap-1',
571
+ )}
572
+ whileHover={{ scale: 1.02 }}
573
+ whileTap={{ scale: 0.98 }}
574
+ >
575
+ <div className="i-ph:download text-xs" />
576
+ Install
577
+ </motion.button>
578
+ )}
579
+ </div>
580
+ </div>
581
+ </div>
582
+ </motion.div>
583
+ ))}
584
+ </div>
585
+
586
+ {installProgress && (
587
+ <motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="space-y-2">
588
+ <div className="flex justify-between text-sm">
589
+ <span className="text-bolt-elements-textSecondary">{installProgress.status}</span>
590
+ <div className="flex items-center gap-4">
591
+ <span className="text-bolt-elements-textTertiary">
592
+ {installProgress.downloadedSize} / {installProgress.totalSize}
593
+ </span>
594
+ <span className="text-bolt-elements-textTertiary">{installProgress.speed}</span>
595
+ <span className="text-bolt-elements-textSecondary">{Math.round(installProgress.progress)}%</span>
596
+ </div>
597
+ </div>
598
+ <Progress value={installProgress.progress} className="h-1" />
599
+ </motion.div>
600
+ )}
601
+ </div>
602
+ );
603
+ }
app/components/@settings/tabs/providers/service-status/ServiceStatusTab.tsx ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react';
2
+ import type { ServiceStatus } from './types';
3
+ import { ProviderStatusCheckerFactory } from './provider-factory';
4
+
5
+ export default function ServiceStatusTab() {
6
+ const [serviceStatuses, setServiceStatuses] = useState<ServiceStatus[]>([]);
7
+ const [loading, setLoading] = useState(true);
8
+ const [error, setError] = useState<string | null>(null);
9
+
10
+ useEffect(() => {
11
+ const checkAllProviders = async () => {
12
+ try {
13
+ setLoading(true);
14
+ setError(null);
15
+
16
+ const providers = ProviderStatusCheckerFactory.getProviderNames();
17
+ const statuses: ServiceStatus[] = [];
18
+
19
+ for (const provider of providers) {
20
+ try {
21
+ const checker = ProviderStatusCheckerFactory.getChecker(provider);
22
+ const result = await checker.checkStatus();
23
+
24
+ statuses.push({
25
+ provider,
26
+ ...result,
27
+ lastChecked: new Date().toISOString(),
28
+ });
29
+ } catch (err) {
30
+ console.error(`Error checking ${provider} status:`, err);
31
+ statuses.push({
32
+ provider,
33
+ status: 'degraded',
34
+ message: 'Unable to check service status',
35
+ incidents: ['Error checking service status'],
36
+ lastChecked: new Date().toISOString(),
37
+ });
38
+ }
39
+ }
40
+
41
+ setServiceStatuses(statuses);
42
+ } catch (err) {
43
+ console.error('Error checking provider statuses:', err);
44
+ setError('Failed to check service statuses');
45
+ } finally {
46
+ setLoading(false);
47
+ }
48
+ };
49
+
50
+ checkAllProviders();
51
+
52
+ // Set up periodic checks every 5 minutes
53
+ const interval = setInterval(checkAllProviders, 5 * 60 * 1000);
54
+
55
+ return () => clearInterval(interval);
56
+ }, []);
57
+
58
+ const getStatusColor = (status: ServiceStatus['status']) => {
59
+ switch (status) {
60
+ case 'operational':
61
+ return 'text-green-500 dark:text-green-400';
62
+ case 'degraded':
63
+ return 'text-yellow-500 dark:text-yellow-400';
64
+ case 'down':
65
+ return 'text-red-500 dark:text-red-400';
66
+ default:
67
+ return 'text-gray-500 dark:text-gray-400';
68
+ }
69
+ };
70
+
71
+ const getStatusIcon = (status: ServiceStatus['status']) => {
72
+ switch (status) {
73
+ case 'operational':
74
+ return 'i-ph:check-circle';
75
+ case 'degraded':
76
+ return 'i-ph:warning';
77
+ case 'down':
78
+ return 'i-ph:x-circle';
79
+ default:
80
+ return 'i-ph:question';
81
+ }
82
+ };
83
+
84
+ if (loading) {
85
+ return (
86
+ <div className="flex items-center justify-center h-full">
87
+ <div className="animate-spin i-ph:circle-notch w-8 h-8 text-purple-500" />
88
+ </div>
89
+ );
90
+ }
91
+
92
+ if (error) {
93
+ return (
94
+ <div className="flex flex-col items-center justify-center h-full text-red-500 dark:text-red-400">
95
+ <div className="i-ph:warning w-8 h-8 mb-2" />
96
+ <p>{error}</p>
97
+ </div>
98
+ );
99
+ }
100
+
101
+ return (
102
+ <div className="space-y-6">
103
+ <div className="grid grid-cols-1 gap-4">
104
+ {serviceStatuses.map((service) => (
105
+ <div
106
+ key={service.provider}
107
+ className="p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
108
+ >
109
+ <div className="flex items-center justify-between mb-2">
110
+ <h3 className="text-lg font-semibold text-gray-900 dark:text-white">{service.provider}</h3>
111
+ <div className={`flex items-center ${getStatusColor(service.status)}`}>
112
+ <div className={`${getStatusIcon(service.status)} w-5 h-5 mr-2`} />
113
+ <span className="capitalize">{service.status}</span>
114
+ </div>
115
+ </div>
116
+ <p className="text-gray-600 dark:text-gray-300 mb-2">{service.message}</p>
117
+ {service.incidents && service.incidents.length > 0 && (
118
+ <div className="mt-2">
119
+ <h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1">Recent Incidents:</h4>
120
+ <ul className="text-sm text-gray-600 dark:text-gray-400 space-y-1">
121
+ {service.incidents.map((incident, index) => (
122
+ <li key={index}>{incident}</li>
123
+ ))}
124
+ </ul>
125
+ </div>
126
+ )}
127
+ <div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
128
+ Last checked: {new Date(service.lastChecked).toLocaleString()}
129
+ </div>
130
+ </div>
131
+ ))}
132
+ </div>
133
+ </div>
134
+ );
135
+ }
app/components/@settings/tabs/providers/service-status/base-provider.ts ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ProviderConfig, StatusCheckResult, ApiResponse } from './types';
2
+
3
+ export abstract class BaseProviderChecker {
4
+ protected config: ProviderConfig;
5
+
6
+ constructor(config: ProviderConfig) {
7
+ this.config = config;
8
+ }
9
+
10
+ protected async checkApiEndpoint(
11
+ url: string,
12
+ headers?: Record<string, string>,
13
+ testModel?: string,
14
+ ): Promise<{ ok: boolean; status: number | string; message?: string; responseTime: number }> {
15
+ try {
16
+ const controller = new AbortController();
17
+ const timeoutId = setTimeout(() => controller.abort(), 10000);
18
+
19
+ const startTime = performance.now();
20
+
21
+ // Add common headers
22
+ const processedHeaders = {
23
+ 'Content-Type': 'application/json',
24
+ ...headers,
25
+ };
26
+
27
+ const response = await fetch(url, {
28
+ method: 'GET',
29
+ headers: processedHeaders,
30
+ signal: controller.signal,
31
+ });
32
+
33
+ const endTime = performance.now();
34
+ const responseTime = endTime - startTime;
35
+
36
+ clearTimeout(timeoutId);
37
+
38
+ const data = (await response.json()) as ApiResponse;
39
+
40
+ if (!response.ok) {
41
+ let errorMessage = `API returned status: ${response.status}`;
42
+
43
+ if (data.error?.message) {
44
+ errorMessage = data.error.message;
45
+ } else if (data.message) {
46
+ errorMessage = data.message;
47
+ }
48
+
49
+ return {
50
+ ok: false,
51
+ status: response.status,
52
+ message: errorMessage,
53
+ responseTime,
54
+ };
55
+ }
56
+
57
+ // Different providers have different model list formats
58
+ let models: string[] = [];
59
+
60
+ if (Array.isArray(data)) {
61
+ models = data.map((model: { id?: string; name?: string }) => model.id || model.name || '');
62
+ } else if (data.data && Array.isArray(data.data)) {
63
+ models = data.data.map((model) => model.id || model.name || '');
64
+ } else if (data.models && Array.isArray(data.models)) {
65
+ models = data.models.map((model) => model.id || model.name || '');
66
+ } else if (data.model) {
67
+ models = [data.model];
68
+ }
69
+
70
+ if (!testModel || models.length > 0) {
71
+ return {
72
+ ok: true,
73
+ status: response.status,
74
+ responseTime,
75
+ message: 'API key is valid',
76
+ };
77
+ }
78
+
79
+ if (testModel && !models.includes(testModel)) {
80
+ return {
81
+ ok: true,
82
+ status: 'model_not_found',
83
+ message: `API key is valid (test model ${testModel} not found in ${models.length} available models)`,
84
+ responseTime,
85
+ };
86
+ }
87
+
88
+ return {
89
+ ok: true,
90
+ status: response.status,
91
+ message: 'API key is valid',
92
+ responseTime,
93
+ };
94
+ } catch (error) {
95
+ console.error(`Error checking API endpoint ${url}:`, error);
96
+ return {
97
+ ok: false,
98
+ status: error instanceof Error ? error.message : 'Unknown error',
99
+ message: error instanceof Error ? `Connection failed: ${error.message}` : 'Connection failed',
100
+ responseTime: 0,
101
+ };
102
+ }
103
+ }
104
+
105
+ protected async checkEndpoint(url: string): Promise<'reachable' | 'unreachable'> {
106
+ try {
107
+ const response = await fetch(url, {
108
+ mode: 'no-cors',
109
+ headers: {
110
+ Accept: 'text/html',
111
+ },
112
+ });
113
+ return response.type === 'opaque' ? 'reachable' : 'unreachable';
114
+ } catch (error) {
115
+ console.error(`Error checking ${url}:`, error);
116
+ return 'unreachable';
117
+ }
118
+ }
119
+
120
+ abstract checkStatus(): Promise<StatusCheckResult>;
121
+ }
app/components/@settings/tabs/providers/service-status/provider-factory.ts ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ProviderName, ProviderConfig, StatusCheckResult } from './types';
2
+ import { BaseProviderChecker } from './base-provider';
3
+
4
+ import { AmazonBedrockStatusChecker } from './providers/amazon-bedrock';
5
+ import { CohereStatusChecker } from './providers/cohere';
6
+ import { DeepseekStatusChecker } from './providers/deepseek';
7
+ import { GoogleStatusChecker } from './providers/google';
8
+ import { GroqStatusChecker } from './providers/groq';
9
+ import { HuggingFaceStatusChecker } from './providers/huggingface';
10
+ import { HyperbolicStatusChecker } from './providers/hyperbolic';
11
+ import { MistralStatusChecker } from './providers/mistral';
12
+ import { OpenRouterStatusChecker } from './providers/openrouter';
13
+ import { PerplexityStatusChecker } from './providers/perplexity';
14
+ import { TogetherStatusChecker } from './providers/together';
15
+ import { XAIStatusChecker } from './providers/xai';
16
+
17
+ export class ProviderStatusCheckerFactory {
18
+ private static _providerConfigs: Record<ProviderName, ProviderConfig> = {
19
+ AmazonBedrock: {
20
+ statusUrl: 'https://health.aws.amazon.com/health/status',
21
+ apiUrl: 'https://bedrock.us-east-1.amazonaws.com/models',
22
+ headers: {},
23
+ testModel: 'anthropic.claude-3-sonnet-20240229-v1:0',
24
+ },
25
+ Cohere: {
26
+ statusUrl: 'https://status.cohere.com/',
27
+ apiUrl: 'https://api.cohere.ai/v1/models',
28
+ headers: {},
29
+ testModel: 'command',
30
+ },
31
+ Deepseek: {
32
+ statusUrl: 'https://status.deepseek.com/',
33
+ apiUrl: 'https://api.deepseek.com/v1/models',
34
+ headers: {},
35
+ testModel: 'deepseek-chat',
36
+ },
37
+ Google: {
38
+ statusUrl: 'https://status.cloud.google.com/',
39
+ apiUrl: 'https://generativelanguage.googleapis.com/v1/models',
40
+ headers: {},
41
+ testModel: 'gemini-pro',
42
+ },
43
+ Groq: {
44
+ statusUrl: 'https://groqstatus.com/',
45
+ apiUrl: 'https://api.groq.com/v1/models',
46
+ headers: {},
47
+ testModel: 'mixtral-8x7b-32768',
48
+ },
49
+ HuggingFace: {
50
+ statusUrl: 'https://status.huggingface.co/',
51
+ apiUrl: 'https://api-inference.huggingface.co/models',
52
+ headers: {},
53
+ testModel: 'mistralai/Mixtral-8x7B-Instruct-v0.1',
54
+ },
55
+ Hyperbolic: {
56
+ statusUrl: 'https://status.hyperbolic.ai/',
57
+ apiUrl: 'https://api.hyperbolic.ai/v1/models',
58
+ headers: {},
59
+ testModel: 'hyperbolic-1',
60
+ },
61
+ Mistral: {
62
+ statusUrl: 'https://status.mistral.ai/',
63
+ apiUrl: 'https://api.mistral.ai/v1/models',
64
+ headers: {},
65
+ testModel: 'mistral-tiny',
66
+ },
67
+ OpenRouter: {
68
+ statusUrl: 'https://status.openrouter.ai/',
69
+ apiUrl: 'https://openrouter.ai/api/v1/models',
70
+ headers: {},
71
+ testModel: 'anthropic/claude-3-sonnet',
72
+ },
73
+ Perplexity: {
74
+ statusUrl: 'https://status.perplexity.com/',
75
+ apiUrl: 'https://api.perplexity.ai/v1/models',
76
+ headers: {},
77
+ testModel: 'pplx-7b-chat',
78
+ },
79
+ Together: {
80
+ statusUrl: 'https://status.together.ai/',
81
+ apiUrl: 'https://api.together.xyz/v1/models',
82
+ headers: {},
83
+ testModel: 'mistralai/Mixtral-8x7B-Instruct-v0.1',
84
+ },
85
+ XAI: {
86
+ statusUrl: 'https://status.x.ai/',
87
+ apiUrl: 'https://api.x.ai/v1/models',
88
+ headers: {},
89
+ testModel: 'grok-1',
90
+ },
91
+ };
92
+
93
+ static getChecker(provider: ProviderName): BaseProviderChecker {
94
+ const config = this._providerConfigs[provider];
95
+
96
+ if (!config) {
97
+ throw new Error(`No configuration found for provider: ${provider}`);
98
+ }
99
+
100
+ switch (provider) {
101
+ case 'AmazonBedrock':
102
+ return new AmazonBedrockStatusChecker(config);
103
+ case 'Cohere':
104
+ return new CohereStatusChecker(config);
105
+ case 'Deepseek':
106
+ return new DeepseekStatusChecker(config);
107
+ case 'Google':
108
+ return new GoogleStatusChecker(config);
109
+ case 'Groq':
110
+ return new GroqStatusChecker(config);
111
+ case 'HuggingFace':
112
+ return new HuggingFaceStatusChecker(config);
113
+ case 'Hyperbolic':
114
+ return new HyperbolicStatusChecker(config);
115
+ case 'Mistral':
116
+ return new MistralStatusChecker(config);
117
+ case 'OpenRouter':
118
+ return new OpenRouterStatusChecker(config);
119
+ case 'Perplexity':
120
+ return new PerplexityStatusChecker(config);
121
+ case 'Together':
122
+ return new TogetherStatusChecker(config);
123
+ case 'XAI':
124
+ return new XAIStatusChecker(config);
125
+ default:
126
+ return new (class extends BaseProviderChecker {
127
+ async checkStatus(): Promise<StatusCheckResult> {
128
+ const endpointStatus = await this.checkEndpoint(this.config.statusUrl);
129
+ const apiStatus = await this.checkEndpoint(this.config.apiUrl);
130
+
131
+ return {
132
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
133
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
134
+ incidents: ['Note: Limited status information due to CORS restrictions'],
135
+ };
136
+ }
137
+ })(config);
138
+ }
139
+ }
140
+
141
+ static getProviderNames(): ProviderName[] {
142
+ return Object.keys(this._providerConfigs) as ProviderName[];
143
+ }
144
+
145
+ static getProviderConfig(provider: ProviderName): ProviderConfig {
146
+ const config = this._providerConfigs[provider];
147
+
148
+ if (!config) {
149
+ throw new Error(`Unknown provider: ${provider}`);
150
+ }
151
+
152
+ return config;
153
+ }
154
+ }
app/components/@settings/tabs/providers/service-status/providers/amazon-bedrock.ts ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
2
+ import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
3
+
4
+ export class AmazonBedrockStatusChecker extends BaseProviderChecker {
5
+ async checkStatus(): Promise<StatusCheckResult> {
6
+ try {
7
+ // Check AWS health status page
8
+ const statusPageResponse = await fetch('https://health.aws.amazon.com/health/status');
9
+ const text = await statusPageResponse.text();
10
+
11
+ // Check for Bedrock and general AWS status
12
+ const hasBedrockIssues =
13
+ text.includes('Amazon Bedrock') &&
14
+ (text.includes('Service is experiencing elevated error rates') ||
15
+ text.includes('Service disruption') ||
16
+ text.includes('Degraded Service'));
17
+
18
+ const hasGeneralIssues = text.includes('Service disruption') || text.includes('Multiple services affected');
19
+
20
+ // Extract incidents
21
+ const incidents: string[] = [];
22
+ const incidentMatches = text.matchAll(/(\d{4}-\d{2}-\d{2})\s+(.*?)\s+Impact:(.*?)(?=\n|$)/g);
23
+
24
+ for (const match of incidentMatches) {
25
+ const [, date, title, impact] = match;
26
+
27
+ if (title.includes('Bedrock') || title.includes('AWS')) {
28
+ incidents.push(`${date}: ${title.trim()} - Impact: ${impact.trim()}`);
29
+ }
30
+ }
31
+
32
+ let status: StatusCheckResult['status'] = 'operational';
33
+ let message = 'All services operational';
34
+
35
+ if (hasBedrockIssues) {
36
+ status = 'degraded';
37
+ message = 'Amazon Bedrock service issues reported';
38
+ } else if (hasGeneralIssues) {
39
+ status = 'degraded';
40
+ message = 'AWS experiencing general issues';
41
+ }
42
+
43
+ // If status page check fails, fallback to endpoint check
44
+ if (!statusPageResponse.ok) {
45
+ const endpointStatus = await this.checkEndpoint('https://health.aws.amazon.com/health/status');
46
+ const apiEndpoint = 'https://bedrock.us-east-1.amazonaws.com/models';
47
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
48
+
49
+ return {
50
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
51
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
52
+ incidents: ['Note: Limited status information due to CORS restrictions'],
53
+ };
54
+ }
55
+
56
+ return {
57
+ status,
58
+ message,
59
+ incidents: incidents.slice(0, 5),
60
+ };
61
+ } catch (error) {
62
+ console.error('Error checking Amazon Bedrock status:', error);
63
+
64
+ // Fallback to basic endpoint check
65
+ const endpointStatus = await this.checkEndpoint('https://health.aws.amazon.com/health/status');
66
+ const apiEndpoint = 'https://bedrock.us-east-1.amazonaws.com/models';
67
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
68
+
69
+ return {
70
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
71
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
72
+ incidents: ['Note: Limited status information due to CORS restrictions'],
73
+ };
74
+ }
75
+ }
76
+ }
app/components/@settings/tabs/providers/service-status/providers/anthropic.ts ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
2
+ import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
3
+
4
+ export class AnthropicStatusChecker extends BaseProviderChecker {
5
+ async checkStatus(): Promise<StatusCheckResult> {
6
+ try {
7
+ // Check status page
8
+ const statusPageResponse = await fetch('https://status.anthropic.com/');
9
+ const text = await statusPageResponse.text();
10
+
11
+ // Check for specific Anthropic status indicators
12
+ const isOperational = text.includes('All Systems Operational');
13
+ const hasDegradedPerformance = text.includes('Degraded Performance');
14
+ const hasPartialOutage = text.includes('Partial Outage');
15
+ const hasMajorOutage = text.includes('Major Outage');
16
+
17
+ // Extract incidents
18
+ const incidents: string[] = [];
19
+ const incidentSection = text.match(/Past Incidents(.*?)(?=\n\n)/s);
20
+
21
+ if (incidentSection) {
22
+ const incidentLines = incidentSection[1]
23
+ .split('\n')
24
+ .map((line) => line.trim())
25
+ .filter((line) => line && line.includes('202')); // Only get dated incidents
26
+
27
+ incidents.push(...incidentLines.slice(0, 5));
28
+ }
29
+
30
+ let status: StatusCheckResult['status'] = 'operational';
31
+ let message = 'All systems operational';
32
+
33
+ if (hasMajorOutage) {
34
+ status = 'down';
35
+ message = 'Major service outage';
36
+ } else if (hasPartialOutage) {
37
+ status = 'down';
38
+ message = 'Partial service outage';
39
+ } else if (hasDegradedPerformance) {
40
+ status = 'degraded';
41
+ message = 'Service experiencing degraded performance';
42
+ } else if (!isOperational) {
43
+ status = 'degraded';
44
+ message = 'Service status unknown';
45
+ }
46
+
47
+ // If status page check fails, fallback to endpoint check
48
+ if (!statusPageResponse.ok) {
49
+ const endpointStatus = await this.checkEndpoint('https://status.anthropic.com/');
50
+ const apiEndpoint = 'https://api.anthropic.com/v1/messages';
51
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
52
+
53
+ return {
54
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
55
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
56
+ incidents: ['Note: Limited status information due to CORS restrictions'],
57
+ };
58
+ }
59
+
60
+ return {
61
+ status,
62
+ message,
63
+ incidents,
64
+ };
65
+ } catch (error) {
66
+ console.error('Error checking Anthropic status:', error);
67
+
68
+ // Fallback to basic endpoint check
69
+ const endpointStatus = await this.checkEndpoint('https://status.anthropic.com/');
70
+ const apiEndpoint = 'https://api.anthropic.com/v1/messages';
71
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
72
+
73
+ return {
74
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
75
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
76
+ incidents: ['Note: Limited status information due to CORS restrictions'],
77
+ };
78
+ }
79
+ }
80
+ }
app/components/@settings/tabs/providers/service-status/providers/cohere.ts ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
2
+ import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
3
+
4
+ export class CohereStatusChecker extends BaseProviderChecker {
5
+ async checkStatus(): Promise<StatusCheckResult> {
6
+ try {
7
+ // Check status page
8
+ const statusPageResponse = await fetch('https://status.cohere.com/');
9
+ const text = await statusPageResponse.text();
10
+
11
+ // Check for specific Cohere status indicators
12
+ const isOperational = text.includes('All Systems Operational');
13
+ const hasIncidents = text.includes('Active Incidents');
14
+ const hasDegradation = text.includes('Degraded Performance');
15
+ const hasOutage = text.includes('Service Outage');
16
+
17
+ // Extract incidents
18
+ const incidents: string[] = [];
19
+ const incidentSection = text.match(/Past Incidents(.*?)(?=\n\n)/s);
20
+
21
+ if (incidentSection) {
22
+ const incidentLines = incidentSection[1]
23
+ .split('\n')
24
+ .map((line) => line.trim())
25
+ .filter((line) => line && line.includes('202')); // Only get dated incidents
26
+
27
+ incidents.push(...incidentLines.slice(0, 5));
28
+ }
29
+
30
+ // Check specific services
31
+ const services = {
32
+ api: {
33
+ operational: text.includes('API Service') && text.includes('Operational'),
34
+ degraded: text.includes('API Service') && text.includes('Degraded Performance'),
35
+ outage: text.includes('API Service') && text.includes('Service Outage'),
36
+ },
37
+ generation: {
38
+ operational: text.includes('Generation Service') && text.includes('Operational'),
39
+ degraded: text.includes('Generation Service') && text.includes('Degraded Performance'),
40
+ outage: text.includes('Generation Service') && text.includes('Service Outage'),
41
+ },
42
+ };
43
+
44
+ let status: StatusCheckResult['status'] = 'operational';
45
+ let message = 'All systems operational';
46
+
47
+ if (services.api.outage || services.generation.outage || hasOutage) {
48
+ status = 'down';
49
+ message = 'Service outage detected';
50
+ } else if (services.api.degraded || services.generation.degraded || hasDegradation || hasIncidents) {
51
+ status = 'degraded';
52
+ message = 'Service experiencing issues';
53
+ } else if (!isOperational) {
54
+ status = 'degraded';
55
+ message = 'Service status unknown';
56
+ }
57
+
58
+ // If status page check fails, fallback to endpoint check
59
+ if (!statusPageResponse.ok) {
60
+ const endpointStatus = await this.checkEndpoint('https://status.cohere.com/');
61
+ const apiEndpoint = 'https://api.cohere.ai/v1/models';
62
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
63
+
64
+ return {
65
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
66
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
67
+ incidents: ['Note: Limited status information due to CORS restrictions'],
68
+ };
69
+ }
70
+
71
+ return {
72
+ status,
73
+ message,
74
+ incidents,
75
+ };
76
+ } catch (error) {
77
+ console.error('Error checking Cohere status:', error);
78
+
79
+ // Fallback to basic endpoint check
80
+ const endpointStatus = await this.checkEndpoint('https://status.cohere.com/');
81
+ const apiEndpoint = 'https://api.cohere.ai/v1/models';
82
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
83
+
84
+ return {
85
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
86
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
87
+ incidents: ['Note: Limited status information due to CORS restrictions'],
88
+ };
89
+ }
90
+ }
91
+ }
app/components/@settings/tabs/providers/service-status/providers/deepseek.ts ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
2
+ import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
3
+
4
+ export class DeepseekStatusChecker extends BaseProviderChecker {
5
+ async checkStatus(): Promise<StatusCheckResult> {
6
+ try {
7
+ /*
8
+ * Check status page - Note: Deepseek doesn't have a public status page yet
9
+ * so we'll check their API endpoint directly
10
+ */
11
+ const apiEndpoint = 'https://api.deepseek.com/v1/models';
12
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
13
+
14
+ // Check their website as a secondary indicator
15
+ const websiteStatus = await this.checkEndpoint('https://deepseek.com');
16
+
17
+ let status: StatusCheckResult['status'] = 'operational';
18
+ let message = 'All systems operational';
19
+
20
+ if (apiStatus !== 'reachable' || websiteStatus !== 'reachable') {
21
+ status = apiStatus !== 'reachable' ? 'down' : 'degraded';
22
+ message = apiStatus !== 'reachable' ? 'API appears to be down' : 'Service may be experiencing issues';
23
+ }
24
+
25
+ return {
26
+ status,
27
+ message,
28
+ incidents: [], // No public incident tracking available yet
29
+ };
30
+ } catch (error) {
31
+ console.error('Error checking Deepseek status:', error);
32
+
33
+ return {
34
+ status: 'degraded',
35
+ message: 'Unable to determine service status',
36
+ incidents: ['Note: Limited status information available'],
37
+ };
38
+ }
39
+ }
40
+ }
app/components/@settings/tabs/providers/service-status/providers/google.ts ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
2
+ import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
3
+
4
+ export class GoogleStatusChecker extends BaseProviderChecker {
5
+ async checkStatus(): Promise<StatusCheckResult> {
6
+ try {
7
+ // Check status page
8
+ const statusPageResponse = await fetch('https://status.cloud.google.com/');
9
+ const text = await statusPageResponse.text();
10
+
11
+ // Check for Vertex AI and general cloud status
12
+ const hasVertexAIIssues =
13
+ text.includes('Vertex AI') &&
14
+ (text.includes('Incident') ||
15
+ text.includes('Disruption') ||
16
+ text.includes('Outage') ||
17
+ text.includes('degraded'));
18
+
19
+ const hasGeneralIssues = text.includes('Major Incidents') || text.includes('Service Disruption');
20
+
21
+ // Extract incidents
22
+ const incidents: string[] = [];
23
+ const incidentMatches = text.matchAll(/(\d{4}-\d{2}-\d{2})\s+(.*?)\s+Impact:(.*?)(?=\n|$)/g);
24
+
25
+ for (const match of incidentMatches) {
26
+ const [, date, title, impact] = match;
27
+
28
+ if (title.includes('Vertex AI') || title.includes('Cloud')) {
29
+ incidents.push(`${date}: ${title.trim()} - Impact: ${impact.trim()}`);
30
+ }
31
+ }
32
+
33
+ let status: StatusCheckResult['status'] = 'operational';
34
+ let message = 'All services operational';
35
+
36
+ if (hasVertexAIIssues) {
37
+ status = 'degraded';
38
+ message = 'Vertex AI service issues reported';
39
+ } else if (hasGeneralIssues) {
40
+ status = 'degraded';
41
+ message = 'Google Cloud experiencing issues';
42
+ }
43
+
44
+ // If status page check fails, fallback to endpoint check
45
+ if (!statusPageResponse.ok) {
46
+ const endpointStatus = await this.checkEndpoint('https://status.cloud.google.com/');
47
+ const apiEndpoint = 'https://generativelanguage.googleapis.com/v1/models';
48
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
49
+
50
+ return {
51
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
52
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
53
+ incidents: ['Note: Limited status information due to CORS restrictions'],
54
+ };
55
+ }
56
+
57
+ return {
58
+ status,
59
+ message,
60
+ incidents: incidents.slice(0, 5),
61
+ };
62
+ } catch (error) {
63
+ console.error('Error checking Google status:', error);
64
+
65
+ // Fallback to basic endpoint check
66
+ const endpointStatus = await this.checkEndpoint('https://status.cloud.google.com/');
67
+ const apiEndpoint = 'https://generativelanguage.googleapis.com/v1/models';
68
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
69
+
70
+ return {
71
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
72
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
73
+ incidents: ['Note: Limited status information due to CORS restrictions'],
74
+ };
75
+ }
76
+ }
77
+ }
app/components/@settings/tabs/providers/service-status/providers/groq.ts ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
2
+ import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
3
+
4
+ export class GroqStatusChecker extends BaseProviderChecker {
5
+ async checkStatus(): Promise<StatusCheckResult> {
6
+ try {
7
+ // Check status page
8
+ const statusPageResponse = await fetch('https://groqstatus.com/');
9
+ const text = await statusPageResponse.text();
10
+
11
+ const isOperational = text.includes('All Systems Operational');
12
+ const hasIncidents = text.includes('Active Incidents');
13
+ const hasDegradation = text.includes('Degraded Performance');
14
+ const hasOutage = text.includes('Service Outage');
15
+
16
+ // Extract incidents
17
+ const incidents: string[] = [];
18
+ const incidentMatches = text.matchAll(/(\d{4}-\d{2}-\d{2})\s+(.*?)\s+Status:(.*?)(?=\n|$)/g);
19
+
20
+ for (const match of incidentMatches) {
21
+ const [, date, title, status] = match;
22
+ incidents.push(`${date}: ${title.trim()} - ${status.trim()}`);
23
+ }
24
+
25
+ let status: StatusCheckResult['status'] = 'operational';
26
+ let message = 'All systems operational';
27
+
28
+ if (hasOutage) {
29
+ status = 'down';
30
+ message = 'Service outage detected';
31
+ } else if (hasDegradation || hasIncidents) {
32
+ status = 'degraded';
33
+ message = 'Service experiencing issues';
34
+ } else if (!isOperational) {
35
+ status = 'degraded';
36
+ message = 'Service status unknown';
37
+ }
38
+
39
+ // If status page check fails, fallback to endpoint check
40
+ if (!statusPageResponse.ok) {
41
+ const endpointStatus = await this.checkEndpoint('https://groqstatus.com/');
42
+ const apiEndpoint = 'https://api.groq.com/v1/models';
43
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
44
+
45
+ return {
46
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
47
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
48
+ incidents: ['Note: Limited status information due to CORS restrictions'],
49
+ };
50
+ }
51
+
52
+ return {
53
+ status,
54
+ message,
55
+ incidents: incidents.slice(0, 5),
56
+ };
57
+ } catch (error) {
58
+ console.error('Error checking Groq status:', error);
59
+
60
+ // Fallback to basic endpoint check
61
+ const endpointStatus = await this.checkEndpoint('https://groqstatus.com/');
62
+ const apiEndpoint = 'https://api.groq.com/v1/models';
63
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
64
+
65
+ return {
66
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
67
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
68
+ incidents: ['Note: Limited status information due to CORS restrictions'],
69
+ };
70
+ }
71
+ }
72
+ }
app/components/@settings/tabs/providers/service-status/providers/huggingface.ts ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
2
+ import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
3
+
4
+ export class HuggingFaceStatusChecker extends BaseProviderChecker {
5
+ async checkStatus(): Promise<StatusCheckResult> {
6
+ try {
7
+ // Check status page
8
+ const statusPageResponse = await fetch('https://status.huggingface.co/');
9
+ const text = await statusPageResponse.text();
10
+
11
+ // Check for "All services are online" message
12
+ const allServicesOnline = text.includes('All services are online');
13
+
14
+ // Get last update time
15
+ const lastUpdateMatch = text.match(/Last updated on (.*?)(EST|PST|GMT)/);
16
+ const lastUpdate = lastUpdateMatch ? `${lastUpdateMatch[1]}${lastUpdateMatch[2]}` : '';
17
+
18
+ // Check individual services and their uptime percentages
19
+ const services = {
20
+ 'Huggingface Hub': {
21
+ operational: text.includes('Huggingface Hub') && text.includes('Operational'),
22
+ uptime: text.match(/Huggingface Hub[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1],
23
+ },
24
+ 'Git Hosting and Serving': {
25
+ operational: text.includes('Git Hosting and Serving') && text.includes('Operational'),
26
+ uptime: text.match(/Git Hosting and Serving[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1],
27
+ },
28
+ 'Inference API': {
29
+ operational: text.includes('Inference API') && text.includes('Operational'),
30
+ uptime: text.match(/Inference API[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1],
31
+ },
32
+ 'HF Endpoints': {
33
+ operational: text.includes('HF Endpoints') && text.includes('Operational'),
34
+ uptime: text.match(/HF Endpoints[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1],
35
+ },
36
+ Spaces: {
37
+ operational: text.includes('Spaces') && text.includes('Operational'),
38
+ uptime: text.match(/Spaces[\s\S]*?(\d+\.\d+)%\s*uptime/)?.[1],
39
+ },
40
+ };
41
+
42
+ // Create service status messages with uptime
43
+ const serviceMessages = Object.entries(services).map(([name, info]) => {
44
+ if (info.uptime) {
45
+ return `${name}: ${info.uptime}% uptime`;
46
+ }
47
+
48
+ return `${name}: ${info.operational ? 'Operational' : 'Issues detected'}`;
49
+ });
50
+
51
+ // Determine overall status
52
+ let status: StatusCheckResult['status'] = 'operational';
53
+ let message = allServicesOnline
54
+ ? `All services are online (Last updated on ${lastUpdate})`
55
+ : 'Checking individual services';
56
+
57
+ // Only mark as degraded if we explicitly detect issues
58
+ const hasIssues = Object.values(services).some((service) => !service.operational);
59
+
60
+ if (hasIssues) {
61
+ status = 'degraded';
62
+ message = `Service issues detected (Last updated on ${lastUpdate})`;
63
+ }
64
+
65
+ // If status page check fails, fallback to endpoint check
66
+ if (!statusPageResponse.ok) {
67
+ const endpointStatus = await this.checkEndpoint('https://status.huggingface.co/');
68
+ const apiEndpoint = 'https://api-inference.huggingface.co/models';
69
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
70
+
71
+ return {
72
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
73
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
74
+ incidents: ['Note: Limited status information due to CORS restrictions'],
75
+ };
76
+ }
77
+
78
+ return {
79
+ status,
80
+ message,
81
+ incidents: serviceMessages,
82
+ };
83
+ } catch (error) {
84
+ console.error('Error checking HuggingFace status:', error);
85
+
86
+ // Fallback to basic endpoint check
87
+ const endpointStatus = await this.checkEndpoint('https://status.huggingface.co/');
88
+ const apiEndpoint = 'https://api-inference.huggingface.co/models';
89
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
90
+
91
+ return {
92
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
93
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
94
+ incidents: ['Note: Limited status information due to CORS restrictions'],
95
+ };
96
+ }
97
+ }
98
+ }
app/components/@settings/tabs/providers/service-status/providers/hyperbolic.ts ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
2
+ import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
3
+
4
+ export class HyperbolicStatusChecker extends BaseProviderChecker {
5
+ async checkStatus(): Promise<StatusCheckResult> {
6
+ try {
7
+ /*
8
+ * Check API endpoint directly since Hyperbolic is a newer provider
9
+ * and may not have a public status page yet
10
+ */
11
+ const apiEndpoint = 'https://api.hyperbolic.ai/v1/models';
12
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
13
+
14
+ // Check their website as a secondary indicator
15
+ const websiteStatus = await this.checkEndpoint('https://hyperbolic.ai');
16
+
17
+ let status: StatusCheckResult['status'] = 'operational';
18
+ let message = 'All systems operational';
19
+
20
+ if (apiStatus !== 'reachable' || websiteStatus !== 'reachable') {
21
+ status = apiStatus !== 'reachable' ? 'down' : 'degraded';
22
+ message = apiStatus !== 'reachable' ? 'API appears to be down' : 'Service may be experiencing issues';
23
+ }
24
+
25
+ return {
26
+ status,
27
+ message,
28
+ incidents: [], // No public incident tracking available yet
29
+ };
30
+ } catch (error) {
31
+ console.error('Error checking Hyperbolic status:', error);
32
+
33
+ return {
34
+ status: 'degraded',
35
+ message: 'Unable to determine service status',
36
+ incidents: ['Note: Limited status information available'],
37
+ };
38
+ }
39
+ }
40
+ }
app/components/@settings/tabs/providers/service-status/providers/mistral.ts ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
2
+ import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
3
+
4
+ export class MistralStatusChecker extends BaseProviderChecker {
5
+ async checkStatus(): Promise<StatusCheckResult> {
6
+ try {
7
+ // Check status page
8
+ const statusPageResponse = await fetch('https://status.mistral.ai/');
9
+ const text = await statusPageResponse.text();
10
+
11
+ const isOperational = text.includes('All Systems Operational');
12
+ const hasIncidents = text.includes('Active Incidents');
13
+ const hasDegradation = text.includes('Degraded Performance');
14
+ const hasOutage = text.includes('Service Outage');
15
+
16
+ // Extract incidents
17
+ const incidents: string[] = [];
18
+ const incidentSection = text.match(/Recent Events(.*?)(?=\n\n)/s);
19
+
20
+ if (incidentSection) {
21
+ const incidentLines = incidentSection[1]
22
+ .split('\n')
23
+ .map((line) => line.trim())
24
+ .filter((line) => line && !line.includes('No incidents'));
25
+
26
+ incidents.push(...incidentLines.slice(0, 5));
27
+ }
28
+
29
+ let status: StatusCheckResult['status'] = 'operational';
30
+ let message = 'All systems operational';
31
+
32
+ if (hasOutage) {
33
+ status = 'down';
34
+ message = 'Service outage detected';
35
+ } else if (hasDegradation || hasIncidents) {
36
+ status = 'degraded';
37
+ message = 'Service experiencing issues';
38
+ } else if (!isOperational) {
39
+ status = 'degraded';
40
+ message = 'Service status unknown';
41
+ }
42
+
43
+ // If status page check fails, fallback to endpoint check
44
+ if (!statusPageResponse.ok) {
45
+ const endpointStatus = await this.checkEndpoint('https://status.mistral.ai/');
46
+ const apiEndpoint = 'https://api.mistral.ai/v1/models';
47
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
48
+
49
+ return {
50
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
51
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
52
+ incidents: ['Note: Limited status information due to CORS restrictions'],
53
+ };
54
+ }
55
+
56
+ return {
57
+ status,
58
+ message,
59
+ incidents,
60
+ };
61
+ } catch (error) {
62
+ console.error('Error checking Mistral status:', error);
63
+
64
+ // Fallback to basic endpoint check
65
+ const endpointStatus = await this.checkEndpoint('https://status.mistral.ai/');
66
+ const apiEndpoint = 'https://api.mistral.ai/v1/models';
67
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
68
+
69
+ return {
70
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
71
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
72
+ incidents: ['Note: Limited status information due to CORS restrictions'],
73
+ };
74
+ }
75
+ }
76
+ }
app/components/@settings/tabs/providers/service-status/providers/openai.ts ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
2
+ import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
3
+
4
+ export class OpenAIStatusChecker extends BaseProviderChecker {
5
+ async checkStatus(): Promise<StatusCheckResult> {
6
+ try {
7
+ // Check status page
8
+ const statusPageResponse = await fetch('https://status.openai.com/');
9
+ const text = await statusPageResponse.text();
10
+
11
+ // Check individual services
12
+ const services = {
13
+ api: {
14
+ operational: text.includes('API ? Operational'),
15
+ degraded: text.includes('API ? Degraded Performance'),
16
+ outage: text.includes('API ? Major Outage') || text.includes('API ? Partial Outage'),
17
+ },
18
+ chat: {
19
+ operational: text.includes('ChatGPT ? Operational'),
20
+ degraded: text.includes('ChatGPT ? Degraded Performance'),
21
+ outage: text.includes('ChatGPT ? Major Outage') || text.includes('ChatGPT ? Partial Outage'),
22
+ },
23
+ };
24
+
25
+ // Extract recent incidents
26
+ const incidents: string[] = [];
27
+ const incidentMatches = text.match(/Past Incidents(.*?)(?=\w+ \d+, \d{4})/s);
28
+
29
+ if (incidentMatches) {
30
+ const recentIncidents = incidentMatches[1]
31
+ .split('\n')
32
+ .map((line) => line.trim())
33
+ .filter((line) => line && line.includes('202')); // Get only dated incidents
34
+
35
+ incidents.push(...recentIncidents.slice(0, 5));
36
+ }
37
+
38
+ // Determine overall status
39
+ let status: StatusCheckResult['status'] = 'operational';
40
+ const messages: string[] = [];
41
+
42
+ if (services.api.outage || services.chat.outage) {
43
+ status = 'down';
44
+
45
+ if (services.api.outage) {
46
+ messages.push('API: Major Outage');
47
+ }
48
+
49
+ if (services.chat.outage) {
50
+ messages.push('ChatGPT: Major Outage');
51
+ }
52
+ } else if (services.api.degraded || services.chat.degraded) {
53
+ status = 'degraded';
54
+
55
+ if (services.api.degraded) {
56
+ messages.push('API: Degraded Performance');
57
+ }
58
+
59
+ if (services.chat.degraded) {
60
+ messages.push('ChatGPT: Degraded Performance');
61
+ }
62
+ } else if (services.api.operational) {
63
+ messages.push('API: Operational');
64
+ }
65
+
66
+ // If status page check fails, fallback to endpoint check
67
+ if (!statusPageResponse.ok) {
68
+ const endpointStatus = await this.checkEndpoint('https://status.openai.com/');
69
+ const apiEndpoint = 'https://api.openai.com/v1/models';
70
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
71
+
72
+ return {
73
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
74
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
75
+ incidents: ['Note: Limited status information due to CORS restrictions'],
76
+ };
77
+ }
78
+
79
+ return {
80
+ status,
81
+ message: messages.join(', ') || 'Status unknown',
82
+ incidents,
83
+ };
84
+ } catch (error) {
85
+ console.error('Error checking OpenAI status:', error);
86
+
87
+ // Fallback to basic endpoint check
88
+ const endpointStatus = await this.checkEndpoint('https://status.openai.com/');
89
+ const apiEndpoint = 'https://api.openai.com/v1/models';
90
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
91
+
92
+ return {
93
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
94
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
95
+ incidents: ['Note: Limited status information due to CORS restrictions'],
96
+ };
97
+ }
98
+ }
99
+ }
app/components/@settings/tabs/providers/service-status/providers/openrouter.ts ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
2
+ import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
3
+
4
+ export class OpenRouterStatusChecker extends BaseProviderChecker {
5
+ async checkStatus(): Promise<StatusCheckResult> {
6
+ try {
7
+ // Check status page
8
+ const statusPageResponse = await fetch('https://status.openrouter.ai/');
9
+ const text = await statusPageResponse.text();
10
+
11
+ // Check for specific OpenRouter status indicators
12
+ const isOperational = text.includes('All Systems Operational');
13
+ const hasIncidents = text.includes('Active Incidents');
14
+ const hasDegradation = text.includes('Degraded Performance');
15
+ const hasOutage = text.includes('Service Outage');
16
+
17
+ // Extract incidents
18
+ const incidents: string[] = [];
19
+ const incidentSection = text.match(/Past Incidents(.*?)(?=\n\n)/s);
20
+
21
+ if (incidentSection) {
22
+ const incidentLines = incidentSection[1]
23
+ .split('\n')
24
+ .map((line) => line.trim())
25
+ .filter((line) => line && line.includes('202')); // Only get dated incidents
26
+
27
+ incidents.push(...incidentLines.slice(0, 5));
28
+ }
29
+
30
+ // Check specific services
31
+ const services = {
32
+ api: {
33
+ operational: text.includes('API Service') && text.includes('Operational'),
34
+ degraded: text.includes('API Service') && text.includes('Degraded Performance'),
35
+ outage: text.includes('API Service') && text.includes('Service Outage'),
36
+ },
37
+ routing: {
38
+ operational: text.includes('Routing Service') && text.includes('Operational'),
39
+ degraded: text.includes('Routing Service') && text.includes('Degraded Performance'),
40
+ outage: text.includes('Routing Service') && text.includes('Service Outage'),
41
+ },
42
+ };
43
+
44
+ let status: StatusCheckResult['status'] = 'operational';
45
+ let message = 'All systems operational';
46
+
47
+ if (services.api.outage || services.routing.outage || hasOutage) {
48
+ status = 'down';
49
+ message = 'Service outage detected';
50
+ } else if (services.api.degraded || services.routing.degraded || hasDegradation || hasIncidents) {
51
+ status = 'degraded';
52
+ message = 'Service experiencing issues';
53
+ } else if (!isOperational) {
54
+ status = 'degraded';
55
+ message = 'Service status unknown';
56
+ }
57
+
58
+ // If status page check fails, fallback to endpoint check
59
+ if (!statusPageResponse.ok) {
60
+ const endpointStatus = await this.checkEndpoint('https://status.openrouter.ai/');
61
+ const apiEndpoint = 'https://openrouter.ai/api/v1/models';
62
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
63
+
64
+ return {
65
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
66
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
67
+ incidents: ['Note: Limited status information due to CORS restrictions'],
68
+ };
69
+ }
70
+
71
+ return {
72
+ status,
73
+ message,
74
+ incidents,
75
+ };
76
+ } catch (error) {
77
+ console.error('Error checking OpenRouter status:', error);
78
+
79
+ // Fallback to basic endpoint check
80
+ const endpointStatus = await this.checkEndpoint('https://status.openrouter.ai/');
81
+ const apiEndpoint = 'https://openrouter.ai/api/v1/models';
82
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
83
+
84
+ return {
85
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
86
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
87
+ incidents: ['Note: Limited status information due to CORS restrictions'],
88
+ };
89
+ }
90
+ }
91
+ }
app/components/@settings/tabs/providers/service-status/providers/perplexity.ts ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
2
+ import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
3
+
4
+ export class PerplexityStatusChecker extends BaseProviderChecker {
5
+ async checkStatus(): Promise<StatusCheckResult> {
6
+ try {
7
+ // Check status page
8
+ const statusPageResponse = await fetch('https://status.perplexity.ai/');
9
+ const text = await statusPageResponse.text();
10
+
11
+ // Check for specific Perplexity status indicators
12
+ const isOperational = text.includes('All Systems Operational');
13
+ const hasIncidents = text.includes('Active Incidents');
14
+ const hasDegradation = text.includes('Degraded Performance');
15
+ const hasOutage = text.includes('Service Outage');
16
+
17
+ // Extract incidents
18
+ const incidents: string[] = [];
19
+ const incidentSection = text.match(/Past Incidents(.*?)(?=\n\n)/s);
20
+
21
+ if (incidentSection) {
22
+ const incidentLines = incidentSection[1]
23
+ .split('\n')
24
+ .map((line) => line.trim())
25
+ .filter((line) => line && line.includes('202')); // Only get dated incidents
26
+
27
+ incidents.push(...incidentLines.slice(0, 5));
28
+ }
29
+
30
+ // Check specific services
31
+ const services = {
32
+ api: {
33
+ operational: text.includes('API Service') && text.includes('Operational'),
34
+ degraded: text.includes('API Service') && text.includes('Degraded Performance'),
35
+ outage: text.includes('API Service') && text.includes('Service Outage'),
36
+ },
37
+ inference: {
38
+ operational: text.includes('Inference Service') && text.includes('Operational'),
39
+ degraded: text.includes('Inference Service') && text.includes('Degraded Performance'),
40
+ outage: text.includes('Inference Service') && text.includes('Service Outage'),
41
+ },
42
+ };
43
+
44
+ let status: StatusCheckResult['status'] = 'operational';
45
+ let message = 'All systems operational';
46
+
47
+ if (services.api.outage || services.inference.outage || hasOutage) {
48
+ status = 'down';
49
+ message = 'Service outage detected';
50
+ } else if (services.api.degraded || services.inference.degraded || hasDegradation || hasIncidents) {
51
+ status = 'degraded';
52
+ message = 'Service experiencing issues';
53
+ } else if (!isOperational) {
54
+ status = 'degraded';
55
+ message = 'Service status unknown';
56
+ }
57
+
58
+ // If status page check fails, fallback to endpoint check
59
+ if (!statusPageResponse.ok) {
60
+ const endpointStatus = await this.checkEndpoint('https://status.perplexity.ai/');
61
+ const apiEndpoint = 'https://api.perplexity.ai/v1/models';
62
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
63
+
64
+ return {
65
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
66
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
67
+ incidents: ['Note: Limited status information due to CORS restrictions'],
68
+ };
69
+ }
70
+
71
+ return {
72
+ status,
73
+ message,
74
+ incidents,
75
+ };
76
+ } catch (error) {
77
+ console.error('Error checking Perplexity status:', error);
78
+
79
+ // Fallback to basic endpoint check
80
+ const endpointStatus = await this.checkEndpoint('https://status.perplexity.ai/');
81
+ const apiEndpoint = 'https://api.perplexity.ai/v1/models';
82
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
83
+
84
+ return {
85
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
86
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
87
+ incidents: ['Note: Limited status information due to CORS restrictions'],
88
+ };
89
+ }
90
+ }
91
+ }
app/components/@settings/tabs/providers/service-status/providers/together.ts ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
2
+ import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
3
+
4
+ export class TogetherStatusChecker extends BaseProviderChecker {
5
+ async checkStatus(): Promise<StatusCheckResult> {
6
+ try {
7
+ // Check status page
8
+ const statusPageResponse = await fetch('https://status.together.ai/');
9
+ const text = await statusPageResponse.text();
10
+
11
+ // Check for specific Together status indicators
12
+ const isOperational = text.includes('All Systems Operational');
13
+ const hasIncidents = text.includes('Active Incidents');
14
+ const hasDegradation = text.includes('Degraded Performance');
15
+ const hasOutage = text.includes('Service Outage');
16
+
17
+ // Extract incidents
18
+ const incidents: string[] = [];
19
+ const incidentSection = text.match(/Past Incidents(.*?)(?=\n\n)/s);
20
+
21
+ if (incidentSection) {
22
+ const incidentLines = incidentSection[1]
23
+ .split('\n')
24
+ .map((line) => line.trim())
25
+ .filter((line) => line && line.includes('202')); // Only get dated incidents
26
+
27
+ incidents.push(...incidentLines.slice(0, 5));
28
+ }
29
+
30
+ // Check specific services
31
+ const services = {
32
+ api: {
33
+ operational: text.includes('API Service') && text.includes('Operational'),
34
+ degraded: text.includes('API Service') && text.includes('Degraded Performance'),
35
+ outage: text.includes('API Service') && text.includes('Service Outage'),
36
+ },
37
+ inference: {
38
+ operational: text.includes('Inference Service') && text.includes('Operational'),
39
+ degraded: text.includes('Inference Service') && text.includes('Degraded Performance'),
40
+ outage: text.includes('Inference Service') && text.includes('Service Outage'),
41
+ },
42
+ };
43
+
44
+ let status: StatusCheckResult['status'] = 'operational';
45
+ let message = 'All systems operational';
46
+
47
+ if (services.api.outage || services.inference.outage || hasOutage) {
48
+ status = 'down';
49
+ message = 'Service outage detected';
50
+ } else if (services.api.degraded || services.inference.degraded || hasDegradation || hasIncidents) {
51
+ status = 'degraded';
52
+ message = 'Service experiencing issues';
53
+ } else if (!isOperational) {
54
+ status = 'degraded';
55
+ message = 'Service status unknown';
56
+ }
57
+
58
+ // If status page check fails, fallback to endpoint check
59
+ if (!statusPageResponse.ok) {
60
+ const endpointStatus = await this.checkEndpoint('https://status.together.ai/');
61
+ const apiEndpoint = 'https://api.together.ai/v1/models';
62
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
63
+
64
+ return {
65
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
66
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
67
+ incidents: ['Note: Limited status information due to CORS restrictions'],
68
+ };
69
+ }
70
+
71
+ return {
72
+ status,
73
+ message,
74
+ incidents,
75
+ };
76
+ } catch (error) {
77
+ console.error('Error checking Together status:', error);
78
+
79
+ // Fallback to basic endpoint check
80
+ const endpointStatus = await this.checkEndpoint('https://status.together.ai/');
81
+ const apiEndpoint = 'https://api.together.ai/v1/models';
82
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
83
+
84
+ return {
85
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
86
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
87
+ incidents: ['Note: Limited status information due to CORS restrictions'],
88
+ };
89
+ }
90
+ }
91
+ }
app/components/@settings/tabs/providers/service-status/providers/xai.ts ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { BaseProviderChecker } from '~/components/@settings/tabs/providers/service-status/base-provider';
2
+ import type { StatusCheckResult } from '~/components/@settings/tabs/providers/service-status/types';
3
+
4
+ export class XAIStatusChecker extends BaseProviderChecker {
5
+ async checkStatus(): Promise<StatusCheckResult> {
6
+ try {
7
+ /*
8
+ * Check API endpoint directly since XAI is a newer provider
9
+ * and may not have a public status page yet
10
+ */
11
+ const apiEndpoint = 'https://api.xai.com/v1/models';
12
+ const apiStatus = await this.checkEndpoint(apiEndpoint);
13
+
14
+ // Check their website as a secondary indicator
15
+ const websiteStatus = await this.checkEndpoint('https://x.ai');
16
+
17
+ let status: StatusCheckResult['status'] = 'operational';
18
+ let message = 'All systems operational';
19
+
20
+ if (apiStatus !== 'reachable' || websiteStatus !== 'reachable') {
21
+ status = apiStatus !== 'reachable' ? 'down' : 'degraded';
22
+ message = apiStatus !== 'reachable' ? 'API appears to be down' : 'Service may be experiencing issues';
23
+ }
24
+
25
+ return {
26
+ status,
27
+ message,
28
+ incidents: [], // No public incident tracking available yet
29
+ };
30
+ } catch (error) {
31
+ console.error('Error checking XAI status:', error);
32
+
33
+ return {
34
+ status: 'degraded',
35
+ message: 'Unable to determine service status',
36
+ incidents: ['Note: Limited status information available'],
37
+ };
38
+ }
39
+ }
40
+ }
app/components/@settings/tabs/providers/service-status/types.ts ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { IconType } from 'react-icons';
2
+
3
+ export type ProviderName =
4
+ | 'AmazonBedrock'
5
+ | 'Cohere'
6
+ | 'Deepseek'
7
+ | 'Google'
8
+ | 'Groq'
9
+ | 'HuggingFace'
10
+ | 'Hyperbolic'
11
+ | 'Mistral'
12
+ | 'OpenRouter'
13
+ | 'Perplexity'
14
+ | 'Together'
15
+ | 'XAI';
16
+
17
+ export type ServiceStatus = {
18
+ provider: ProviderName;
19
+ status: 'operational' | 'degraded' | 'down';
20
+ lastChecked: string;
21
+ statusUrl?: string;
22
+ icon?: IconType;
23
+ message?: string;
24
+ responseTime?: number;
25
+ incidents?: string[];
26
+ };
27
+
28
+ export interface ProviderConfig {
29
+ statusUrl: string;
30
+ apiUrl: string;
31
+ headers: Record<string, string>;
32
+ testModel: string;
33
+ }
34
+
35
+ export type ApiResponse = {
36
+ error?: {
37
+ message: string;
38
+ };
39
+ message?: string;
40
+ model?: string;
41
+ models?: Array<{
42
+ id?: string;
43
+ name?: string;
44
+ }>;
45
+ data?: Array<{
46
+ id?: string;
47
+ name?: string;
48
+ }>;
49
+ };
50
+
51
+ export type StatusCheckResult = {
52
+ status: 'operational' | 'degraded' | 'down';
53
+ message: string;
54
+ incidents: string[];
55
+ };
app/components/@settings/tabs/providers/status/ServiceStatusTab.tsx ADDED
@@ -0,0 +1,886 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState, useCallback } from 'react';
2
+ import { motion } from 'framer-motion';
3
+ import { classNames } from '~/utils/classNames';
4
+ import { TbActivityHeartbeat } from 'react-icons/tb';
5
+ import { BsCheckCircleFill, BsXCircleFill, BsExclamationCircleFill } from 'react-icons/bs';
6
+ import { SiAmazon, SiGoogle, SiHuggingface, SiPerplexity, SiOpenai } from 'react-icons/si';
7
+ import { BsRobot, BsCloud } from 'react-icons/bs';
8
+ import { TbBrain } from 'react-icons/tb';
9
+ import { BiChip, BiCodeBlock } from 'react-icons/bi';
10
+ import { FaCloud, FaBrain } from 'react-icons/fa';
11
+ import type { IconType } from 'react-icons';
12
+ import { useSettings } from '~/lib/hooks/useSettings';
13
+ import { useToast } from '~/components/ui/use-toast';
14
+
15
+ // Types
16
+ type ProviderName =
17
+ | 'AmazonBedrock'
18
+ | 'Anthropic'
19
+ | 'Cohere'
20
+ | 'Deepseek'
21
+ | 'Google'
22
+ | 'Groq'
23
+ | 'HuggingFace'
24
+ | 'Mistral'
25
+ | 'OpenAI'
26
+ | 'OpenRouter'
27
+ | 'Perplexity'
28
+ | 'Together'
29
+ | 'XAI';
30
+
31
+ type ServiceStatus = {
32
+ provider: ProviderName;
33
+ status: 'operational' | 'degraded' | 'down';
34
+ lastChecked: string;
35
+ statusUrl?: string;
36
+ icon?: IconType;
37
+ message?: string;
38
+ responseTime?: number;
39
+ incidents?: string[];
40
+ };
41
+
42
+ type ProviderConfig = {
43
+ statusUrl: string;
44
+ apiUrl: string;
45
+ headers: Record<string, string>;
46
+ testModel: string;
47
+ };
48
+
49
+ // Types for API responses
50
+ type ApiResponse = {
51
+ error?: {
52
+ message: string;
53
+ };
54
+ message?: string;
55
+ model?: string;
56
+ models?: Array<{
57
+ id?: string;
58
+ name?: string;
59
+ }>;
60
+ data?: Array<{
61
+ id?: string;
62
+ name?: string;
63
+ }>;
64
+ };
65
+
66
+ // Constants
67
+ const PROVIDER_STATUS_URLS: Record<ProviderName, ProviderConfig> = {
68
+ OpenAI: {
69
+ statusUrl: 'https://status.openai.com/',
70
+ apiUrl: 'https://api.openai.com/v1/models',
71
+ headers: {
72
+ Authorization: 'Bearer $OPENAI_API_KEY',
73
+ },
74
+ testModel: 'gpt-3.5-turbo',
75
+ },
76
+ Anthropic: {
77
+ statusUrl: 'https://status.anthropic.com/',
78
+ apiUrl: 'https://api.anthropic.com/v1/messages',
79
+ headers: {
80
+ 'x-api-key': '$ANTHROPIC_API_KEY',
81
+ 'anthropic-version': '2024-02-29',
82
+ },
83
+ testModel: 'claude-3-sonnet-20240229',
84
+ },
85
+ Cohere: {
86
+ statusUrl: 'https://status.cohere.com/',
87
+ apiUrl: 'https://api.cohere.ai/v1/models',
88
+ headers: {
89
+ Authorization: 'Bearer $COHERE_API_KEY',
90
+ },
91
+ testModel: 'command',
92
+ },
93
+ Google: {
94
+ statusUrl: 'https://status.cloud.google.com/',
95
+ apiUrl: 'https://generativelanguage.googleapis.com/v1/models',
96
+ headers: {
97
+ 'x-goog-api-key': '$GOOGLE_API_KEY',
98
+ },
99
+ testModel: 'gemini-pro',
100
+ },
101
+ HuggingFace: {
102
+ statusUrl: 'https://status.huggingface.co/',
103
+ apiUrl: 'https://api-inference.huggingface.co/models',
104
+ headers: {
105
+ Authorization: 'Bearer $HUGGINGFACE_API_KEY',
106
+ },
107
+ testModel: 'mistralai/Mixtral-8x7B-Instruct-v0.1',
108
+ },
109
+ Mistral: {
110
+ statusUrl: 'https://status.mistral.ai/',
111
+ apiUrl: 'https://api.mistral.ai/v1/models',
112
+ headers: {
113
+ Authorization: 'Bearer $MISTRAL_API_KEY',
114
+ },
115
+ testModel: 'mistral-tiny',
116
+ },
117
+ Perplexity: {
118
+ statusUrl: 'https://status.perplexity.com/',
119
+ apiUrl: 'https://api.perplexity.ai/v1/models',
120
+ headers: {
121
+ Authorization: 'Bearer $PERPLEXITY_API_KEY',
122
+ },
123
+ testModel: 'pplx-7b-chat',
124
+ },
125
+ Together: {
126
+ statusUrl: 'https://status.together.ai/',
127
+ apiUrl: 'https://api.together.xyz/v1/models',
128
+ headers: {
129
+ Authorization: 'Bearer $TOGETHER_API_KEY',
130
+ },
131
+ testModel: 'mistralai/Mixtral-8x7B-Instruct-v0.1',
132
+ },
133
+ AmazonBedrock: {
134
+ statusUrl: 'https://health.aws.amazon.com/health/status',
135
+ apiUrl: 'https://bedrock.us-east-1.amazonaws.com/models',
136
+ headers: {
137
+ Authorization: 'Bearer $AWS_BEDROCK_CONFIG',
138
+ },
139
+ testModel: 'anthropic.claude-3-sonnet-20240229-v1:0',
140
+ },
141
+ Groq: {
142
+ statusUrl: 'https://groqstatus.com/',
143
+ apiUrl: 'https://api.groq.com/v1/models',
144
+ headers: {
145
+ Authorization: 'Bearer $GROQ_API_KEY',
146
+ },
147
+ testModel: 'mixtral-8x7b-32768',
148
+ },
149
+ OpenRouter: {
150
+ statusUrl: 'https://status.openrouter.ai/',
151
+ apiUrl: 'https://openrouter.ai/api/v1/models',
152
+ headers: {
153
+ Authorization: 'Bearer $OPEN_ROUTER_API_KEY',
154
+ },
155
+ testModel: 'anthropic/claude-3-sonnet',
156
+ },
157
+ XAI: {
158
+ statusUrl: 'https://status.x.ai/',
159
+ apiUrl: 'https://api.x.ai/v1/models',
160
+ headers: {
161
+ Authorization: 'Bearer $XAI_API_KEY',
162
+ },
163
+ testModel: 'grok-1',
164
+ },
165
+ Deepseek: {
166
+ statusUrl: 'https://status.deepseek.com/',
167
+ apiUrl: 'https://api.deepseek.com/v1/models',
168
+ headers: {
169
+ Authorization: 'Bearer $DEEPSEEK_API_KEY',
170
+ },
171
+ testModel: 'deepseek-chat',
172
+ },
173
+ };
174
+
175
+ const PROVIDER_ICONS: Record<ProviderName, IconType> = {
176
+ AmazonBedrock: SiAmazon,
177
+ Anthropic: FaBrain,
178
+ Cohere: BiChip,
179
+ Google: SiGoogle,
180
+ Groq: BsCloud,
181
+ HuggingFace: SiHuggingface,
182
+ Mistral: TbBrain,
183
+ OpenAI: SiOpenai,
184
+ OpenRouter: FaCloud,
185
+ Perplexity: SiPerplexity,
186
+ Together: BsCloud,
187
+ XAI: BsRobot,
188
+ Deepseek: BiCodeBlock,
189
+ };
190
+
191
+ const ServiceStatusTab = () => {
192
+ const [serviceStatuses, setServiceStatuses] = useState<ServiceStatus[]>([]);
193
+ const [loading, setLoading] = useState(true);
194
+ const [lastRefresh, setLastRefresh] = useState<Date>(new Date());
195
+ const [testApiKey, setTestApiKey] = useState<string>('');
196
+ const [testProvider, setTestProvider] = useState<ProviderName | ''>('');
197
+ const [testingStatus, setTestingStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle');
198
+ const settings = useSettings();
199
+ const { success, error } = useToast();
200
+
201
+ // Function to get the API key for a provider from environment variables
202
+ const getApiKey = useCallback(
203
+ (provider: ProviderName): string | null => {
204
+ if (!settings.providers) {
205
+ return null;
206
+ }
207
+
208
+ // Map provider names to environment variable names
209
+ const envKeyMap: Record<ProviderName, string> = {
210
+ OpenAI: 'OPENAI_API_KEY',
211
+ Anthropic: 'ANTHROPIC_API_KEY',
212
+ Cohere: 'COHERE_API_KEY',
213
+ Google: 'GOOGLE_GENERATIVE_AI_API_KEY',
214
+ HuggingFace: 'HuggingFace_API_KEY',
215
+ Mistral: 'MISTRAL_API_KEY',
216
+ Perplexity: 'PERPLEXITY_API_KEY',
217
+ Together: 'TOGETHER_API_KEY',
218
+ AmazonBedrock: 'AWS_BEDROCK_CONFIG',
219
+ Groq: 'GROQ_API_KEY',
220
+ OpenRouter: 'OPEN_ROUTER_API_KEY',
221
+ XAI: 'XAI_API_KEY',
222
+ Deepseek: 'DEEPSEEK_API_KEY',
223
+ };
224
+
225
+ const envKey = envKeyMap[provider];
226
+
227
+ if (!envKey) {
228
+ return null;
229
+ }
230
+
231
+ // Get the API key from environment variables
232
+ const apiKey = (import.meta.env[envKey] as string) || null;
233
+
234
+ // Special handling for providers with base URLs
235
+ if (provider === 'Together' && apiKey) {
236
+ const baseUrl = import.meta.env.TOGETHER_API_BASE_URL;
237
+
238
+ if (!baseUrl) {
239
+ return null;
240
+ }
241
+ }
242
+
243
+ return apiKey;
244
+ },
245
+ [settings.providers],
246
+ );
247
+
248
+ // Update provider configurations based on available API keys
249
+ const getProviderConfig = useCallback((provider: ProviderName): ProviderConfig | null => {
250
+ const config = PROVIDER_STATUS_URLS[provider];
251
+
252
+ if (!config) {
253
+ return null;
254
+ }
255
+
256
+ // Handle special cases for providers with base URLs
257
+ let updatedConfig = { ...config };
258
+ const togetherBaseUrl = import.meta.env.TOGETHER_API_BASE_URL;
259
+
260
+ if (provider === 'Together' && togetherBaseUrl) {
261
+ updatedConfig = {
262
+ ...config,
263
+ apiUrl: `${togetherBaseUrl}/models`,
264
+ };
265
+ }
266
+
267
+ return updatedConfig;
268
+ }, []);
269
+
270
+ // Function to check if an API endpoint is accessible with model verification
271
+ const checkApiEndpoint = useCallback(
272
+ async (
273
+ url: string,
274
+ headers?: Record<string, string>,
275
+ testModel?: string,
276
+ ): Promise<{ ok: boolean; status: number | string; message?: string; responseTime: number }> => {
277
+ try {
278
+ const controller = new AbortController();
279
+ const timeoutId = setTimeout(() => controller.abort(), 10000);
280
+
281
+ const startTime = performance.now();
282
+
283
+ // Add common headers
284
+ const processedHeaders = {
285
+ 'Content-Type': 'application/json',
286
+ ...headers,
287
+ };
288
+
289
+ // First check if the API is accessible
290
+ const response = await fetch(url, {
291
+ method: 'GET',
292
+ headers: processedHeaders,
293
+ signal: controller.signal,
294
+ });
295
+
296
+ const endTime = performance.now();
297
+ const responseTime = endTime - startTime;
298
+
299
+ clearTimeout(timeoutId);
300
+
301
+ // Get response data
302
+ const data = (await response.json()) as ApiResponse;
303
+
304
+ // Special handling for different provider responses
305
+ if (!response.ok) {
306
+ let errorMessage = `API returned status: ${response.status}`;
307
+
308
+ // Handle provider-specific error messages
309
+ if (data.error?.message) {
310
+ errorMessage = data.error.message;
311
+ } else if (data.message) {
312
+ errorMessage = data.message;
313
+ }
314
+
315
+ return {
316
+ ok: false,
317
+ status: response.status,
318
+ message: errorMessage,
319
+ responseTime,
320
+ };
321
+ }
322
+
323
+ // Different providers have different model list formats
324
+ let models: string[] = [];
325
+
326
+ if (Array.isArray(data)) {
327
+ models = data.map((model: { id?: string; name?: string }) => model.id || model.name || '');
328
+ } else if (data.data && Array.isArray(data.data)) {
329
+ models = data.data.map((model) => model.id || model.name || '');
330
+ } else if (data.models && Array.isArray(data.models)) {
331
+ models = data.models.map((model) => model.id || model.name || '');
332
+ } else if (data.model) {
333
+ // Some providers return single model info
334
+ models = [data.model];
335
+ }
336
+
337
+ // For some providers, just having a successful response is enough
338
+ if (!testModel || models.length > 0) {
339
+ return {
340
+ ok: true,
341
+ status: response.status,
342
+ responseTime,
343
+ message: 'API key is valid',
344
+ };
345
+ }
346
+
347
+ // If a specific model was requested, verify it exists
348
+ if (testModel && !models.includes(testModel)) {
349
+ return {
350
+ ok: true, // Still mark as ok since API works
351
+ status: 'model_not_found',
352
+ message: `API key is valid (test model ${testModel} not found in ${models.length} available models)`,
353
+ responseTime,
354
+ };
355
+ }
356
+
357
+ return {
358
+ ok: true,
359
+ status: response.status,
360
+ message: 'API key is valid',
361
+ responseTime,
362
+ };
363
+ } catch (error) {
364
+ console.error(`Error checking API endpoint ${url}:`, error);
365
+ return {
366
+ ok: false,
367
+ status: error instanceof Error ? error.message : 'Unknown error',
368
+ message: error instanceof Error ? `Connection failed: ${error.message}` : 'Connection failed',
369
+ responseTime: 0,
370
+ };
371
+ }
372
+ },
373
+ [getApiKey],
374
+ );
375
+
376
+ // Function to fetch real status from provider status pages
377
+ const fetchPublicStatus = useCallback(
378
+ async (
379
+ provider: ProviderName,
380
+ ): Promise<{
381
+ status: ServiceStatus['status'];
382
+ message?: string;
383
+ incidents?: string[];
384
+ }> => {
385
+ try {
386
+ // Due to CORS restrictions, we can only check if the endpoints are reachable
387
+ const checkEndpoint = async (url: string) => {
388
+ try {
389
+ const response = await fetch(url, {
390
+ mode: 'no-cors',
391
+ headers: {
392
+ Accept: 'text/html',
393
+ },
394
+ });
395
+
396
+ // With no-cors, we can only know if the request succeeded
397
+ return response.type === 'opaque' ? 'reachable' : 'unreachable';
398
+ } catch (error) {
399
+ console.error(`Error checking ${url}:`, error);
400
+ return 'unreachable';
401
+ }
402
+ };
403
+
404
+ switch (provider) {
405
+ case 'HuggingFace': {
406
+ const endpointStatus = await checkEndpoint('https://status.huggingface.co/');
407
+
408
+ // Check API endpoint as fallback
409
+ const apiEndpoint = 'https://api-inference.huggingface.co/models';
410
+ const apiStatus = await checkEndpoint(apiEndpoint);
411
+
412
+ return {
413
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
414
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
415
+ incidents: ['Note: Limited status information due to CORS restrictions'],
416
+ };
417
+ }
418
+
419
+ case 'OpenAI': {
420
+ const endpointStatus = await checkEndpoint('https://status.openai.com/');
421
+ const apiEndpoint = 'https://api.openai.com/v1/models';
422
+ const apiStatus = await checkEndpoint(apiEndpoint);
423
+
424
+ return {
425
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
426
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
427
+ incidents: ['Note: Limited status information due to CORS restrictions'],
428
+ };
429
+ }
430
+
431
+ case 'Google': {
432
+ const endpointStatus = await checkEndpoint('https://status.cloud.google.com/');
433
+ const apiEndpoint = 'https://generativelanguage.googleapis.com/v1/models';
434
+ const apiStatus = await checkEndpoint(apiEndpoint);
435
+
436
+ return {
437
+ status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded',
438
+ message: `Status page: ${endpointStatus}, API: ${apiStatus}`,
439
+ incidents: ['Note: Limited status information due to CORS restrictions'],
440
+ };
441
+ }
442
+
443
+ // Similar pattern for other providers...
444
+ default:
445
+ return {
446
+ status: 'operational',
447
+ message: 'Basic reachability check only',
448
+ incidents: ['Note: Limited status information due to CORS restrictions'],
449
+ };
450
+ }
451
+ } catch (error) {
452
+ console.error(`Error fetching status for ${provider}:`, error);
453
+ return {
454
+ status: 'degraded',
455
+ message: 'Unable to fetch status due to CORS restrictions',
456
+ incidents: ['Error: Unable to check service status'],
457
+ };
458
+ }
459
+ },
460
+ [],
461
+ );
462
+
463
+ // Function to fetch status for a provider with retries
464
+ const fetchProviderStatus = useCallback(
465
+ async (provider: ProviderName, config: ProviderConfig): Promise<ServiceStatus> => {
466
+ const MAX_RETRIES = 2;
467
+ const RETRY_DELAY = 2000; // 2 seconds
468
+
469
+ const attemptCheck = async (attempt: number): Promise<ServiceStatus> => {
470
+ try {
471
+ // First check the public status page if available
472
+ const hasPublicStatus = [
473
+ 'Anthropic',
474
+ 'OpenAI',
475
+ 'Google',
476
+ 'HuggingFace',
477
+ 'Mistral',
478
+ 'Groq',
479
+ 'Perplexity',
480
+ 'Together',
481
+ ].includes(provider);
482
+
483
+ if (hasPublicStatus) {
484
+ const publicStatus = await fetchPublicStatus(provider);
485
+
486
+ return {
487
+ provider,
488
+ status: publicStatus.status,
489
+ lastChecked: new Date().toISOString(),
490
+ statusUrl: config.statusUrl,
491
+ icon: PROVIDER_ICONS[provider],
492
+ message: publicStatus.message,
493
+ incidents: publicStatus.incidents,
494
+ };
495
+ }
496
+
497
+ // For other providers, we'll show status but mark API check as separate
498
+ const apiKey = getApiKey(provider);
499
+ const providerConfig = getProviderConfig(provider);
500
+
501
+ if (!apiKey || !providerConfig) {
502
+ return {
503
+ provider,
504
+ status: 'operational',
505
+ lastChecked: new Date().toISOString(),
506
+ statusUrl: config.statusUrl,
507
+ icon: PROVIDER_ICONS[provider],
508
+ message: !apiKey
509
+ ? 'Status operational (API key needed for usage)'
510
+ : 'Status operational (configuration needed for usage)',
511
+ incidents: [],
512
+ };
513
+ }
514
+
515
+ // If we have API access, let's verify that too
516
+ const { ok, status, message, responseTime } = await checkApiEndpoint(
517
+ providerConfig.apiUrl,
518
+ providerConfig.headers,
519
+ providerConfig.testModel,
520
+ );
521
+
522
+ if (!ok && attempt < MAX_RETRIES) {
523
+ await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY));
524
+ return attemptCheck(attempt + 1);
525
+ }
526
+
527
+ return {
528
+ provider,
529
+ status: ok ? 'operational' : 'degraded',
530
+ lastChecked: new Date().toISOString(),
531
+ statusUrl: providerConfig.statusUrl,
532
+ icon: PROVIDER_ICONS[provider],
533
+ message: ok ? 'Service and API operational' : `Service operational (API: ${message || status})`,
534
+ responseTime,
535
+ incidents: [],
536
+ };
537
+ } catch (error) {
538
+ console.error(`Error fetching status for ${provider} (attempt ${attempt}):`, error);
539
+
540
+ if (attempt < MAX_RETRIES) {
541
+ await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY));
542
+ return attemptCheck(attempt + 1);
543
+ }
544
+
545
+ return {
546
+ provider,
547
+ status: 'degraded',
548
+ lastChecked: new Date().toISOString(),
549
+ statusUrl: config.statusUrl,
550
+ icon: PROVIDER_ICONS[provider],
551
+ message: 'Service operational (Status check error)',
552
+ responseTime: 0,
553
+ incidents: [],
554
+ };
555
+ }
556
+ };
557
+
558
+ return attemptCheck(1);
559
+ },
560
+ [checkApiEndpoint, getApiKey, getProviderConfig, fetchPublicStatus],
561
+ );
562
+
563
+ // Memoize the fetchAllStatuses function
564
+ const fetchAllStatuses = useCallback(async () => {
565
+ try {
566
+ setLoading(true);
567
+
568
+ const statuses = await Promise.all(
569
+ Object.entries(PROVIDER_STATUS_URLS).map(([provider, config]) =>
570
+ fetchProviderStatus(provider as ProviderName, config),
571
+ ),
572
+ );
573
+
574
+ setServiceStatuses(statuses.sort((a, b) => a.provider.localeCompare(b.provider)));
575
+ setLastRefresh(new Date());
576
+ success('Service statuses updated successfully');
577
+ } catch (err) {
578
+ console.error('Error fetching all statuses:', err);
579
+ error('Failed to update service statuses');
580
+ } finally {
581
+ setLoading(false);
582
+ }
583
+ }, [fetchProviderStatus, success, error]);
584
+
585
+ useEffect(() => {
586
+ fetchAllStatuses();
587
+
588
+ // Refresh status every 2 minutes
589
+ const interval = setInterval(fetchAllStatuses, 2 * 60 * 1000);
590
+
591
+ return () => clearInterval(interval);
592
+ }, [fetchAllStatuses]);
593
+
594
+ // Function to test an API key
595
+ const testApiKeyForProvider = useCallback(
596
+ async (provider: ProviderName, apiKey: string) => {
597
+ try {
598
+ setTestingStatus('testing');
599
+
600
+ const config = PROVIDER_STATUS_URLS[provider];
601
+
602
+ if (!config) {
603
+ throw new Error('Provider configuration not found');
604
+ }
605
+
606
+ const headers = { ...config.headers };
607
+
608
+ // Replace the placeholder API key with the test key
609
+ Object.keys(headers).forEach((key) => {
610
+ if (headers[key].startsWith('$')) {
611
+ headers[key] = headers[key].replace(/\$.*/, apiKey);
612
+ }
613
+ });
614
+
615
+ // Special handling for certain providers
616
+ switch (provider) {
617
+ case 'Anthropic':
618
+ headers['anthropic-version'] = '2024-02-29';
619
+ break;
620
+ case 'OpenAI':
621
+ if (!headers.Authorization?.startsWith('Bearer ')) {
622
+ headers.Authorization = `Bearer ${apiKey}`;
623
+ }
624
+
625
+ break;
626
+ case 'Google': {
627
+ // Google uses the API key directly in the URL
628
+ const googleUrl = `${config.apiUrl}?key=${apiKey}`;
629
+ const result = await checkApiEndpoint(googleUrl, {}, config.testModel);
630
+
631
+ if (result.ok) {
632
+ setTestingStatus('success');
633
+ success('API key is valid!');
634
+ } else {
635
+ setTestingStatus('error');
636
+ error(`API key test failed: ${result.message}`);
637
+ }
638
+
639
+ return;
640
+ }
641
+ }
642
+
643
+ const { ok, message } = await checkApiEndpoint(config.apiUrl, headers, config.testModel);
644
+
645
+ if (ok) {
646
+ setTestingStatus('success');
647
+ success('API key is valid!');
648
+ } else {
649
+ setTestingStatus('error');
650
+ error(`API key test failed: ${message}`);
651
+ }
652
+ } catch (err: unknown) {
653
+ setTestingStatus('error');
654
+ error('Failed to test API key: ' + (err instanceof Error ? err.message : 'Unknown error'));
655
+ } finally {
656
+ // Reset testing status after a delay
657
+ setTimeout(() => setTestingStatus('idle'), 3000);
658
+ }
659
+ },
660
+ [checkApiEndpoint, success, error],
661
+ );
662
+
663
+ const getStatusColor = (status: ServiceStatus['status']) => {
664
+ switch (status) {
665
+ case 'operational':
666
+ return 'text-green-500';
667
+ case 'degraded':
668
+ return 'text-yellow-500';
669
+ case 'down':
670
+ return 'text-red-500';
671
+ default:
672
+ return 'text-gray-500';
673
+ }
674
+ };
675
+
676
+ const getStatusIcon = (status: ServiceStatus['status']) => {
677
+ switch (status) {
678
+ case 'operational':
679
+ return <BsCheckCircleFill className="w-4 h-4" />;
680
+ case 'degraded':
681
+ return <BsExclamationCircleFill className="w-4 h-4" />;
682
+ case 'down':
683
+ return <BsXCircleFill className="w-4 h-4" />;
684
+ default:
685
+ return <BsXCircleFill className="w-4 h-4" />;
686
+ }
687
+ };
688
+
689
+ return (
690
+ <div className="space-y-6">
691
+ <motion.div
692
+ className="space-y-4"
693
+ initial={{ opacity: 0, y: 20 }}
694
+ animate={{ opacity: 1, y: 0 }}
695
+ transition={{ duration: 0.3 }}
696
+ >
697
+ <div className="flex items-center justify-between gap-2 mt-8 mb-4">
698
+ <div className="flex items-center gap-2">
699
+ <div
700
+ className={classNames(
701
+ 'w-8 h-8 flex items-center justify-center rounded-lg',
702
+ 'bg-bolt-elements-background-depth-3',
703
+ 'text-purple-500',
704
+ )}
705
+ >
706
+ <TbActivityHeartbeat className="w-5 h-5" />
707
+ </div>
708
+ <div>
709
+ <h4 className="text-md font-medium text-bolt-elements-textPrimary">Service Status</h4>
710
+ <p className="text-sm text-bolt-elements-textSecondary">
711
+ Monitor and test the operational status of cloud LLM providers
712
+ </p>
713
+ </div>
714
+ </div>
715
+ <div className="flex items-center gap-2">
716
+ <span className="text-sm text-bolt-elements-textSecondary">
717
+ Last updated: {lastRefresh.toLocaleTimeString()}
718
+ </span>
719
+ <button
720
+ onClick={() => fetchAllStatuses()}
721
+ className={classNames(
722
+ 'px-3 py-1.5 rounded-lg text-sm',
723
+ 'bg-bolt-elements-background-depth-3 hover:bg-bolt-elements-background-depth-4',
724
+ 'text-bolt-elements-textPrimary',
725
+ 'transition-all duration-200',
726
+ 'flex items-center gap-2',
727
+ loading ? 'opacity-50 cursor-not-allowed' : '',
728
+ )}
729
+ disabled={loading}
730
+ >
731
+ <div className={`i-ph:arrows-clockwise w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
732
+ <span>{loading ? 'Refreshing...' : 'Refresh'}</span>
733
+ </button>
734
+ </div>
735
+ </div>
736
+
737
+ {/* API Key Test Section */}
738
+ <div className="p-4 bg-bolt-elements-background-depth-2 rounded-lg">
739
+ <h5 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Test API Key</h5>
740
+ <div className="flex gap-2">
741
+ <select
742
+ value={testProvider}
743
+ onChange={(e) => setTestProvider(e.target.value as ProviderName)}
744
+ className={classNames(
745
+ 'flex-1 px-3 py-1.5 rounded-lg text-sm max-w-[200px]',
746
+ 'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
747
+ 'text-bolt-elements-textPrimary',
748
+ 'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
749
+ )}
750
+ >
751
+ <option value="">Select Provider</option>
752
+ {Object.keys(PROVIDER_STATUS_URLS).map((provider) => (
753
+ <option key={provider} value={provider}>
754
+ {provider}
755
+ </option>
756
+ ))}
757
+ </select>
758
+ <input
759
+ type="password"
760
+ value={testApiKey}
761
+ onChange={(e) => setTestApiKey(e.target.value)}
762
+ placeholder="Enter API key to test"
763
+ className={classNames(
764
+ 'flex-1 px-3 py-1.5 rounded-lg text-sm',
765
+ 'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
766
+ 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
767
+ 'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
768
+ )}
769
+ />
770
+ <button
771
+ onClick={() =>
772
+ testProvider && testApiKey && testApiKeyForProvider(testProvider as ProviderName, testApiKey)
773
+ }
774
+ disabled={!testProvider || !testApiKey || testingStatus === 'testing'}
775
+ className={classNames(
776
+ 'px-4 py-1.5 rounded-lg text-sm',
777
+ 'bg-purple-500 hover:bg-purple-600',
778
+ 'text-white',
779
+ 'transition-all duration-200',
780
+ 'flex items-center gap-2',
781
+ !testProvider || !testApiKey || testingStatus === 'testing' ? 'opacity-50 cursor-not-allowed' : '',
782
+ )}
783
+ >
784
+ {testingStatus === 'testing' ? (
785
+ <>
786
+ <div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
787
+ <span>Testing...</span>
788
+ </>
789
+ ) : (
790
+ <>
791
+ <div className="i-ph:key w-4 h-4" />
792
+ <span>Test Key</span>
793
+ </>
794
+ )}
795
+ </button>
796
+ </div>
797
+ </div>
798
+
799
+ {/* Status Grid */}
800
+ {loading && serviceStatuses.length === 0 ? (
801
+ <div className="text-center py-8 text-bolt-elements-textSecondary">Loading service statuses...</div>
802
+ ) : (
803
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
804
+ {serviceStatuses.map((service, index) => (
805
+ <motion.div
806
+ key={service.provider}
807
+ className={classNames(
808
+ 'bg-bolt-elements-background-depth-2',
809
+ 'hover:bg-bolt-elements-background-depth-3',
810
+ 'transition-all duration-200',
811
+ 'relative overflow-hidden rounded-lg',
812
+ )}
813
+ initial={{ opacity: 0, y: 20 }}
814
+ animate={{ opacity: 1, y: 0 }}
815
+ transition={{ delay: index * 0.1 }}
816
+ whileHover={{ scale: 1.02 }}
817
+ >
818
+ <div
819
+ className={classNames('block p-4', service.statusUrl ? 'cursor-pointer' : '')}
820
+ onClick={() => service.statusUrl && window.open(service.statusUrl, '_blank')}
821
+ >
822
+ <div className="flex items-center justify-between gap-4">
823
+ <div className="flex items-center gap-3">
824
+ {service.icon && (
825
+ <div
826
+ className={classNames(
827
+ 'w-8 h-8 flex items-center justify-center rounded-lg',
828
+ 'bg-bolt-elements-background-depth-3',
829
+ getStatusColor(service.status),
830
+ )}
831
+ >
832
+ {React.createElement(service.icon, {
833
+ className: 'w-5 h-5',
834
+ })}
835
+ </div>
836
+ )}
837
+ <div>
838
+ <h4 className="text-sm font-medium text-bolt-elements-textPrimary">{service.provider}</h4>
839
+ <div className="space-y-1">
840
+ <p className="text-xs text-bolt-elements-textSecondary">
841
+ Last checked: {new Date(service.lastChecked).toLocaleTimeString()}
842
+ </p>
843
+ {service.responseTime && (
844
+ <p className="text-xs text-bolt-elements-textTertiary">
845
+ Response time: {Math.round(service.responseTime)}ms
846
+ </p>
847
+ )}
848
+ {service.message && (
849
+ <p className="text-xs text-bolt-elements-textTertiary">{service.message}</p>
850
+ )}
851
+ </div>
852
+ </div>
853
+ </div>
854
+ <div className={classNames('flex items-center gap-2', getStatusColor(service.status))}>
855
+ <span className="text-sm capitalize">{service.status}</span>
856
+ {getStatusIcon(service.status)}
857
+ </div>
858
+ </div>
859
+ {service.incidents && service.incidents.length > 0 && (
860
+ <div className="mt-2 border-t border-bolt-elements-borderColor pt-2">
861
+ <p className="text-xs font-medium text-bolt-elements-textSecondary mb-1">Recent Incidents:</p>
862
+ <ul className="text-xs text-bolt-elements-textTertiary space-y-1">
863
+ {service.incidents.map((incident, i) => (
864
+ <li key={i}>{incident}</li>
865
+ ))}
866
+ </ul>
867
+ </div>
868
+ )}
869
+ </div>
870
+ </motion.div>
871
+ ))}
872
+ </div>
873
+ )}
874
+ </motion.div>
875
+ </div>
876
+ );
877
+ };
878
+
879
+ // Add tab metadata
880
+ ServiceStatusTab.tabMetadata = {
881
+ icon: 'i-ph:activity-bold',
882
+ description: 'Monitor and test LLM provider service status',
883
+ category: 'services',
884
+ };
885
+
886
+ export default ServiceStatusTab;
app/components/@settings/tabs/settings/SettingsTab.tsx ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react';
2
+ import { motion } from 'framer-motion';
3
+ import { toast } from 'react-toastify';
4
+ import { classNames } from '~/utils/classNames';
5
+ import { Switch } from '~/components/ui/Switch';
6
+ import type { UserProfile } from '~/components/@settings/core/types';
7
+ import { isMac } from '~/utils/os';
8
+
9
+ // Helper to get modifier key symbols/text
10
+ const getModifierSymbol = (modifier: string): string => {
11
+ switch (modifier) {
12
+ case 'meta':
13
+ return isMac ? '⌘' : 'Win';
14
+ case 'alt':
15
+ return isMac ? '⌥' : 'Alt';
16
+ case 'shift':
17
+ return '⇧';
18
+ default:
19
+ return modifier;
20
+ }
21
+ };
22
+
23
+ export default function SettingsTab() {
24
+ const [currentTimezone, setCurrentTimezone] = useState('');
25
+ const [settings, setSettings] = useState<UserProfile>(() => {
26
+ const saved = localStorage.getItem('bolt_user_profile');
27
+ return saved
28
+ ? JSON.parse(saved)
29
+ : {
30
+ notifications: true,
31
+ language: 'en',
32
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
33
+ };
34
+ });
35
+
36
+ useEffect(() => {
37
+ setCurrentTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
38
+ }, []);
39
+
40
+ // Save settings automatically when they change
41
+ useEffect(() => {
42
+ try {
43
+ // Get existing profile data
44
+ const existingProfile = JSON.parse(localStorage.getItem('bolt_user_profile') || '{}');
45
+
46
+ // Merge with new settings
47
+ const updatedProfile = {
48
+ ...existingProfile,
49
+ notifications: settings.notifications,
50
+ language: settings.language,
51
+ timezone: settings.timezone,
52
+ };
53
+
54
+ localStorage.setItem('bolt_user_profile', JSON.stringify(updatedProfile));
55
+ toast.success('Settings updated');
56
+ } catch (error) {
57
+ console.error('Error saving settings:', error);
58
+ toast.error('Failed to update settings');
59
+ }
60
+ }, [settings]);
61
+
62
+ return (
63
+ <div className="space-y-4">
64
+ {/* Language & Notifications */}
65
+ <motion.div
66
+ className="bg-white dark:bg-[#0A0A0A] rounded-lg shadow-sm dark:shadow-none p-4 space-y-4"
67
+ initial={{ opacity: 0, y: 20 }}
68
+ animate={{ opacity: 1, y: 0 }}
69
+ transition={{ delay: 0.1 }}
70
+ >
71
+ <div className="flex items-center gap-2 mb-4">
72
+ <div className="i-ph:palette-fill w-4 h-4 text-purple-500" />
73
+ <span className="text-sm font-medium text-bolt-elements-textPrimary">Preferences</span>
74
+ </div>
75
+
76
+ <div>
77
+ <div className="flex items-center gap-2 mb-2">
78
+ <div className="i-ph:translate-fill w-4 h-4 text-bolt-elements-textSecondary" />
79
+ <label className="block text-sm text-bolt-elements-textSecondary">Language</label>
80
+ </div>
81
+ <select
82
+ value={settings.language}
83
+ onChange={(e) => setSettings((prev) => ({ ...prev, language: e.target.value }))}
84
+ className={classNames(
85
+ 'w-full px-3 py-2 rounded-lg text-sm',
86
+ 'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
87
+ 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
88
+ 'text-bolt-elements-textPrimary',
89
+ 'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
90
+ 'transition-all duration-200',
91
+ )}
92
+ >
93
+ <option value="en">English</option>
94
+ <option value="es">Español</option>
95
+ <option value="fr">Français</option>
96
+ <option value="de">Deutsch</option>
97
+ <option value="it">Italiano</option>
98
+ <option value="pt">Português</option>
99
+ <option value="ru">Русский</option>
100
+ <option value="zh">中文</option>
101
+ <option value="ja">日本語</option>
102
+ <option value="ko">한국어</option>
103
+ </select>
104
+ </div>
105
+
106
+ <div>
107
+ <div className="flex items-center gap-2 mb-2">
108
+ <div className="i-ph:bell-fill w-4 h-4 text-bolt-elements-textSecondary" />
109
+ <label className="block text-sm text-bolt-elements-textSecondary">Notifications</label>
110
+ </div>
111
+ <div className="flex items-center justify-between">
112
+ <span className="text-sm text-bolt-elements-textSecondary">
113
+ {settings.notifications ? 'Notifications are enabled' : 'Notifications are disabled'}
114
+ </span>
115
+ <Switch
116
+ checked={settings.notifications}
117
+ onCheckedChange={(checked) => {
118
+ // Update local state
119
+ setSettings((prev) => ({ ...prev, notifications: checked }));
120
+
121
+ // Update localStorage immediately
122
+ const existingProfile = JSON.parse(localStorage.getItem('bolt_user_profile') || '{}');
123
+ const updatedProfile = {
124
+ ...existingProfile,
125
+ notifications: checked,
126
+ };
127
+ localStorage.setItem('bolt_user_profile', JSON.stringify(updatedProfile));
128
+
129
+ // Dispatch storage event for other components
130
+ window.dispatchEvent(
131
+ new StorageEvent('storage', {
132
+ key: 'bolt_user_profile',
133
+ newValue: JSON.stringify(updatedProfile),
134
+ }),
135
+ );
136
+
137
+ toast.success(`Notifications ${checked ? 'enabled' : 'disabled'}`);
138
+ }}
139
+ />
140
+ </div>
141
+ </div>
142
+ </motion.div>
143
+
144
+ {/* Timezone */}
145
+ <motion.div
146
+ className="bg-white dark:bg-[#0A0A0A] rounded-lg shadow-sm dark:shadow-none p-4"
147
+ initial={{ opacity: 0, y: 20 }}
148
+ animate={{ opacity: 1, y: 0 }}
149
+ transition={{ delay: 0.2 }}
150
+ >
151
+ <div className="flex items-center gap-2 mb-4">
152
+ <div className="i-ph:clock-fill w-4 h-4 text-purple-500" />
153
+ <span className="text-sm font-medium text-bolt-elements-textPrimary">Time Settings</span>
154
+ </div>
155
+
156
+ <div>
157
+ <div className="flex items-center gap-2 mb-2">
158
+ <div className="i-ph:globe-fill w-4 h-4 text-bolt-elements-textSecondary" />
159
+ <label className="block text-sm text-bolt-elements-textSecondary">Timezone</label>
160
+ </div>
161
+ <select
162
+ value={settings.timezone}
163
+ onChange={(e) => setSettings((prev) => ({ ...prev, timezone: e.target.value }))}
164
+ className={classNames(
165
+ 'w-full px-3 py-2 rounded-lg text-sm',
166
+ 'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
167
+ 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
168
+ 'text-bolt-elements-textPrimary',
169
+ 'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
170
+ 'transition-all duration-200',
171
+ )}
172
+ >
173
+ <option value={currentTimezone}>{currentTimezone}</option>
174
+ </select>
175
+ </div>
176
+ </motion.div>
177
+
178
+ {/* Simplified Keyboard Shortcuts */}
179
+ <motion.div
180
+ className="bg-white dark:bg-[#0A0A0A] rounded-lg shadow-sm dark:shadow-none p-4"
181
+ initial={{ opacity: 0, y: 20 }}
182
+ animate={{ opacity: 1, y: 0 }}
183
+ transition={{ delay: 0.3 }}
184
+ >
185
+ <div className="flex items-center gap-2 mb-4">
186
+ <div className="i-ph:keyboard-fill w-4 h-4 text-purple-500" />
187
+ <span className="text-sm font-medium text-bolt-elements-textPrimary">Keyboard Shortcuts</span>
188
+ </div>
189
+
190
+ <div className="space-y-2">
191
+ <div className="flex items-center justify-between p-2 rounded-lg bg-[#FAFAFA] dark:bg-[#1A1A1A]">
192
+ <div className="flex flex-col">
193
+ <span className="text-sm text-bolt-elements-textPrimary">Toggle Theme</span>
194
+ <span className="text-xs text-bolt-elements-textSecondary">Switch between light and dark mode</span>
195
+ </div>
196
+ <div className="flex items-center gap-1">
197
+ <kbd className="px-2 py-1 text-xs font-semibold text-bolt-elements-textSecondary bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] rounded shadow-sm">
198
+ {getModifierSymbol('meta')}
199
+ </kbd>
200
+ <kbd className="px-2 py-1 text-xs font-semibold text-bolt-elements-textSecondary bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] rounded shadow-sm">
201
+ {getModifierSymbol('alt')}
202
+ </kbd>
203
+ <kbd className="px-2 py-1 text-xs font-semibold text-bolt-elements-textSecondary bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] rounded shadow-sm">
204
+ {getModifierSymbol('shift')}
205
+ </kbd>
206
+ <kbd className="px-2 py-1 text-xs font-semibold text-bolt-elements-textSecondary bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] rounded shadow-sm">
207
+ D
208
+ </kbd>
209
+ </div>
210
+ </div>
211
+ </div>
212
+ </motion.div>
213
+ </div>
214
+ );
215
+ }
app/components/@settings/tabs/task-manager/TaskManagerTab.tsx ADDED
@@ -0,0 +1,1265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from 'react';
2
+ import { useEffect, useState, useRef, useCallback } from 'react';
3
+ import { classNames } from '~/utils/classNames';
4
+ import { Line } from 'react-chartjs-2';
5
+ import {
6
+ Chart as ChartJS,
7
+ CategoryScale,
8
+ LinearScale,
9
+ PointElement,
10
+ LineElement,
11
+ Title,
12
+ Tooltip,
13
+ Legend,
14
+ } from 'chart.js';
15
+ import { toast } from 'react-toastify'; // Import toast
16
+ import { useUpdateCheck } from '~/lib/hooks/useUpdateCheck';
17
+ import { tabConfigurationStore, type TabConfig } from '~/lib/stores/tabConfigurationStore';
18
+ import { useStore } from 'zustand';
19
+
20
+ // Register ChartJS components
21
+ ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend);
22
+
23
+ interface BatteryManager extends EventTarget {
24
+ charging: boolean;
25
+ chargingTime: number;
26
+ dischargingTime: number;
27
+ level: number;
28
+ }
29
+
30
+ interface SystemMetrics {
31
+ cpu: {
32
+ usage: number;
33
+ cores: number[];
34
+ temperature?: number;
35
+ frequency?: number;
36
+ };
37
+ memory: {
38
+ used: number;
39
+ total: number;
40
+ percentage: number;
41
+ heap: {
42
+ used: number;
43
+ total: number;
44
+ limit: number;
45
+ };
46
+ cache?: number;
47
+ };
48
+ uptime: number;
49
+ battery?: {
50
+ level: number;
51
+ charging: boolean;
52
+ timeRemaining?: number;
53
+ temperature?: number;
54
+ cycles?: number;
55
+ health?: number;
56
+ };
57
+ network: {
58
+ downlink: number;
59
+ uplink?: number;
60
+ latency: number;
61
+ type: string;
62
+ activeConnections?: number;
63
+ bytesReceived: number;
64
+ bytesSent: number;
65
+ };
66
+ performance: {
67
+ fps: number;
68
+ pageLoad: number;
69
+ domReady: number;
70
+ resources: {
71
+ total: number;
72
+ size: number;
73
+ loadTime: number;
74
+ };
75
+ timing: {
76
+ ttfb: number;
77
+ fcp: number;
78
+ lcp: number;
79
+ };
80
+ };
81
+ health: {
82
+ score: number;
83
+ issues: string[];
84
+ suggestions: string[];
85
+ };
86
+ }
87
+
88
+ interface MetricsHistory {
89
+ timestamps: string[];
90
+ cpu: number[];
91
+ memory: number[];
92
+ battery: number[];
93
+ network: number[];
94
+ }
95
+
96
+ interface EnergySavings {
97
+ updatesReduced: number;
98
+ timeInSaverMode: number;
99
+ estimatedEnergySaved: number; // in mWh (milliwatt-hours)
100
+ }
101
+
102
+ interface PowerProfile {
103
+ name: string;
104
+ description: string;
105
+ settings: {
106
+ updateInterval: number;
107
+ enableAnimations: boolean;
108
+ backgroundProcessing: boolean;
109
+ networkThrottling: boolean;
110
+ };
111
+ }
112
+
113
+ interface PerformanceAlert {
114
+ type: 'warning' | 'error' | 'info';
115
+ message: string;
116
+ timestamp: number;
117
+ metric: string;
118
+ threshold: number;
119
+ value: number;
120
+ }
121
+
122
+ declare global {
123
+ interface Navigator {
124
+ getBattery(): Promise<BatteryManager>;
125
+ }
126
+ interface Performance {
127
+ memory?: {
128
+ jsHeapSizeLimit: number;
129
+ totalJSHeapSize: number;
130
+ usedJSHeapSize: number;
131
+ };
132
+ }
133
+ }
134
+
135
+ // Constants for update intervals
136
+ const UPDATE_INTERVALS = {
137
+ normal: {
138
+ metrics: 1000, // 1 second
139
+ animation: 16, // ~60fps
140
+ },
141
+ energySaver: {
142
+ metrics: 5000, // 5 seconds
143
+ animation: 32, // ~30fps
144
+ },
145
+ };
146
+
147
+ // Constants for performance thresholds
148
+ const PERFORMANCE_THRESHOLDS = {
149
+ cpu: {
150
+ warning: 70,
151
+ critical: 90,
152
+ },
153
+ memory: {
154
+ warning: 80,
155
+ critical: 95,
156
+ },
157
+ fps: {
158
+ warning: 30,
159
+ critical: 15,
160
+ },
161
+ };
162
+
163
+ // Constants for energy calculations
164
+ const ENERGY_COSTS = {
165
+ update: 0.1, // mWh per update
166
+ };
167
+
168
+ // Default power profiles
169
+ const POWER_PROFILES: PowerProfile[] = [
170
+ {
171
+ name: 'Performance',
172
+ description: 'Maximum performance with frequent updates',
173
+ settings: {
174
+ updateInterval: UPDATE_INTERVALS.normal.metrics,
175
+ enableAnimations: true,
176
+ backgroundProcessing: true,
177
+ networkThrottling: false,
178
+ },
179
+ },
180
+ {
181
+ name: 'Balanced',
182
+ description: 'Optimal balance between performance and energy efficiency',
183
+ settings: {
184
+ updateInterval: 2000,
185
+ enableAnimations: true,
186
+ backgroundProcessing: true,
187
+ networkThrottling: false,
188
+ },
189
+ },
190
+ {
191
+ name: 'Energy Saver',
192
+ description: 'Maximum energy efficiency with reduced updates',
193
+ settings: {
194
+ updateInterval: UPDATE_INTERVALS.energySaver.metrics,
195
+ enableAnimations: false,
196
+ backgroundProcessing: false,
197
+ networkThrottling: true,
198
+ },
199
+ },
200
+ ];
201
+
202
+ // Default metrics state
203
+ const DEFAULT_METRICS_STATE: SystemMetrics = {
204
+ cpu: {
205
+ usage: 0,
206
+ cores: [],
207
+ },
208
+ memory: {
209
+ used: 0,
210
+ total: 0,
211
+ percentage: 0,
212
+ heap: {
213
+ used: 0,
214
+ total: 0,
215
+ limit: 0,
216
+ },
217
+ },
218
+ uptime: 0,
219
+ network: {
220
+ downlink: 0,
221
+ latency: 0,
222
+ type: 'unknown',
223
+ bytesReceived: 0,
224
+ bytesSent: 0,
225
+ },
226
+ performance: {
227
+ fps: 0,
228
+ pageLoad: 0,
229
+ domReady: 0,
230
+ resources: {
231
+ total: 0,
232
+ size: 0,
233
+ loadTime: 0,
234
+ },
235
+ timing: {
236
+ ttfb: 0,
237
+ fcp: 0,
238
+ lcp: 0,
239
+ },
240
+ },
241
+ health: {
242
+ score: 0,
243
+ issues: [],
244
+ suggestions: [],
245
+ },
246
+ };
247
+
248
+ // Default metrics history
249
+ const DEFAULT_METRICS_HISTORY: MetricsHistory = {
250
+ timestamps: Array(10).fill(new Date().toLocaleTimeString()),
251
+ cpu: Array(10).fill(0),
252
+ memory: Array(10).fill(0),
253
+ battery: Array(10).fill(0),
254
+ network: Array(10).fill(0),
255
+ };
256
+
257
+ // Battery threshold for auto energy saver mode
258
+ const BATTERY_THRESHOLD = 20; // percentage
259
+
260
+ // Maximum number of history points to keep
261
+ const MAX_HISTORY_POINTS = 10;
262
+
263
+ const TaskManagerTab: React.FC = () => {
264
+ // Initialize metrics state with defaults
265
+ const [metrics, setMetrics] = useState<SystemMetrics>(() => DEFAULT_METRICS_STATE);
266
+ const [metricsHistory, setMetricsHistory] = useState<MetricsHistory>(() => DEFAULT_METRICS_HISTORY);
267
+ const [energySaverMode, setEnergySaverMode] = useState<boolean>(false);
268
+ const [autoEnergySaver, setAutoEnergySaver] = useState<boolean>(false);
269
+ const [energySavings, setEnergySavings] = useState<EnergySavings>(() => ({
270
+ updatesReduced: 0,
271
+ timeInSaverMode: 0,
272
+ estimatedEnergySaved: 0,
273
+ }));
274
+ const [selectedProfile, setSelectedProfile] = useState<PowerProfile>(() => POWER_PROFILES[1]);
275
+ const [alerts, setAlerts] = useState<PerformanceAlert[]>([]);
276
+ const saverModeStartTime = useRef<number | null>(null);
277
+
278
+ // Get update status and tab configuration
279
+ const { hasUpdate } = useUpdateCheck();
280
+ const tabConfig = useStore(tabConfigurationStore);
281
+
282
+ const resetTabConfiguration = useCallback(() => {
283
+ tabConfig.reset();
284
+ return tabConfig.get();
285
+ }, [tabConfig]);
286
+
287
+ // Effect to handle tab visibility
288
+ useEffect(() => {
289
+ const handleTabVisibility = () => {
290
+ const currentConfig = tabConfig.get();
291
+ const controlledTabs = ['debug', 'update'];
292
+
293
+ // Update visibility based on conditions
294
+ const updatedTabs = currentConfig.userTabs.map((tab: TabConfig) => {
295
+ if (controlledTabs.includes(tab.id)) {
296
+ return {
297
+ ...tab,
298
+ visible: tab.id === 'debug' ? metrics.cpu.usage > 80 : hasUpdate,
299
+ };
300
+ }
301
+
302
+ return tab;
303
+ });
304
+
305
+ tabConfig.set({
306
+ ...currentConfig,
307
+ userTabs: updatedTabs,
308
+ });
309
+ };
310
+
311
+ const checkInterval = setInterval(handleTabVisibility, 5000);
312
+
313
+ return () => {
314
+ clearInterval(checkInterval);
315
+ };
316
+ }, [metrics.cpu.usage, hasUpdate, tabConfig]);
317
+
318
+ // Effect to handle reset and initialization
319
+ useEffect(() => {
320
+ const resetToDefaults = () => {
321
+ console.log('TaskManagerTab: Resetting to defaults');
322
+
323
+ // Reset metrics and local state
324
+ setMetrics(DEFAULT_METRICS_STATE);
325
+ setMetricsHistory(DEFAULT_METRICS_HISTORY);
326
+ setEnergySaverMode(false);
327
+ setAutoEnergySaver(false);
328
+ setEnergySavings({
329
+ updatesReduced: 0,
330
+ timeInSaverMode: 0,
331
+ estimatedEnergySaved: 0,
332
+ });
333
+ setSelectedProfile(POWER_PROFILES[1]);
334
+ setAlerts([]);
335
+ saverModeStartTime.current = null;
336
+
337
+ // Reset tab configuration to ensure proper visibility
338
+ const defaultConfig = resetTabConfiguration();
339
+ console.log('TaskManagerTab: Reset tab configuration:', defaultConfig);
340
+ };
341
+
342
+ // Listen for both storage changes and custom reset event
343
+ const handleReset = (event: Event | StorageEvent) => {
344
+ if (event instanceof StorageEvent) {
345
+ if (event.key === 'tabConfiguration' && event.newValue === null) {
346
+ resetToDefaults();
347
+ }
348
+ } else if (event instanceof CustomEvent && event.type === 'tabConfigReset') {
349
+ resetToDefaults();
350
+ }
351
+ };
352
+
353
+ // Initial setup
354
+ const initializeTab = async () => {
355
+ try {
356
+ // Load saved preferences
357
+ const savedEnergySaver = localStorage.getItem('energySaverMode');
358
+ const savedAutoSaver = localStorage.getItem('autoEnergySaver');
359
+ const savedProfile = localStorage.getItem('selectedProfile');
360
+
361
+ if (savedEnergySaver) {
362
+ setEnergySaverMode(JSON.parse(savedEnergySaver));
363
+ }
364
+
365
+ if (savedAutoSaver) {
366
+ setAutoEnergySaver(JSON.parse(savedAutoSaver));
367
+ }
368
+
369
+ if (savedProfile) {
370
+ const profile = POWER_PROFILES.find((p) => p.name === savedProfile);
371
+
372
+ if (profile) {
373
+ setSelectedProfile(profile);
374
+ }
375
+ }
376
+
377
+ await updateMetrics();
378
+ } catch (error) {
379
+ console.error('Failed to initialize TaskManagerTab:', error);
380
+ resetToDefaults();
381
+ }
382
+ };
383
+
384
+ window.addEventListener('storage', handleReset);
385
+ window.addEventListener('tabConfigReset', handleReset);
386
+ initializeTab();
387
+
388
+ return () => {
389
+ window.removeEventListener('storage', handleReset);
390
+ window.removeEventListener('tabConfigReset', handleReset);
391
+ };
392
+ }, []);
393
+
394
+ // Get detailed performance metrics
395
+ const getPerformanceMetrics = async (): Promise<Partial<SystemMetrics['performance']>> => {
396
+ try {
397
+ // Get FPS
398
+ const fps = await measureFrameRate();
399
+
400
+ // Get page load metrics
401
+ const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
402
+ const pageLoad = navigation.loadEventEnd - navigation.startTime;
403
+ const domReady = navigation.domContentLoadedEventEnd - navigation.startTime;
404
+
405
+ // Get resource metrics
406
+ const resources = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
407
+ const resourceMetrics = {
408
+ total: resources.length,
409
+ size: resources.reduce((total, r) => total + (r.transferSize || 0), 0),
410
+ loadTime: Math.max(0, ...resources.map((r) => r.duration)),
411
+ };
412
+
413
+ // Get Web Vitals
414
+ const ttfb = navigation.responseStart - navigation.requestStart;
415
+ const paintEntries = performance.getEntriesByType('paint');
416
+ const fcp = paintEntries.find((entry) => entry.name === 'first-contentful-paint')?.startTime || 0;
417
+ const lcpEntry = await getLargestContentfulPaint();
418
+
419
+ return {
420
+ fps,
421
+ pageLoad,
422
+ domReady,
423
+ resources: resourceMetrics,
424
+ timing: {
425
+ ttfb,
426
+ fcp,
427
+ lcp: lcpEntry?.startTime || 0,
428
+ },
429
+ };
430
+ } catch (error) {
431
+ console.error('Failed to get performance metrics:', error);
432
+ return {};
433
+ }
434
+ };
435
+
436
+ // Single useEffect for metrics updates
437
+ useEffect(() => {
438
+ let isComponentMounted = true;
439
+
440
+ const updateMetricsWrapper = async () => {
441
+ if (!isComponentMounted) {
442
+ return;
443
+ }
444
+
445
+ try {
446
+ await updateMetrics();
447
+ } catch (error) {
448
+ console.error('Failed to update metrics:', error);
449
+ }
450
+ };
451
+
452
+ // Initial update
453
+ updateMetricsWrapper();
454
+
455
+ // Set up interval with immediate assignment
456
+ const metricsInterval = setInterval(
457
+ updateMetricsWrapper,
458
+ energySaverMode ? UPDATE_INTERVALS.energySaver.metrics : UPDATE_INTERVALS.normal.metrics,
459
+ );
460
+
461
+ // Cleanup function
462
+ return () => {
463
+ isComponentMounted = false;
464
+ clearInterval(metricsInterval);
465
+ };
466
+ }, [energySaverMode]); // Only depend on energySaverMode
467
+
468
+ // Handle energy saver mode changes
469
+ const handleEnergySaverChange = (checked: boolean) => {
470
+ setEnergySaverMode(checked);
471
+ localStorage.setItem('energySaverMode', JSON.stringify(checked));
472
+ toast.success(checked ? 'Energy Saver mode enabled' : 'Energy Saver mode disabled');
473
+ };
474
+
475
+ // Handle auto energy saver changes
476
+ const handleAutoEnergySaverChange = (checked: boolean) => {
477
+ setAutoEnergySaver(checked);
478
+ localStorage.setItem('autoEnergySaver', JSON.stringify(checked));
479
+ toast.success(checked ? 'Auto Energy Saver enabled' : 'Auto Energy Saver disabled');
480
+
481
+ if (!checked) {
482
+ // When disabling auto mode, also disable energy saver mode
483
+ setEnergySaverMode(false);
484
+ localStorage.setItem('energySaverMode', 'false');
485
+ }
486
+ };
487
+
488
+ // Update energy savings calculation
489
+ const updateEnergySavings = useCallback(() => {
490
+ if (!energySaverMode) {
491
+ saverModeStartTime.current = null;
492
+ setEnergySavings({
493
+ updatesReduced: 0,
494
+ timeInSaverMode: 0,
495
+ estimatedEnergySaved: 0,
496
+ });
497
+
498
+ return;
499
+ }
500
+
501
+ if (!saverModeStartTime.current) {
502
+ saverModeStartTime.current = Date.now();
503
+ }
504
+
505
+ const timeInSaverMode = Math.max(0, (Date.now() - (saverModeStartTime.current || Date.now())) / 1000);
506
+
507
+ const normalUpdatesPerMinute = 60 / (UPDATE_INTERVALS.normal.metrics / 1000);
508
+ const saverUpdatesPerMinute = 60 / (UPDATE_INTERVALS.energySaver.metrics / 1000);
509
+ const updatesReduced = Math.floor((normalUpdatesPerMinute - saverUpdatesPerMinute) * (timeInSaverMode / 60));
510
+
511
+ const energyPerUpdate = ENERGY_COSTS.update;
512
+ const energySaved = (updatesReduced * energyPerUpdate) / 3600;
513
+
514
+ setEnergySavings({
515
+ updatesReduced,
516
+ timeInSaverMode,
517
+ estimatedEnergySaved: energySaved,
518
+ });
519
+ }, [energySaverMode]);
520
+
521
+ // Add interval for energy savings updates
522
+ useEffect(() => {
523
+ const interval = setInterval(updateEnergySavings, 1000);
524
+ return () => clearInterval(interval);
525
+ }, [updateEnergySavings]);
526
+
527
+ // Measure frame rate
528
+ const measureFrameRate = async (): Promise<number> => {
529
+ return new Promise((resolve) => {
530
+ const frameCount = { value: 0 };
531
+ const startTime = performance.now();
532
+
533
+ const countFrame = (time: number) => {
534
+ frameCount.value++;
535
+
536
+ if (time - startTime >= 1000) {
537
+ resolve(Math.round((frameCount.value * 1000) / (time - startTime)));
538
+ } else {
539
+ requestAnimationFrame(countFrame);
540
+ }
541
+ };
542
+
543
+ requestAnimationFrame(countFrame);
544
+ });
545
+ };
546
+
547
+ // Get Largest Contentful Paint
548
+ const getLargestContentfulPaint = async (): Promise<PerformanceEntry | undefined> => {
549
+ return new Promise((resolve) => {
550
+ new PerformanceObserver((list) => {
551
+ const entries = list.getEntries();
552
+ resolve(entries[entries.length - 1]);
553
+ }).observe({ entryTypes: ['largest-contentful-paint'] });
554
+
555
+ // Resolve after 3 seconds if no LCP entry is found
556
+ setTimeout(() => resolve(undefined), 3000);
557
+ });
558
+ };
559
+
560
+ // Analyze system health
561
+ const analyzeSystemHealth = (currentMetrics: SystemMetrics): SystemMetrics['health'] => {
562
+ const issues: string[] = [];
563
+ const suggestions: string[] = [];
564
+ let score = 100;
565
+
566
+ // CPU analysis
567
+ if (currentMetrics.cpu.usage > PERFORMANCE_THRESHOLDS.cpu.critical) {
568
+ score -= 30;
569
+ issues.push('Critical CPU usage');
570
+ suggestions.push('Consider closing resource-intensive applications');
571
+ } else if (currentMetrics.cpu.usage > PERFORMANCE_THRESHOLDS.cpu.warning) {
572
+ score -= 15;
573
+ issues.push('High CPU usage');
574
+ suggestions.push('Monitor system processes for unusual activity');
575
+ }
576
+
577
+ // Memory analysis
578
+ if (currentMetrics.memory.percentage > PERFORMANCE_THRESHOLDS.memory.critical) {
579
+ score -= 30;
580
+ issues.push('Critical memory usage');
581
+ suggestions.push('Close unused applications to free up memory');
582
+ } else if (currentMetrics.memory.percentage > PERFORMANCE_THRESHOLDS.memory.warning) {
583
+ score -= 15;
584
+ issues.push('High memory usage');
585
+ suggestions.push('Consider freeing up memory by closing background applications');
586
+ }
587
+
588
+ // Performance analysis
589
+ if (currentMetrics.performance.fps < PERFORMANCE_THRESHOLDS.fps.critical) {
590
+ score -= 20;
591
+ issues.push('Very low frame rate');
592
+ suggestions.push('Disable animations or switch to power saver mode');
593
+ } else if (currentMetrics.performance.fps < PERFORMANCE_THRESHOLDS.fps.warning) {
594
+ score -= 10;
595
+ issues.push('Low frame rate');
596
+ suggestions.push('Consider reducing visual effects');
597
+ }
598
+
599
+ // Battery analysis
600
+ if (currentMetrics.battery && !currentMetrics.battery.charging && currentMetrics.battery.level < 20) {
601
+ score -= 10;
602
+ issues.push('Low battery');
603
+ suggestions.push('Connect to power source or enable power saver mode');
604
+ }
605
+
606
+ return {
607
+ score: Math.max(0, score),
608
+ issues,
609
+ suggestions,
610
+ };
611
+ };
612
+
613
+ // Update metrics with enhanced data
614
+ const updateMetrics = async () => {
615
+ try {
616
+ // Get memory info using Performance API
617
+ const memory = performance.memory || {
618
+ jsHeapSizeLimit: 0,
619
+ totalJSHeapSize: 0,
620
+ usedJSHeapSize: 0,
621
+ };
622
+ const totalMem = memory.totalJSHeapSize / (1024 * 1024);
623
+ const usedMem = memory.usedJSHeapSize / (1024 * 1024);
624
+ const memPercentage = (usedMem / totalMem) * 100;
625
+
626
+ // Get CPU usage using Performance API
627
+ const cpuUsage = await getCPUUsage();
628
+
629
+ // Get battery info
630
+ let batteryInfo: SystemMetrics['battery'] | undefined;
631
+
632
+ try {
633
+ const battery = await navigator.getBattery();
634
+ batteryInfo = {
635
+ level: battery.level * 100,
636
+ charging: battery.charging,
637
+ timeRemaining: battery.charging ? battery.chargingTime : battery.dischargingTime,
638
+ };
639
+ } catch {
640
+ console.log('Battery API not available');
641
+ }
642
+
643
+ // Get network info using Network Information API
644
+ const connection =
645
+ (navigator as any).connection || (navigator as any).mozConnection || (navigator as any).webkitConnection;
646
+ const networkInfo = {
647
+ downlink: connection?.downlink || 0,
648
+ uplink: connection?.uplink,
649
+ latency: connection?.rtt || 0,
650
+ type: connection?.type || 'unknown',
651
+ activeConnections: connection?.activeConnections,
652
+ bytesReceived: connection?.bytesReceived || 0,
653
+ bytesSent: connection?.bytesSent || 0,
654
+ };
655
+
656
+ // Get enhanced performance metrics
657
+ const performanceMetrics = await getPerformanceMetrics();
658
+
659
+ const metrics: SystemMetrics = {
660
+ cpu: { usage: cpuUsage, cores: [], temperature: undefined, frequency: undefined },
661
+ memory: {
662
+ used: Math.round(usedMem),
663
+ total: Math.round(totalMem),
664
+ percentage: Math.round(memPercentage),
665
+ heap: {
666
+ used: Math.round(usedMem),
667
+ total: Math.round(totalMem),
668
+ limit: Math.round(totalMem),
669
+ },
670
+ },
671
+ uptime: performance.now() / 1000,
672
+ battery: batteryInfo,
673
+ network: networkInfo,
674
+ performance: performanceMetrics as SystemMetrics['performance'],
675
+ health: { score: 0, issues: [], suggestions: [] },
676
+ };
677
+
678
+ // Analyze system health
679
+ metrics.health = analyzeSystemHealth(metrics);
680
+
681
+ // Check for alerts
682
+ checkPerformanceAlerts(metrics);
683
+
684
+ setMetrics(metrics);
685
+
686
+ // Update metrics history
687
+ const now = new Date().toLocaleTimeString();
688
+ setMetricsHistory((prev) => {
689
+ const timestamps = [...prev.timestamps, now].slice(-MAX_HISTORY_POINTS);
690
+ const cpu = [...prev.cpu, metrics.cpu.usage].slice(-MAX_HISTORY_POINTS);
691
+ const memory = [...prev.memory, metrics.memory.percentage].slice(-MAX_HISTORY_POINTS);
692
+ const battery = [...prev.battery, batteryInfo?.level || 0].slice(-MAX_HISTORY_POINTS);
693
+ const network = [...prev.network, networkInfo.downlink].slice(-MAX_HISTORY_POINTS);
694
+
695
+ return { timestamps, cpu, memory, battery, network };
696
+ });
697
+ } catch (error) {
698
+ console.error('Failed to update system metrics:', error);
699
+ }
700
+ };
701
+
702
+ // Get real CPU usage using Performance API
703
+ const getCPUUsage = async (): Promise<number> => {
704
+ try {
705
+ const t0 = performance.now();
706
+
707
+ // Create some actual work to measure and use the result
708
+ let result = 0;
709
+
710
+ for (let i = 0; i < 10000; i++) {
711
+ result += Math.random();
712
+ }
713
+
714
+ // Use result to prevent optimization
715
+ if (result < 0) {
716
+ console.log('Unexpected negative result');
717
+ }
718
+
719
+ const t1 = performance.now();
720
+ const timeTaken = t1 - t0;
721
+
722
+ /*
723
+ * Normalize to percentage (0-100)
724
+ * Lower time = higher CPU availability
725
+ */
726
+ const maxExpectedTime = 50; // baseline in ms
727
+ const cpuAvailability = Math.max(0, Math.min(100, ((maxExpectedTime - timeTaken) / maxExpectedTime) * 100));
728
+
729
+ return 100 - cpuAvailability; // Convert availability to usage
730
+ } catch (error) {
731
+ console.error('Failed to get CPU usage:', error);
732
+ return 0;
733
+ }
734
+ };
735
+
736
+ // Add network change listener
737
+ useEffect(() => {
738
+ const connection =
739
+ (navigator as any).connection || (navigator as any).mozConnection || (navigator as any).webkitConnection;
740
+
741
+ if (!connection) {
742
+ return;
743
+ }
744
+
745
+ const updateNetworkInfo = () => {
746
+ setMetrics((prev) => ({
747
+ ...prev,
748
+ network: {
749
+ downlink: connection.downlink || 0,
750
+ latency: connection.rtt || 0,
751
+ type: connection.type || 'unknown',
752
+ bytesReceived: connection.bytesReceived || 0,
753
+ bytesSent: connection.bytesSent || 0,
754
+ },
755
+ }));
756
+ };
757
+
758
+ connection.addEventListener('change', updateNetworkInfo);
759
+
760
+ // eslint-disable-next-line consistent-return
761
+ return () => connection.removeEventListener('change', updateNetworkInfo);
762
+ }, []);
763
+
764
+ // Remove all animation and process monitoring
765
+ useEffect(() => {
766
+ const metricsInterval = setInterval(
767
+ () => {
768
+ if (!energySaverMode) {
769
+ updateMetrics();
770
+ }
771
+ },
772
+ energySaverMode ? UPDATE_INTERVALS.energySaver.metrics : UPDATE_INTERVALS.normal.metrics,
773
+ );
774
+
775
+ return () => {
776
+ clearInterval(metricsInterval);
777
+ };
778
+ }, [energySaverMode]);
779
+
780
+ const getUsageColor = (usage: number): string => {
781
+ if (usage > 80) {
782
+ return 'text-red-500';
783
+ }
784
+
785
+ if (usage > 50) {
786
+ return 'text-yellow-500';
787
+ }
788
+
789
+ return 'text-gray-500';
790
+ };
791
+
792
+ const renderUsageGraph = (data: number[], label: string, color: string) => {
793
+ const chartData = {
794
+ labels: metricsHistory.timestamps,
795
+ datasets: [
796
+ {
797
+ label,
798
+ data,
799
+ borderColor: color,
800
+ fill: false,
801
+ tension: 0.4,
802
+ },
803
+ ],
804
+ };
805
+
806
+ const options = {
807
+ responsive: true,
808
+ maintainAspectRatio: false,
809
+ scales: {
810
+ y: {
811
+ beginAtZero: true,
812
+ max: 100,
813
+ grid: {
814
+ color: 'rgba(255, 255, 255, 0.1)',
815
+ },
816
+ },
817
+ x: {
818
+ grid: {
819
+ display: false,
820
+ },
821
+ },
822
+ },
823
+ plugins: {
824
+ legend: {
825
+ display: false,
826
+ },
827
+ },
828
+ animation: {
829
+ duration: 0,
830
+ } as const,
831
+ };
832
+
833
+ return (
834
+ <div className="h-32">
835
+ <Line data={chartData} options={options} />
836
+ </div>
837
+ );
838
+ };
839
+
840
+ useEffect((): (() => void) | undefined => {
841
+ if (!autoEnergySaver) {
842
+ // If auto mode is disabled, clear any forced energy saver state
843
+ setEnergySaverMode(false);
844
+ return undefined;
845
+ }
846
+
847
+ const checkBatteryStatus = async () => {
848
+ try {
849
+ const battery = await navigator.getBattery();
850
+ const shouldEnableSaver = !battery.charging && battery.level * 100 <= BATTERY_THRESHOLD;
851
+ setEnergySaverMode(shouldEnableSaver);
852
+ } catch {
853
+ console.log('Battery API not available');
854
+ }
855
+ };
856
+
857
+ checkBatteryStatus();
858
+
859
+ const batteryCheckInterval = setInterval(checkBatteryStatus, 60000);
860
+
861
+ return () => clearInterval(batteryCheckInterval);
862
+ }, [autoEnergySaver]);
863
+
864
+ // Check for performance alerts
865
+ const checkPerformanceAlerts = (currentMetrics: SystemMetrics) => {
866
+ const newAlerts: PerformanceAlert[] = [];
867
+
868
+ // CPU alert
869
+ if (currentMetrics.cpu.usage > PERFORMANCE_THRESHOLDS.cpu.critical) {
870
+ newAlerts.push({
871
+ type: 'error',
872
+ message: 'Critical CPU usage detected',
873
+ timestamp: Date.now(),
874
+ metric: 'cpu',
875
+ threshold: PERFORMANCE_THRESHOLDS.cpu.critical,
876
+ value: currentMetrics.cpu.usage,
877
+ });
878
+ }
879
+
880
+ // Memory alert
881
+ if (currentMetrics.memory.percentage > PERFORMANCE_THRESHOLDS.memory.critical) {
882
+ newAlerts.push({
883
+ type: 'error',
884
+ message: 'Critical memory usage detected',
885
+ timestamp: Date.now(),
886
+ metric: 'memory',
887
+ threshold: PERFORMANCE_THRESHOLDS.memory.critical,
888
+ value: currentMetrics.memory.percentage,
889
+ });
890
+ }
891
+
892
+ // Performance alert
893
+ if (currentMetrics.performance.fps < PERFORMANCE_THRESHOLDS.fps.critical) {
894
+ newAlerts.push({
895
+ type: 'warning',
896
+ message: 'Very low frame rate detected',
897
+ timestamp: Date.now(),
898
+ metric: 'fps',
899
+ threshold: PERFORMANCE_THRESHOLDS.fps.critical,
900
+ value: currentMetrics.performance.fps,
901
+ });
902
+ }
903
+
904
+ if (newAlerts.length > 0) {
905
+ setAlerts((prev) => [...prev, ...newAlerts]);
906
+ newAlerts.forEach((alert) => {
907
+ toast.warning(alert.message);
908
+ });
909
+ }
910
+ };
911
+
912
+ return (
913
+ <div className="flex flex-col gap-6">
914
+ {/* Power Profile Selection */}
915
+ <div className="flex flex-col gap-4">
916
+ <div className="flex items-center justify-between">
917
+ <h3 className="text-base font-medium text-bolt-elements-textPrimary">Power Management</h3>
918
+ <div className="flex items-center gap-4">
919
+ <div className="flex items-center gap-2">
920
+ <input
921
+ type="checkbox"
922
+ id="autoEnergySaver"
923
+ checked={autoEnergySaver}
924
+ onChange={(e) => handleAutoEnergySaverChange(e.target.checked)}
925
+ className="form-checkbox h-4 w-4 text-purple-600 rounded border-gray-300 dark:border-gray-700"
926
+ />
927
+ <div className="i-ph:gauge-duotone w-4 h-4 text-bolt-elements-textSecondary" />
928
+ <label htmlFor="autoEnergySaver" className="text-sm text-bolt-elements-textSecondary">
929
+ Auto Energy Saver
930
+ </label>
931
+ </div>
932
+ <div className="flex items-center gap-2">
933
+ <input
934
+ type="checkbox"
935
+ id="energySaver"
936
+ checked={energySaverMode}
937
+ onChange={(e) => !autoEnergySaver && handleEnergySaverChange(e.target.checked)}
938
+ disabled={autoEnergySaver}
939
+ className="form-checkbox h-4 w-4 text-purple-600 rounded border-gray-300 dark:border-gray-700 disabled:opacity-50"
940
+ />
941
+ <div className="i-ph:leaf-duotone w-4 h-4 text-bolt-elements-textSecondary" />
942
+ <label
943
+ htmlFor="energySaver"
944
+ className={classNames('text-sm text-bolt-elements-textSecondary', { 'opacity-50': autoEnergySaver })}
945
+ >
946
+ Energy Saver
947
+ {energySaverMode && <span className="ml-2 text-xs text-bolt-elements-textSecondary">Active</span>}
948
+ </label>
949
+ </div>
950
+ <div className="relative">
951
+ <select
952
+ value={selectedProfile.name}
953
+ onChange={(e) => {
954
+ const profile = POWER_PROFILES.find((p) => p.name === e.target.value);
955
+
956
+ if (profile) {
957
+ setSelectedProfile(profile);
958
+ toast.success(`Switched to ${profile.name} power profile`);
959
+ }
960
+ }}
961
+ className="pl-8 pr-8 py-1.5 rounded-md bg-bolt-background-secondary dark:bg-[#1E1E1E] border border-bolt-border dark:border-bolt-borderDark text-sm text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimaryDark hover:border-bolt-action-primary dark:hover:border-bolt-action-primary focus:outline-none focus:ring-1 focus:ring-bolt-action-primary appearance-none min-w-[160px] cursor-pointer transition-colors duration-150"
962
+ style={{ WebkitAppearance: 'none', MozAppearance: 'none' }}
963
+ >
964
+ {POWER_PROFILES.map((profile) => (
965
+ <option
966
+ key={profile.name}
967
+ value={profile.name}
968
+ className="py-2 px-3 bg-bolt-background-secondary dark:bg-[#1E1E1E] text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimaryDark hover:bg-bolt-background-tertiary dark:hover:bg-bolt-backgroundDark-tertiary cursor-pointer"
969
+ >
970
+ {profile.name}
971
+ </option>
972
+ ))}
973
+ </select>
974
+ <div className="absolute left-2 top-1/2 -translate-y-1/2 pointer-events-none">
975
+ <div
976
+ className={classNames('w-4 h-4 text-bolt-elements-textSecondary', {
977
+ 'i-ph:lightning-fill text-yellow-500': selectedProfile.name === 'Performance',
978
+ 'i-ph:scales-fill text-blue-500': selectedProfile.name === 'Balanced',
979
+ 'i-ph:leaf-fill text-green-500': selectedProfile.name === 'Energy Saver',
980
+ })}
981
+ />
982
+ </div>
983
+ <div className="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none">
984
+ <div className="i-ph:caret-down w-4 h-4 text-bolt-elements-textSecondary opacity-75" />
985
+ </div>
986
+ </div>
987
+ </div>
988
+ </div>
989
+ <div className="text-sm text-bolt-elements-textSecondary">{selectedProfile.description}</div>
990
+ </div>
991
+
992
+ {/* System Health Score */}
993
+ <div className="flex flex-col gap-4">
994
+ <h3 className="text-base font-medium text-bolt-elements-textPrimary">System Health</h3>
995
+ <div className="grid grid-cols-1 gap-4">
996
+ <div className="flex flex-col gap-2 rounded-lg bg-[#F8F8F8] dark:bg-[#141414] p-4">
997
+ <div className="flex items-center justify-between">
998
+ <span className="text-sm text-bolt-elements-textSecondary">Health Score</span>
999
+ <span
1000
+ className={classNames('text-lg font-medium', {
1001
+ 'text-green-500': metrics.health.score >= 80,
1002
+ 'text-yellow-500': metrics.health.score >= 60 && metrics.health.score < 80,
1003
+ 'text-red-500': metrics.health.score < 60,
1004
+ })}
1005
+ >
1006
+ {metrics.health.score}%
1007
+ </span>
1008
+ </div>
1009
+ {metrics.health.issues.length > 0 && (
1010
+ <div className="mt-2">
1011
+ <div className="text-sm font-medium text-bolt-elements-textSecondary mb-1">Issues:</div>
1012
+ <ul className="text-sm text-bolt-elements-textSecondary space-y-1">
1013
+ {metrics.health.issues.map((issue, index) => (
1014
+ <li key={index} className="flex items-center gap-2">
1015
+ <div className="i-ph:warning-circle-fill text-yellow-500 w-4 h-4" />
1016
+ {issue}
1017
+ </li>
1018
+ ))}
1019
+ </ul>
1020
+ </div>
1021
+ )}
1022
+ {metrics.health.suggestions.length > 0 && (
1023
+ <div className="mt-2">
1024
+ <div className="text-sm font-medium text-bolt-elements-textSecondary mb-1">Suggestions:</div>
1025
+ <ul className="text-sm text-bolt-elements-textSecondary space-y-1">
1026
+ {metrics.health.suggestions.map((suggestion, index) => (
1027
+ <li key={index} className="flex items-center gap-2">
1028
+ <div className="i-ph:lightbulb-fill text-purple-500 w-4 h-4" />
1029
+ {suggestion}
1030
+ </li>
1031
+ ))}
1032
+ </ul>
1033
+ </div>
1034
+ )}
1035
+ </div>
1036
+ </div>
1037
+ </div>
1038
+
1039
+ {/* System Metrics */}
1040
+ <div className="flex flex-col gap-4">
1041
+ <h3 className="text-base font-medium text-bolt-elements-textPrimary">System Metrics</h3>
1042
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
1043
+ {/* CPU Usage */}
1044
+ <div className="flex flex-col gap-2 rounded-lg bg-[#F8F8F8] dark:bg-[#141414] p-4">
1045
+ <div className="flex items-center justify-between">
1046
+ <span className="text-sm text-bolt-elements-textSecondary">CPU Usage</span>
1047
+ <span className={classNames('text-sm font-medium', getUsageColor(metrics.cpu.usage))}>
1048
+ {Math.round(metrics.cpu.usage)}%
1049
+ </span>
1050
+ </div>
1051
+ {renderUsageGraph(metricsHistory.cpu, 'CPU', '#9333ea')}
1052
+ {metrics.cpu.temperature && (
1053
+ <div className="text-xs text-bolt-elements-textSecondary mt-2">
1054
+ Temperature: {metrics.cpu.temperature}°C
1055
+ </div>
1056
+ )}
1057
+ {metrics.cpu.frequency && (
1058
+ <div className="text-xs text-bolt-elements-textSecondary">
1059
+ Frequency: {(metrics.cpu.frequency / 1000).toFixed(1)} GHz
1060
+ </div>
1061
+ )}
1062
+ </div>
1063
+
1064
+ {/* Memory Usage */}
1065
+ <div className="flex flex-col gap-2 rounded-lg bg-[#F8F8F8] dark:bg-[#141414] p-4">
1066
+ <div className="flex items-center justify-between">
1067
+ <span className="text-sm text-bolt-elements-textSecondary">Memory Usage</span>
1068
+ <span className={classNames('text-sm font-medium', getUsageColor(metrics.memory.percentage))}>
1069
+ {Math.round(metrics.memory.percentage)}%
1070
+ </span>
1071
+ </div>
1072
+ {renderUsageGraph(metricsHistory.memory, 'Memory', '#2563eb')}
1073
+ <div className="text-xs text-bolt-elements-textSecondary mt-2">
1074
+ Used: {formatBytes(metrics.memory.used)}
1075
+ </div>
1076
+ <div className="text-xs text-bolt-elements-textSecondary">Total: {formatBytes(metrics.memory.total)}</div>
1077
+ <div className="text-xs text-bolt-elements-textSecondary">
1078
+ Heap: {formatBytes(metrics.memory.heap.used)} / {formatBytes(metrics.memory.heap.total)}
1079
+ </div>
1080
+ </div>
1081
+
1082
+ {/* Performance */}
1083
+ <div className="flex flex-col gap-2 rounded-lg bg-[#F8F8F8] dark:bg-[#141414] p-4">
1084
+ <div className="flex items-center justify-between">
1085
+ <span className="text-sm text-bolt-elements-textSecondary">Performance</span>
1086
+ <span
1087
+ className={classNames('text-sm font-medium', {
1088
+ 'text-red-500': metrics.performance.fps < PERFORMANCE_THRESHOLDS.fps.critical,
1089
+ 'text-yellow-500': metrics.performance.fps < PERFORMANCE_THRESHOLDS.fps.warning,
1090
+ 'text-green-500': metrics.performance.fps >= PERFORMANCE_THRESHOLDS.fps.warning,
1091
+ })}
1092
+ >
1093
+ {Math.round(metrics.performance.fps)} FPS
1094
+ </span>
1095
+ </div>
1096
+ <div className="text-xs text-bolt-elements-textSecondary mt-2">
1097
+ Page Load: {(metrics.performance.pageLoad / 1000).toFixed(2)}s
1098
+ </div>
1099
+ <div className="text-xs text-bolt-elements-textSecondary">
1100
+ DOM Ready: {(metrics.performance.domReady / 1000).toFixed(2)}s
1101
+ </div>
1102
+ <div className="text-xs text-bolt-elements-textSecondary">
1103
+ TTFB: {(metrics.performance.timing.ttfb / 1000).toFixed(2)}s
1104
+ </div>
1105
+ <div className="text-xs text-bolt-elements-textSecondary">
1106
+ Resources: {metrics.performance.resources.total} ({formatBytes(metrics.performance.resources.size)})
1107
+ </div>
1108
+ </div>
1109
+
1110
+ {/* Network */}
1111
+ <div className="flex flex-col gap-2 rounded-lg bg-[#F8F8F8] dark:bg-[#141414] p-4">
1112
+ <div className="flex items-center justify-between">
1113
+ <span className="text-sm text-bolt-elements-textSecondary">Network</span>
1114
+ <span className="text-sm font-medium text-bolt-elements-textPrimary">
1115
+ {metrics.network.downlink.toFixed(1)} Mbps
1116
+ </span>
1117
+ </div>
1118
+ {renderUsageGraph(metricsHistory.network, 'Network', '#f59e0b')}
1119
+ <div className="text-xs text-bolt-elements-textSecondary mt-2">Type: {metrics.network.type}</div>
1120
+ <div className="text-xs text-bolt-elements-textSecondary">Latency: {metrics.network.latency}ms</div>
1121
+ <div className="text-xs text-bolt-elements-textSecondary">
1122
+ Received: {formatBytes(metrics.network.bytesReceived)}
1123
+ </div>
1124
+ <div className="text-xs text-bolt-elements-textSecondary">
1125
+ Sent: {formatBytes(metrics.network.bytesSent)}
1126
+ </div>
1127
+ </div>
1128
+ </div>
1129
+
1130
+ {/* Battery Section */}
1131
+ {metrics.battery && (
1132
+ <div className="flex flex-col gap-2 rounded-lg bg-[#F8F8F8] dark:bg-[#141414] p-4">
1133
+ <div className="flex items-center justify-between">
1134
+ <span className="text-sm text-bolt-elements-textSecondary">Battery</span>
1135
+ <div className="flex items-center gap-2">
1136
+ {metrics.battery.charging && <div className="i-ph:lightning-fill w-4 h-4 text-bolt-action-primary" />}
1137
+ <span
1138
+ className={classNames(
1139
+ 'text-sm font-medium',
1140
+ metrics.battery.level > 20 ? 'text-bolt-elements-textPrimary' : 'text-red-500',
1141
+ )}
1142
+ >
1143
+ {Math.round(metrics.battery.level)}%
1144
+ </span>
1145
+ </div>
1146
+ </div>
1147
+ {renderUsageGraph(metricsHistory.battery, 'Battery', '#22c55e')}
1148
+ {metrics.battery.timeRemaining && (
1149
+ <div className="text-xs text-bolt-elements-textSecondary mt-2">
1150
+ {metrics.battery.charging ? 'Time to full: ' : 'Time remaining: '}
1151
+ {formatTime(metrics.battery.timeRemaining)}
1152
+ </div>
1153
+ )}
1154
+ {metrics.battery.temperature && (
1155
+ <div className="text-xs text-bolt-elements-textSecondary">
1156
+ Temperature: {metrics.battery.temperature}°C
1157
+ </div>
1158
+ )}
1159
+ {metrics.battery.cycles && (
1160
+ <div className="text-xs text-bolt-elements-textSecondary">Charge cycles: {metrics.battery.cycles}</div>
1161
+ )}
1162
+ {metrics.battery.health && (
1163
+ <div className="text-xs text-bolt-elements-textSecondary">Battery health: {metrics.battery.health}%</div>
1164
+ )}
1165
+ </div>
1166
+ )}
1167
+
1168
+ {/* Performance Alerts */}
1169
+ {alerts.length > 0 && (
1170
+ <div className="flex flex-col gap-2 rounded-lg bg-[#F8F8F8] dark:bg-[#141414] p-4">
1171
+ <div className="flex items-center justify-between">
1172
+ <span className="text-sm font-medium text-bolt-elements-textPrimary">Recent Alerts</span>
1173
+ <button
1174
+ onClick={() => setAlerts([])}
1175
+ className="text-xs text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary"
1176
+ >
1177
+ Clear All
1178
+ </button>
1179
+ </div>
1180
+ <div className="space-y-2">
1181
+ {alerts.slice(-5).map((alert, index) => (
1182
+ <div
1183
+ key={index}
1184
+ className={classNames('flex items-center gap-2 text-sm', {
1185
+ 'text-red-500': alert.type === 'error',
1186
+ 'text-yellow-500': alert.type === 'warning',
1187
+ 'text-blue-500': alert.type === 'info',
1188
+ })}
1189
+ >
1190
+ <div
1191
+ className={classNames('w-4 h-4', {
1192
+ 'i-ph:warning-circle-fill': alert.type === 'warning',
1193
+ 'i-ph:x-circle-fill': alert.type === 'error',
1194
+ 'i-ph:info-fill': alert.type === 'info',
1195
+ })}
1196
+ />
1197
+ <span>{alert.message}</span>
1198
+ <span className="text-xs text-bolt-elements-textSecondary ml-auto">
1199
+ {new Date(alert.timestamp).toLocaleTimeString()}
1200
+ </span>
1201
+ </div>
1202
+ ))}
1203
+ </div>
1204
+ </div>
1205
+ )}
1206
+
1207
+ {/* Energy Savings */}
1208
+ {energySaverMode && (
1209
+ <div className="flex flex-col gap-2 rounded-lg bg-[#F8F8F8] dark:bg-[#141414] p-4">
1210
+ <h4 className="text-sm font-medium text-bolt-elements-textPrimary">Energy Savings</h4>
1211
+ <div className="grid grid-cols-3 gap-4">
1212
+ <div>
1213
+ <span className="text-sm text-bolt-elements-textSecondary">Updates Reduced</span>
1214
+ <p className="text-lg font-medium text-bolt-elements-textPrimary">{energySavings.updatesReduced}</p>
1215
+ </div>
1216
+ <div>
1217
+ <span className="text-sm text-bolt-elements-textSecondary">Time in Saver Mode</span>
1218
+ <p className="text-lg font-medium text-bolt-elements-textPrimary">
1219
+ {Math.floor(energySavings.timeInSaverMode / 60)}m {Math.floor(energySavings.timeInSaverMode % 60)}s
1220
+ </p>
1221
+ </div>
1222
+ <div>
1223
+ <span className="text-sm text-bolt-elements-textSecondary">Energy Saved</span>
1224
+ <p className="text-lg font-medium text-bolt-elements-textPrimary">
1225
+ {energySavings.estimatedEnergySaved.toFixed(2)} mWh
1226
+ </p>
1227
+ </div>
1228
+ </div>
1229
+ </div>
1230
+ )}
1231
+ </div>
1232
+ </div>
1233
+ );
1234
+ };
1235
+
1236
+ export default React.memo(TaskManagerTab);
1237
+
1238
+ // Helper function to format bytes
1239
+ const formatBytes = (bytes: number): string => {
1240
+ if (bytes === 0) {
1241
+ return '0 B';
1242
+ }
1243
+
1244
+ const k = 1024;
1245
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
1246
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
1247
+
1248
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
1249
+ };
1250
+
1251
+ // Helper function to format time
1252
+ const formatTime = (seconds: number): string => {
1253
+ if (!isFinite(seconds) || seconds === 0) {
1254
+ return 'Unknown';
1255
+ }
1256
+
1257
+ const hours = Math.floor(seconds / 3600);
1258
+ const minutes = Math.floor((seconds % 3600) / 60);
1259
+
1260
+ if (hours > 0) {
1261
+ return `${hours}h ${minutes}m`;
1262
+ }
1263
+
1264
+ return `${minutes}m`;
1265
+ };
app/components/@settings/tabs/update/UpdateTab.tsx ADDED
@@ -0,0 +1,628 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react';
2
+ import { motion } from 'framer-motion';
3
+ import { useSettings } from '~/lib/hooks/useSettings';
4
+ import { logStore } from '~/lib/stores/logs';
5
+ import { toast } from 'react-toastify';
6
+ import { Dialog, DialogRoot, DialogTitle, DialogDescription, DialogButton } from '~/components/ui/Dialog';
7
+ import { classNames } from '~/utils/classNames';
8
+ import { Markdown } from '~/components/chat/Markdown';
9
+
10
+ interface UpdateProgress {
11
+ stage: 'fetch' | 'pull' | 'install' | 'build' | 'complete';
12
+ message: string;
13
+ progress?: number;
14
+ error?: string;
15
+ details?: {
16
+ changedFiles?: string[];
17
+ additions?: number;
18
+ deletions?: number;
19
+ commitMessages?: string[];
20
+ totalSize?: string;
21
+ currentCommit?: string;
22
+ remoteCommit?: string;
23
+ updateReady?: boolean;
24
+ changelog?: string;
25
+ compareUrl?: string;
26
+ };
27
+ }
28
+
29
+ interface UpdateSettings {
30
+ autoUpdate: boolean;
31
+ notifyInApp: boolean;
32
+ checkInterval: number;
33
+ }
34
+
35
+ const ProgressBar = ({ progress }: { progress: number }) => (
36
+ <div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
37
+ <motion.div
38
+ className="h-full bg-blue-500"
39
+ initial={{ width: 0 }}
40
+ animate={{ width: `${progress}%` }}
41
+ transition={{ duration: 0.3 }}
42
+ />
43
+ </div>
44
+ );
45
+
46
+ const UpdateProgressDisplay = ({ progress }: { progress: UpdateProgress }) => (
47
+ <div className="mt-4 space-y-2">
48
+ <div className="flex justify-between items-center">
49
+ <span className="text-sm font-medium">{progress.message}</span>
50
+ <span className="text-sm text-gray-500">{progress.progress}%</span>
51
+ </div>
52
+ <ProgressBar progress={progress.progress || 0} />
53
+ {progress.details && (
54
+ <div className="mt-2 text-sm text-gray-600">
55
+ {progress.details.changedFiles && progress.details.changedFiles.length > 0 && (
56
+ <div className="mt-4">
57
+ <div className="font-medium mb-2">Changed Files:</div>
58
+ <div className="space-y-2">
59
+ {/* Group files by type */}
60
+ {['Modified', 'Added', 'Deleted'].map((type) => {
61
+ const filesOfType = progress.details?.changedFiles?.filter((file) => file.startsWith(type)) || [];
62
+
63
+ if (filesOfType.length === 0) {
64
+ return null;
65
+ }
66
+
67
+ return (
68
+ <div key={type} className="space-y-1">
69
+ <div
70
+ className={classNames('text-sm font-medium', {
71
+ 'text-blue-500': type === 'Modified',
72
+ 'text-green-500': type === 'Added',
73
+ 'text-red-500': type === 'Deleted',
74
+ })}
75
+ >
76
+ {type} ({filesOfType.length})
77
+ </div>
78
+ <div className="pl-4 space-y-1">
79
+ {filesOfType.map((file, index) => {
80
+ const fileName = file.split(': ')[1];
81
+ return (
82
+ <div key={index} className="text-sm text-bolt-elements-textSecondary flex items-center gap-2">
83
+ <div
84
+ className={classNames('w-4 h-4', {
85
+ 'i-ph:pencil-simple': type === 'Modified',
86
+ 'i-ph:plus': type === 'Added',
87
+ 'i-ph:trash': type === 'Deleted',
88
+ 'text-blue-500': type === 'Modified',
89
+ 'text-green-500': type === 'Added',
90
+ 'text-red-500': type === 'Deleted',
91
+ })}
92
+ />
93
+ <span className="font-mono text-xs">{fileName}</span>
94
+ </div>
95
+ );
96
+ })}
97
+ </div>
98
+ </div>
99
+ );
100
+ })}
101
+ </div>
102
+ </div>
103
+ )}
104
+ {progress.details.totalSize && <div className="mt-1">Total size: {progress.details.totalSize}</div>}
105
+ {progress.details.additions !== undefined && progress.details.deletions !== undefined && (
106
+ <div className="mt-1">
107
+ Changes: <span className="text-green-600">+{progress.details.additions}</span>{' '}
108
+ <span className="text-red-600">-{progress.details.deletions}</span>
109
+ </div>
110
+ )}
111
+ {progress.details.currentCommit && progress.details.remoteCommit && (
112
+ <div className="mt-1">
113
+ Updating from {progress.details.currentCommit} to {progress.details.remoteCommit}
114
+ </div>
115
+ )}
116
+ </div>
117
+ )}
118
+ </div>
119
+ );
120
+
121
+ const UpdateTab = () => {
122
+ const { isLatestBranch } = useSettings();
123
+ const [isChecking, setIsChecking] = useState(false);
124
+ const [error, setError] = useState<string | null>(null);
125
+ const [updateSettings, setUpdateSettings] = useState<UpdateSettings>(() => {
126
+ const stored = localStorage.getItem('update_settings');
127
+ return stored
128
+ ? JSON.parse(stored)
129
+ : {
130
+ autoUpdate: false,
131
+ notifyInApp: true,
132
+ checkInterval: 24,
133
+ };
134
+ });
135
+ const [showUpdateDialog, setShowUpdateDialog] = useState(false);
136
+ const [updateProgress, setUpdateProgress] = useState<UpdateProgress | null>(null);
137
+
138
+ useEffect(() => {
139
+ localStorage.setItem('update_settings', JSON.stringify(updateSettings));
140
+ }, [updateSettings]);
141
+
142
+ const checkForUpdates = async () => {
143
+ console.log('Starting update check...');
144
+ setIsChecking(true);
145
+ setError(null);
146
+ setUpdateProgress(null);
147
+
148
+ try {
149
+ const branchToCheck = isLatestBranch ? 'main' : 'stable';
150
+
151
+ // Start the update check with streaming progress
152
+ const response = await fetch('/api/update', {
153
+ method: 'POST',
154
+ headers: {
155
+ 'Content-Type': 'application/json',
156
+ },
157
+ body: JSON.stringify({
158
+ branch: branchToCheck,
159
+ autoUpdate: updateSettings.autoUpdate,
160
+ }),
161
+ });
162
+
163
+ if (!response.ok) {
164
+ throw new Error(`Update check failed: ${response.statusText}`);
165
+ }
166
+
167
+ const reader = response.body?.getReader();
168
+
169
+ if (!reader) {
170
+ throw new Error('No response stream available');
171
+ }
172
+
173
+ // Read the stream
174
+ while (true) {
175
+ const { done, value } = await reader.read();
176
+
177
+ if (done) {
178
+ break;
179
+ }
180
+
181
+ // Convert the chunk to text and parse the JSON
182
+ const chunk = new TextDecoder().decode(value);
183
+ const lines = chunk.split('\n').filter(Boolean);
184
+
185
+ for (const line of lines) {
186
+ try {
187
+ const progress = JSON.parse(line) as UpdateProgress;
188
+ setUpdateProgress(progress);
189
+
190
+ if (progress.error) {
191
+ setError(progress.error);
192
+ }
193
+
194
+ // If we're done, update the UI accordingly
195
+ if (progress.stage === 'complete') {
196
+ setIsChecking(false);
197
+
198
+ if (!progress.error) {
199
+ // Update check completed
200
+ toast.success('Update check completed');
201
+
202
+ // Show update dialog only if there are changes and auto-update is disabled
203
+ if (progress.details?.changedFiles?.length && progress.details.updateReady) {
204
+ setShowUpdateDialog(true);
205
+ }
206
+ }
207
+ }
208
+ } catch (e) {
209
+ console.error('Error parsing progress update:', e);
210
+ }
211
+ }
212
+ }
213
+ } catch (error) {
214
+ setError(error instanceof Error ? error.message : 'Unknown error occurred');
215
+ logStore.logWarning('Update Check Failed', {
216
+ type: 'update',
217
+ message: error instanceof Error ? error.message : 'Unknown error occurred',
218
+ });
219
+ } finally {
220
+ setIsChecking(false);
221
+ }
222
+ };
223
+
224
+ const handleUpdate = async () => {
225
+ setShowUpdateDialog(false);
226
+
227
+ try {
228
+ const branchToCheck = isLatestBranch ? 'main' : 'stable';
229
+
230
+ // Start the update with autoUpdate set to true to force the update
231
+ const response = await fetch('/api/update', {
232
+ method: 'POST',
233
+ headers: {
234
+ 'Content-Type': 'application/json',
235
+ },
236
+ body: JSON.stringify({
237
+ branch: branchToCheck,
238
+ autoUpdate: true,
239
+ }),
240
+ });
241
+
242
+ if (!response.ok) {
243
+ throw new Error(`Update failed: ${response.statusText}`);
244
+ }
245
+
246
+ // Handle the update progress stream
247
+ const reader = response.body?.getReader();
248
+
249
+ if (!reader) {
250
+ throw new Error('No response stream available');
251
+ }
252
+
253
+ while (true) {
254
+ const { done, value } = await reader.read();
255
+
256
+ if (done) {
257
+ break;
258
+ }
259
+
260
+ const chunk = new TextDecoder().decode(value);
261
+ const lines = chunk.split('\n').filter(Boolean);
262
+
263
+ for (const line of lines) {
264
+ try {
265
+ const progress = JSON.parse(line) as UpdateProgress;
266
+ setUpdateProgress(progress);
267
+
268
+ if (progress.error) {
269
+ setError(progress.error);
270
+ toast.error('Update failed');
271
+ }
272
+
273
+ if (progress.stage === 'complete' && !progress.error) {
274
+ toast.success('Update completed successfully');
275
+ }
276
+ } catch (e) {
277
+ console.error('Error parsing update progress:', e);
278
+ }
279
+ }
280
+ }
281
+ } catch (error) {
282
+ setError(error instanceof Error ? error.message : 'Unknown error occurred');
283
+ toast.error('Update failed');
284
+ }
285
+ };
286
+
287
+ return (
288
+ <div className="flex flex-col gap-6">
289
+ <motion.div
290
+ className="flex items-center gap-3"
291
+ initial={{ opacity: 0, y: -20 }}
292
+ animate={{ opacity: 1, y: 0 }}
293
+ transition={{ duration: 0.3 }}
294
+ >
295
+ <div className="i-ph:arrow-circle-up text-xl text-purple-500" />
296
+ <div>
297
+ <h3 className="text-lg font-medium text-bolt-elements-textPrimary">Updates</h3>
298
+ <p className="text-sm text-bolt-elements-textSecondary">Check for and manage application updates</p>
299
+ </div>
300
+ </motion.div>
301
+
302
+ {/* Update Settings Card */}
303
+ <motion.div
304
+ className="p-6 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]"
305
+ initial={{ opacity: 0, y: 20 }}
306
+ animate={{ opacity: 1, y: 0 }}
307
+ transition={{ duration: 0.3, delay: 0.1 }}
308
+ >
309
+ <div className="flex items-center gap-3 mb-6">
310
+ <div className="i-ph:gear text-purple-500 w-5 h-5" />
311
+ <h3 className="text-lg font-medium text-bolt-elements-textPrimary">Update Settings</h3>
312
+ </div>
313
+
314
+ <div className="space-y-4">
315
+ <div className="flex items-center justify-between">
316
+ <div>
317
+ <span className="text-sm text-bolt-elements-textPrimary">Automatic Updates</span>
318
+ <p className="text-xs text-bolt-elements-textSecondary">
319
+ Automatically check and apply updates when available
320
+ </p>
321
+ </div>
322
+ <button
323
+ onClick={() => setUpdateSettings((prev) => ({ ...prev, autoUpdate: !prev.autoUpdate }))}
324
+ className={classNames(
325
+ 'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
326
+ updateSettings.autoUpdate ? 'bg-purple-500' : 'bg-gray-200 dark:bg-gray-700',
327
+ )}
328
+ >
329
+ <span
330
+ className={classNames(
331
+ 'inline-block h-4 w-4 transform rounded-full bg-white transition-transform',
332
+ updateSettings.autoUpdate ? 'translate-x-6' : 'translate-x-1',
333
+ )}
334
+ />
335
+ </button>
336
+ </div>
337
+
338
+ <div className="flex items-center justify-between">
339
+ <div>
340
+ <span className="text-sm text-bolt-elements-textPrimary">In-App Notifications</span>
341
+ <p className="text-xs text-bolt-elements-textSecondary">Show notifications when updates are available</p>
342
+ </div>
343
+ <button
344
+ onClick={() => setUpdateSettings((prev) => ({ ...prev, notifyInApp: !prev.notifyInApp }))}
345
+ className={classNames(
346
+ 'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
347
+ updateSettings.notifyInApp ? 'bg-purple-500' : 'bg-gray-200 dark:bg-gray-700',
348
+ )}
349
+ >
350
+ <span
351
+ className={classNames(
352
+ 'inline-block h-4 w-4 transform rounded-full bg-white transition-transform',
353
+ updateSettings.notifyInApp ? 'translate-x-6' : 'translate-x-1',
354
+ )}
355
+ />
356
+ </button>
357
+ </div>
358
+
359
+ <div className="flex items-center justify-between">
360
+ <div>
361
+ <span className="text-sm text-bolt-elements-textPrimary">Check Interval</span>
362
+ <p className="text-xs text-bolt-elements-textSecondary">How often to check for updates</p>
363
+ </div>
364
+ <select
365
+ value={updateSettings.checkInterval}
366
+ onChange={(e) => setUpdateSettings((prev) => ({ ...prev, checkInterval: Number(e.target.value) }))}
367
+ className={classNames(
368
+ 'px-3 py-2 rounded-lg text-sm',
369
+ 'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
370
+ 'border border-[#E5E5E5] dark:border-[#1A1A1A]',
371
+ 'text-bolt-elements-textPrimary',
372
+ 'hover:bg-[#E5E5E5] dark:hover:bg-[#2A2A2A]',
373
+ 'transition-colors duration-200',
374
+ )}
375
+ >
376
+ <option value="6">6 hours</option>
377
+ <option value="12">12 hours</option>
378
+ <option value="24">24 hours</option>
379
+ <option value="48">48 hours</option>
380
+ </select>
381
+ </div>
382
+ </div>
383
+ </motion.div>
384
+
385
+ {/* Update Status Card */}
386
+ <motion.div
387
+ className="p-6 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]"
388
+ initial={{ opacity: 0, y: 20 }}
389
+ animate={{ opacity: 1, y: 0 }}
390
+ transition={{ duration: 0.3, delay: 0.2 }}
391
+ >
392
+ <div className="flex items-center justify-between mb-6">
393
+ <div className="flex items-center gap-3">
394
+ <div className="i-ph:arrows-clockwise text-purple-500 w-5 h-5" />
395
+ <h3 className="text-lg font-medium text-bolt-elements-textPrimary">Update Status</h3>
396
+ </div>
397
+ <div className="flex items-center gap-2">
398
+ {updateProgress?.details?.updateReady && !updateSettings.autoUpdate && (
399
+ <button
400
+ onClick={handleUpdate}
401
+ className={classNames(
402
+ 'flex items-center gap-2 px-4 py-2 rounded-lg text-sm',
403
+ 'bg-purple-500 text-white',
404
+ 'hover:bg-purple-600',
405
+ 'transition-colors duration-200',
406
+ )}
407
+ >
408
+ <div className="i-ph:arrow-circle-up w-4 h-4" />
409
+ Update Now
410
+ </button>
411
+ )}
412
+ <button
413
+ onClick={() => {
414
+ setError(null);
415
+ checkForUpdates();
416
+ }}
417
+ className={classNames(
418
+ 'flex items-center gap-2 px-4 py-2 rounded-lg text-sm',
419
+ 'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
420
+ 'hover:bg-purple-500/10 hover:text-purple-500',
421
+ 'dark:hover:bg-purple-500/20 dark:hover:text-purple-500',
422
+ 'text-bolt-elements-textPrimary',
423
+ 'transition-colors duration-200',
424
+ 'disabled:opacity-50 disabled:cursor-not-allowed',
425
+ )}
426
+ disabled={isChecking}
427
+ >
428
+ {isChecking ? (
429
+ <div className="flex items-center gap-2">
430
+ <motion.div
431
+ animate={{ rotate: 360 }}
432
+ transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
433
+ className="i-ph:arrows-clockwise w-4 h-4"
434
+ />
435
+ Checking...
436
+ </div>
437
+ ) : (
438
+ <>
439
+ <div className="i-ph:arrows-clockwise w-4 h-4" />
440
+ Check for Updates
441
+ </>
442
+ )}
443
+ </button>
444
+ </div>
445
+ </div>
446
+
447
+ {/* Show progress information */}
448
+ {updateProgress && <UpdateProgressDisplay progress={updateProgress} />}
449
+
450
+ {error && <div className="mt-4 p-4 bg-red-100 text-red-700 rounded">{error}</div>}
451
+
452
+ {/* Show update source information */}
453
+ {updateProgress?.details?.currentCommit && updateProgress?.details?.remoteCommit && (
454
+ <div className="mt-4 text-sm text-bolt-elements-textSecondary">
455
+ <div className="flex items-center justify-between">
456
+ <div>
457
+ <p>
458
+ Updates are fetched from: <span className="font-mono">stackblitz-labs/bolt.diy</span> (
459
+ {isLatestBranch ? 'main' : 'stable'} branch)
460
+ </p>
461
+ <p className="mt-1">
462
+ Current version: <span className="font-mono">{updateProgress.details.currentCommit}</span>
463
+ <span className="mx-2">→</span>
464
+ Latest version: <span className="font-mono">{updateProgress.details.remoteCommit}</span>
465
+ </p>
466
+ </div>
467
+ {updateProgress?.details?.compareUrl && (
468
+ <a
469
+ href={updateProgress.details.compareUrl}
470
+ target="_blank"
471
+ rel="noopener noreferrer"
472
+ className={classNames(
473
+ 'flex items-center gap-2 px-4 py-2 rounded-lg text-sm',
474
+ 'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
475
+ 'hover:bg-purple-500/10 hover:text-purple-500',
476
+ 'dark:hover:bg-purple-500/20 dark:hover:text-purple-500',
477
+ 'text-bolt-elements-textPrimary',
478
+ 'transition-colors duration-200',
479
+ 'w-fit',
480
+ )}
481
+ >
482
+ <div className="i-ph:github-logo w-4 h-4" />
483
+ View Changes on GitHub
484
+ </a>
485
+ )}
486
+ </div>
487
+ {updateProgress?.details?.additions !== undefined && updateProgress?.details?.deletions !== undefined && (
488
+ <div className="mt-2 flex items-center gap-2">
489
+ <div className="i-ph:git-diff text-purple-500 w-4 h-4" />
490
+ Changes: <span className="text-green-600">+{updateProgress.details.additions}</span>{' '}
491
+ <span className="text-red-600">-{updateProgress.details.deletions}</span>
492
+ </div>
493
+ )}
494
+ </div>
495
+ )}
496
+
497
+ {/* Add this before the changed files section */}
498
+ {updateProgress?.details?.changelog && (
499
+ <div className="mb-6">
500
+ <div className="flex items-center gap-2 mb-2">
501
+ <div className="i-ph:scroll text-purple-500 w-5 h-5" />
502
+ <p className="font-medium">Changelog</p>
503
+ </div>
504
+ <div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-lg p-4 overflow-auto max-h-[300px]">
505
+ <div className="prose dark:prose-invert prose-sm max-w-none">
506
+ <Markdown>{updateProgress.details.changelog}</Markdown>
507
+ </div>
508
+ </div>
509
+ </div>
510
+ )}
511
+
512
+ {/* Add this in the update status card, after the commit info */}
513
+ {updateProgress?.details?.compareUrl && (
514
+ <div className="mt-4">
515
+ <a
516
+ href={updateProgress.details.compareUrl}
517
+ target="_blank"
518
+ rel="noopener noreferrer"
519
+ className={classNames(
520
+ 'flex items-center gap-2 px-4 py-2 rounded-lg text-sm',
521
+ 'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
522
+ 'hover:bg-purple-500/10 hover:text-purple-500',
523
+ 'dark:hover:bg-purple-500/20 dark:hover:text-purple-500',
524
+ 'text-bolt-elements-textPrimary',
525
+ 'transition-colors duration-200',
526
+ 'w-fit',
527
+ )}
528
+ >
529
+ <div className="i-ph:github-logo w-4 h-4" />
530
+ View Changes on GitHub
531
+ </a>
532
+ </div>
533
+ )}
534
+
535
+ {updateProgress?.details?.commitMessages && updateProgress.details.commitMessages.length > 0 && (
536
+ <div className="mb-6">
537
+ <p className="font-medium mb-2">Changes in this Update:</p>
538
+ <div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-lg p-4 overflow-auto max-h-[400px]">
539
+ <div className="prose dark:prose-invert prose-sm max-w-none">
540
+ {updateProgress.details.commitMessages.map((section, index) => (
541
+ <Markdown key={index}>{section}</Markdown>
542
+ ))}
543
+ </div>
544
+ </div>
545
+ </div>
546
+ )}
547
+ </motion.div>
548
+
549
+ {/* Update dialog */}
550
+ <DialogRoot open={showUpdateDialog} onOpenChange={setShowUpdateDialog}>
551
+ <Dialog>
552
+ <DialogTitle>Update Available</DialogTitle>
553
+ <DialogDescription>
554
+ <div className="mt-4">
555
+ <p className="text-sm text-bolt-elements-textSecondary mb-4">
556
+ A new version is available from <span className="font-mono">stackblitz-labs/bolt.diy</span> (
557
+ {isLatestBranch ? 'main' : 'stable'} branch)
558
+ </p>
559
+
560
+ {updateProgress?.details?.compareUrl && (
561
+ <div className="mb-6">
562
+ <a
563
+ href={updateProgress.details.compareUrl}
564
+ target="_blank"
565
+ rel="noopener noreferrer"
566
+ className={classNames(
567
+ 'flex items-center gap-2 px-4 py-2 rounded-lg text-sm',
568
+ 'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
569
+ 'hover:bg-purple-500/10 hover:text-purple-500',
570
+ 'dark:hover:bg-purple-500/20 dark:hover:text-purple-500',
571
+ 'text-bolt-elements-textPrimary',
572
+ 'transition-colors duration-200',
573
+ 'w-fit',
574
+ )}
575
+ >
576
+ <div className="i-ph:github-logo w-4 h-4" />
577
+ View Changes on GitHub
578
+ </a>
579
+ </div>
580
+ )}
581
+
582
+ {updateProgress?.details?.commitMessages && updateProgress.details.commitMessages.length > 0 && (
583
+ <div className="mb-6">
584
+ <p className="font-medium mb-2">Commit Messages:</p>
585
+ <div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-lg p-3 space-y-2">
586
+ {updateProgress.details.commitMessages.map((msg, index) => (
587
+ <div key={index} className="text-sm text-bolt-elements-textSecondary flex items-start gap-2">
588
+ <div className="i-ph:git-commit text-purple-500 w-4 h-4 mt-0.5 flex-shrink-0" />
589
+ <span>{msg}</span>
590
+ </div>
591
+ ))}
592
+ </div>
593
+ </div>
594
+ )}
595
+
596
+ {updateProgress?.details?.totalSize && (
597
+ <div className="flex items-center gap-4 text-sm text-bolt-elements-textSecondary">
598
+ <div className="flex items-center gap-2">
599
+ <div className="i-ph:file text-purple-500 w-4 h-4" />
600
+ Total size: {updateProgress.details.totalSize}
601
+ </div>
602
+ {updateProgress?.details?.additions !== undefined &&
603
+ updateProgress?.details?.deletions !== undefined && (
604
+ <div className="flex items-center gap-2">
605
+ <div className="i-ph:git-diff text-purple-500 w-4 h-4" />
606
+ Changes: <span className="text-green-600">+{updateProgress.details.additions}</span>{' '}
607
+ <span className="text-red-600">-{updateProgress.details.deletions}</span>
608
+ </div>
609
+ )}
610
+ </div>
611
+ )}
612
+ </div>
613
+ </DialogDescription>
614
+ <div className="flex justify-end gap-2 mt-6">
615
+ <DialogButton type="secondary" onClick={() => setShowUpdateDialog(false)}>
616
+ Cancel
617
+ </DialogButton>
618
+ <DialogButton type="primary" onClick={handleUpdate}>
619
+ Update Now
620
+ </DialogButton>
621
+ </div>
622
+ </Dialog>
623
+ </DialogRoot>
624
+ </div>
625
+ );
626
+ };
627
+
628
+ export default UpdateTab;
app/components/@settings/utils/animations.ts ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Variants } from 'framer-motion';
2
+
3
+ export const fadeIn: Variants = {
4
+ initial: { opacity: 0 },
5
+ animate: { opacity: 1 },
6
+ exit: { opacity: 0 },
7
+ };
8
+
9
+ export const slideIn: Variants = {
10
+ initial: { opacity: 0, y: 20 },
11
+ animate: { opacity: 1, y: 0 },
12
+ exit: { opacity: 0, y: -20 },
13
+ };
14
+
15
+ export const scaleIn: Variants = {
16
+ initial: { opacity: 0, scale: 0.8 },
17
+ animate: { opacity: 1, scale: 1 },
18
+ exit: { opacity: 0, scale: 0.8 },
19
+ };
20
+
21
+ export const tabAnimation: Variants = {
22
+ initial: { opacity: 0, scale: 0.8, y: 20 },
23
+ animate: { opacity: 1, scale: 1, y: 0 },
24
+ exit: { opacity: 0, scale: 0.8, y: -20 },
25
+ };
26
+
27
+ export const overlayAnimation: Variants = {
28
+ initial: { opacity: 0 },
29
+ animate: { opacity: 1 },
30
+ exit: { opacity: 0 },
31
+ };
32
+
33
+ export const modalAnimation: Variants = {
34
+ initial: { opacity: 0, scale: 0.95, y: 20 },
35
+ animate: { opacity: 1, scale: 1, y: 0 },
36
+ exit: { opacity: 0, scale: 0.95, y: 20 },
37
+ };
38
+
39
+ export const transition = {
40
+ duration: 0.2,
41
+ };
app/components/@settings/utils/tab-helpers.ts ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { TabType, TabVisibilityConfig } from '~/components/@settings/core/types';
2
+ import { DEFAULT_TAB_CONFIG } from '~/components/@settings/core/constants';
3
+
4
+ export const getVisibleTabs = (
5
+ tabConfiguration: { userTabs: TabVisibilityConfig[]; developerTabs?: TabVisibilityConfig[] },
6
+ isDeveloperMode: boolean,
7
+ notificationsEnabled: boolean,
8
+ ): TabVisibilityConfig[] => {
9
+ if (!tabConfiguration?.userTabs || !Array.isArray(tabConfiguration.userTabs)) {
10
+ console.warn('Invalid tab configuration, using defaults');
11
+ return DEFAULT_TAB_CONFIG as TabVisibilityConfig[];
12
+ }
13
+
14
+ // In developer mode, show ALL tabs without restrictions
15
+ if (isDeveloperMode) {
16
+ // Combine all unique tabs from both user and developer configurations
17
+ const allTabs = new Set([
18
+ ...DEFAULT_TAB_CONFIG.map((tab) => tab.id),
19
+ ...tabConfiguration.userTabs.map((tab) => tab.id),
20
+ ...(tabConfiguration.developerTabs || []).map((tab) => tab.id),
21
+ 'task-manager' as TabType, // Always include task-manager in developer mode
22
+ ]);
23
+
24
+ // Create a complete tab list with all tabs visible
25
+ const devTabs = Array.from(allTabs).map((tabId) => {
26
+ // Try to find existing configuration for this tab
27
+ const existingTab =
28
+ tabConfiguration.developerTabs?.find((t) => t.id === tabId) ||
29
+ tabConfiguration.userTabs?.find((t) => t.id === tabId) ||
30
+ DEFAULT_TAB_CONFIG.find((t) => t.id === tabId);
31
+
32
+ return {
33
+ id: tabId as TabType,
34
+ visible: true,
35
+ window: 'developer' as const,
36
+ order: existingTab?.order || DEFAULT_TAB_CONFIG.findIndex((t) => t.id === tabId),
37
+ } as TabVisibilityConfig;
38
+ });
39
+
40
+ return devTabs.sort((a, b) => a.order - b.order);
41
+ }
42
+
43
+ // In user mode, only show visible user tabs
44
+ return tabConfiguration.userTabs
45
+ .filter((tab) => {
46
+ if (!tab || typeof tab.id !== 'string') {
47
+ console.warn('Invalid tab entry:', tab);
48
+ return false;
49
+ }
50
+
51
+ // Hide notifications tab if notifications are disabled
52
+ if (tab.id === 'notifications' && !notificationsEnabled) {
53
+ return false;
54
+ }
55
+
56
+ // Always show task-manager in user mode if it's configured as visible
57
+ if (tab.id === 'task-manager') {
58
+ return tab.visible;
59
+ }
60
+
61
+ // Only show tabs that are explicitly visible and assigned to the user window
62
+ return tab.visible && tab.window === 'user';
63
+ })
64
+ .sort((a, b) => a.order - b.order);
65
+ };
66
+
67
+ export const reorderTabs = (
68
+ tabs: TabVisibilityConfig[],
69
+ startIndex: number,
70
+ endIndex: number,
71
+ ): TabVisibilityConfig[] => {
72
+ const result = Array.from(tabs);
73
+ const [removed] = result.splice(startIndex, 1);
74
+ result.splice(endIndex, 0, removed);
75
+
76
+ // Update order property
77
+ return result.map((tab, index) => ({
78
+ ...tab,
79
+ order: index,
80
+ }));
81
+ };
82
+
83
+ export const resetToDefaultConfig = (isDeveloperMode: boolean): TabVisibilityConfig[] => {
84
+ return DEFAULT_TAB_CONFIG.map((tab) => ({
85
+ ...tab,
86
+ visible: isDeveloperMode ? true : tab.window === 'user',
87
+ window: isDeveloperMode ? 'developer' : tab.window,
88
+ })) as TabVisibilityConfig[];
89
+ };
app/components/chat/APIKeyManager.tsx ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useCallback } from 'react';
2
+ import { IconButton } from '~/components/ui/IconButton';
3
+ import { Switch } from '~/components/ui/Switch';
4
+ import type { ProviderInfo } from '~/types/model';
5
+ import Cookies from 'js-cookie';
6
+ interface APIKeyManagerProps {
7
+ provider: ProviderInfo;
8
+ apiKey: string;
9
+ setApiKey: (key: string) => void;
10
+ getApiKeyLink?: string;
11
+ labelForGetApiKey?: string;
12
+ }
13
+
14
+ // cache which stores whether the provider's API key is set via environment variable
15
+ const providerEnvKeyStatusCache: Record<string, boolean> = {};
16
+
17
+ const apiKeyMemoizeCache: { [k: string]: Record<string, string> } = {};
18
+
19
+ export function getApiKeysFromCookies() {
20
+ const storedApiKeys = Cookies.get('apiKeys');
21
+ let parsedKeys: Record<string, string> = {};
22
+
23
+ if (storedApiKeys) {
24
+ parsedKeys = apiKeyMemoizeCache[storedApiKeys];
25
+
26
+ if (!parsedKeys) {
27
+ parsedKeys = apiKeyMemoizeCache[storedApiKeys] = JSON.parse(storedApiKeys);
28
+ }
29
+ }
30
+
31
+ return parsedKeys;
32
+ }
33
+
34
+ // eslint-disable-next-line @typescript-eslint/naming-convention
35
+ export const APIKeyManager: React.FC<APIKeyManagerProps> = ({ provider, apiKey, setApiKey }) => {
36
+ const [isEditing, setIsEditing] = useState(false);
37
+ const [tempKey, setTempKey] = useState(apiKey);
38
+ const [isPromptCachingEnabled, setIsPromptCachingEnabled] = useState(() => {
39
+ // Read initial state from localStorage, defaulting to true
40
+ const savedState = localStorage.getItem('PROMPT_CACHING_ENABLED');
41
+ return savedState !== null ? JSON.parse(savedState) : true;
42
+ });
43
+ const [isEnvKeySet, setIsEnvKeySet] = useState(false);
44
+
45
+ useEffect(() => {
46
+ // Update localStorage whenever the prompt caching state changes
47
+ localStorage.setItem('PROMPT_CACHING_ENABLED', JSON.stringify(isPromptCachingEnabled));
48
+ }, [isPromptCachingEnabled]);
49
+
50
+ // Reset states and load saved key when provider changes
51
+ useEffect(() => {
52
+ // Load saved API key from cookies for this provider
53
+ const savedKeys = getApiKeysFromCookies();
54
+ const savedKey = savedKeys[provider.name] || '';
55
+
56
+ setTempKey(savedKey);
57
+ setApiKey(savedKey);
58
+ setIsEditing(false);
59
+ }, [provider.name]);
60
+
61
+ const checkEnvApiKey = useCallback(async () => {
62
+ // Check cache first
63
+ if (providerEnvKeyStatusCache[provider.name] !== undefined) {
64
+ setIsEnvKeySet(providerEnvKeyStatusCache[provider.name]);
65
+ return;
66
+ }
67
+
68
+ try {
69
+ const response = await fetch(`/api/check-env-key?provider=${encodeURIComponent(provider.name)}`);
70
+ const data = await response.json();
71
+ const isSet = (data as { isSet: boolean }).isSet;
72
+
73
+ // Cache the result
74
+ providerEnvKeyStatusCache[provider.name] = isSet;
75
+ setIsEnvKeySet(isSet);
76
+ } catch (error) {
77
+ console.error('Failed to check environment API key:', error);
78
+ setIsEnvKeySet(false);
79
+ }
80
+ }, [provider.name]);
81
+
82
+ useEffect(() => {
83
+ checkEnvApiKey();
84
+ }, [checkEnvApiKey]);
85
+
86
+ const handleSave = () => {
87
+ // Save to parent state
88
+ setApiKey(tempKey);
89
+
90
+ // Save to cookies
91
+ const currentKeys = getApiKeysFromCookies();
92
+ const newKeys = { ...currentKeys, [provider.name]: tempKey };
93
+ Cookies.set('apiKeys', JSON.stringify(newKeys));
94
+
95
+ setIsEditing(false);
96
+ };
97
+
98
+ return (
99
+ <div className="flex flex-col items-left justify-between py-3 px-1">
100
+ <div className="flex">
101
+ <div className="flex items-center gap-2 flex-1">
102
+ <div className="flex items-center gap-2">
103
+ <span className="text-sm font-medium text-bolt-elements-textSecondary">{provider?.name} API Key:</span>
104
+ {!isEditing && (
105
+ <div className="flex items-center gap-2">
106
+ {apiKey ? (
107
+ <>
108
+ <div className="i-ph:check-circle-fill text-green-500 w-4 h-4" />
109
+ <span className="text-xs text-green-500">Set via UI</span>
110
+ </>
111
+ ) : isEnvKeySet ? (
112
+ <>
113
+ <div className="i-ph:check-circle-fill text-green-500 w-4 h-4" />
114
+ <span className="text-xs text-green-500">Set via environment variable</span>
115
+ </>
116
+ ) : (
117
+ <>
118
+ <div className="i-ph:x-circle-fill text-red-500 w-4 h-4" />
119
+ <span className="text-xs text-red-500">Not Set (Please set via UI or ENV_VAR)</span>
120
+ </>
121
+ )}
122
+ </div>
123
+ )}
124
+ </div>
125
+ </div>
126
+
127
+ <div className="flex items-center gap-2 shrink-0">
128
+ {isEditing ? (
129
+ <div className="flex items-center gap-2">
130
+ <input
131
+ type="password"
132
+ value={tempKey}
133
+ placeholder="Enter API Key"
134
+ onChange={(e) => setTempKey(e.target.value)}
135
+ className="w-[300px] px-3 py-1.5 text-sm rounded border border-bolt-elements-borderColor
136
+ bg-bolt-elements-prompt-background text-bolt-elements-textPrimary
137
+ focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus"
138
+ />
139
+ <IconButton
140
+ onClick={handleSave}
141
+ title="Save API Key"
142
+ className="bg-green-500/10 hover:bg-green-500/20 text-green-500"
143
+ >
144
+ <div className="i-ph:check w-4 h-4" />
145
+ </IconButton>
146
+ <IconButton
147
+ onClick={() => setIsEditing(false)}
148
+ title="Cancel"
149
+ className="bg-red-500/10 hover:bg-red-500/20 text-red-500"
150
+ >
151
+ <div className="i-ph:x w-4 h-4" />
152
+ </IconButton>
153
+ </div>
154
+ ) : (
155
+ <>
156
+ {
157
+ <IconButton
158
+ onClick={() => setIsEditing(true)}
159
+ title="Edit API Key"
160
+ className="bg-blue-500/10 hover:bg-blue-500/20 text-blue-500"
161
+ >
162
+ <div className="i-ph:pencil-simple w-4 h-4" />
163
+ </IconButton>
164
+ }
165
+ {provider?.getApiKeyLink && !apiKey && (
166
+ <IconButton
167
+ onClick={() => window.open(provider?.getApiKeyLink)}
168
+ title="Get API Key"
169
+ className="bg-purple-500/10 hover:bg-purple-500/20 text-purple-500 flex items-center gap-2"
170
+ >
171
+ <span className="text-xs whitespace-nowrap">{provider?.labelForGetApiKey || 'Get API Key'}</span>
172
+ <div className={`${provider?.icon || 'i-ph:key'} w-4 h-4`} />
173
+ </IconButton>
174
+ )}
175
+ </>
176
+ )}
177
+ </div>
178
+ </div>
179
+
180
+ {provider?.name === 'Anthropic' && (
181
+ <div className="border-t mt-4 pt-4 pb-2 -mt-4">
182
+ <div className="flex items-center space-x-2">
183
+ <Switch checked={isPromptCachingEnabled} onCheckedChange={setIsPromptCachingEnabled} />
184
+ <label htmlFor="prompt-caching" className="text-sm text-bolt-elements-textSecondary">
185
+ Enable Prompt Caching
186
+ </label>
187
+ </div>
188
+ <p className="text-xs text-bolt-elements-textTertiary mt-2">
189
+ When enabled, generates 10x cheaper responses if re-prompted within 5 mins (Recommended)
190
+ </p>
191
+ </div>
192
+ )}
193
+ </div>
194
+ );
195
+ };