Final UI V3
Browse files# UI V3 Changelog
Major updates and improvements in this release:
## Core Changes
- Complete NEW REWRITTEN UI system overhaul (V3) with semantic design tokens
- New settings management system with drag-and-drop capabilities
- Enhanced provider system supporting multiple AI services
- Improved theme system with better dark mode support
- New component library with consistent design patterns
## Technical Updates
- Reorganized project architecture for better maintainability
- Performance optimizations and bundle size improvements
- Enhanced security features and access controls
- Improved developer experience with better tooling
- Comprehensive testing infrastructure
## New Features
- Background rays effect for improved visual feedback
- Advanced tab management system
- Automatic and manual update support
- Enhanced error handling and visualization
- Improved accessibility across all components
For detailed information about all changes and improvements, please see the full changelog.
- .gitignore +1 -0
- app/components/@settings/core/AvatarDropdown.tsx +181 -0
- app/components/{settings β @settings/core}/ControlPanel.tsx +197 -345
- app/components/@settings/core/constants.ts +88 -0
- app/components/{settings/settings.types.ts β @settings/core/types.ts} +31 -38
- app/components/@settings/index.ts +14 -0
- app/components/{settings/shared β @settings/shared/components}/DraggableTabList.tsx +2 -2
- app/components/@settings/shared/components/TabManagement.tsx +259 -0
- app/components/{settings/shared β @settings/shared/components}/TabTile.tsx +86 -158
- app/components/{settings β @settings/tabs}/connections/ConnectionsTab.tsx +0 -0
- app/components/{settings β @settings/tabs}/connections/components/ConnectionForm.tsx +1 -1
- app/components/{settings β @settings/tabs}/connections/components/CreateBranchDialog.tsx +1 -1
- app/components/{settings β @settings/tabs}/connections/components/PushToGitHubDialog.tsx +0 -0
- app/components/{settings β @settings/tabs}/connections/components/RepositorySelectionDialog.tsx +0 -0
- app/components/{settings β @settings/tabs}/connections/types/GitHub.ts +0 -0
- app/components/{settings β @settings/tabs}/data/DataTab.tsx +0 -0
- app/components/{settings β @settings/tabs}/debug/DebugTab.tsx +133 -36
- app/components/@settings/tabs/event-logs/EventLogsTab.tsx +613 -0
- app/components/{settings β @settings/tabs}/features/FeaturesTab.tsx +39 -38
- app/components/{settings β @settings/tabs}/notifications/NotificationsTab.tsx +0 -0
- app/components/@settings/tabs/profile/ProfileTab.tsx +174 -0
- app/components/{settings/providers β @settings/tabs/providers/cloud}/CloudProvidersTab.tsx +0 -0
- app/components/{settings/providers β @settings/tabs/providers/local}/LocalProvidersTab.tsx +362 -558
- app/components/@settings/tabs/providers/local/OllamaModelInstaller.tsx +597 -0
- app/components/{settings β @settings/tabs}/providers/service-status/ServiceStatusTab.tsx +0 -0
- app/components/{settings β @settings/tabs}/providers/service-status/base-provider.ts +0 -0
- app/components/{settings β @settings/tabs}/providers/service-status/provider-factory.ts +0 -0
- app/components/{settings β @settings/tabs}/providers/service-status/providers/amazon-bedrock.ts +2 -2
- app/components/{settings β @settings/tabs}/providers/service-status/providers/anthropic.ts +2 -2
- app/components/{settings β @settings/tabs}/providers/service-status/providers/cohere.ts +2 -2
- app/components/{settings β @settings/tabs}/providers/service-status/providers/deepseek.ts +2 -2
- app/components/{settings β @settings/tabs}/providers/service-status/providers/google.ts +2 -2
- app/components/{settings β @settings/tabs}/providers/service-status/providers/groq.ts +2 -2
- app/components/{settings β @settings/tabs}/providers/service-status/providers/huggingface.ts +2 -2
- app/components/{settings β @settings/tabs}/providers/service-status/providers/hyperbolic.ts +2 -2
- app/components/{settings β @settings/tabs}/providers/service-status/providers/mistral.ts +2 -2
- app/components/{settings β @settings/tabs}/providers/service-status/providers/openai.ts +2 -2
- app/components/{settings β @settings/tabs}/providers/service-status/providers/openrouter.ts +2 -2
- app/components/{settings β @settings/tabs}/providers/service-status/providers/perplexity.ts +2 -2
- app/components/{settings β @settings/tabs}/providers/service-status/providers/together.ts +2 -2
- app/components/{settings β @settings/tabs}/providers/service-status/providers/xai.ts +2 -2
- app/components/{settings β @settings/tabs}/providers/service-status/types.ts +0 -0
- app/components/{settings/providers β @settings/tabs/providers/status}/ServiceStatusTab.tsx +0 -0
- app/components/{settings β @settings/tabs}/settings/SettingsTab.tsx +1 -1
- app/components/{settings β @settings/tabs}/task-manager/TaskManagerTab.tsx +336 -174
- app/components/{settings β @settings/tabs}/update/UpdateTab.tsx +84 -94
- app/components/@settings/utils/animations.ts +41 -0
- app/components/@settings/utils/tab-helpers.ts +89 -0
- app/components/chat/Chat.client.tsx +32 -25
- app/components/chat/GitCloneButton.tsx +1 -1
@@ -44,3 +44,4 @@ changelogUI.md
|
|
44 |
docs/instructions/Roadmap.md
|
45 |
.cursorrules
|
46 |
.cursorrules
|
|
|
|
44 |
docs/instructions/Roadmap.md
|
45 |
.cursorrules
|
46 |
.cursorrules
|
47 |
+
*.md
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
+
interface AvatarDropdownProps {
|
9 |
+
onSelectTab: (tab: TabType) => void;
|
10 |
+
}
|
11 |
+
|
12 |
+
export const AvatarDropdown = ({ onSelectTab }: AvatarDropdownProps) => {
|
13 |
+
const profile = useStore(profileStore) as Profile;
|
14 |
+
|
15 |
+
return (
|
16 |
+
<DropdownMenu.Root>
|
17 |
+
<DropdownMenu.Trigger asChild>
|
18 |
+
<motion.button
|
19 |
+
className="group flex items-center justify-center"
|
20 |
+
whileHover={{ scale: 1.02 }}
|
21 |
+
whileTap={{ scale: 0.98 }}
|
22 |
+
>
|
23 |
+
<div
|
24 |
+
className={classNames(
|
25 |
+
'w-10 h-10',
|
26 |
+
'rounded-full overflow-hidden',
|
27 |
+
'bg-gray-100/50 dark:bg-gray-800/50',
|
28 |
+
'flex items-center justify-center',
|
29 |
+
'ring-1 ring-gray-200/50 dark:ring-gray-700/50',
|
30 |
+
'group-hover:ring-purple-500/50 dark:group-hover:ring-purple-500/50',
|
31 |
+
'group-hover:bg-purple-500/10 dark:group-hover:bg-purple-500/10',
|
32 |
+
'transition-all duration-200',
|
33 |
+
'relative',
|
34 |
+
)}
|
35 |
+
>
|
36 |
+
{profile?.avatar ? (
|
37 |
+
<div className="w-full h-full">
|
38 |
+
<img
|
39 |
+
src={profile.avatar}
|
40 |
+
alt={profile?.username || 'Profile'}
|
41 |
+
className={classNames(
|
42 |
+
'w-full h-full',
|
43 |
+
'object-cover',
|
44 |
+
'transform-gpu',
|
45 |
+
'image-rendering-crisp',
|
46 |
+
'group-hover:brightness-110',
|
47 |
+
'group-hover:scale-105',
|
48 |
+
'transition-all duration-200',
|
49 |
+
)}
|
50 |
+
loading="eager"
|
51 |
+
decoding="sync"
|
52 |
+
/>
|
53 |
+
<div
|
54 |
+
className={classNames(
|
55 |
+
'absolute inset-0',
|
56 |
+
'ring-1 ring-inset ring-black/5 dark:ring-white/5',
|
57 |
+
'group-hover:ring-purple-500/20 dark:group-hover:ring-purple-500/20',
|
58 |
+
'group-hover:bg-purple-500/5 dark:group-hover:bg-purple-500/5',
|
59 |
+
'transition-colors duration-200',
|
60 |
+
)}
|
61 |
+
/>
|
62 |
+
</div>
|
63 |
+
) : (
|
64 |
+
<div className="i-ph:robot-fill w-6 h-6 text-gray-400 dark:text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
|
65 |
+
)}
|
66 |
+
</div>
|
67 |
+
</motion.button>
|
68 |
+
</DropdownMenu.Trigger>
|
69 |
+
|
70 |
+
<DropdownMenu.Portal>
|
71 |
+
<DropdownMenu.Content
|
72 |
+
className={classNames(
|
73 |
+
'min-w-[240px] z-[250]',
|
74 |
+
'bg-white dark:bg-[#141414]',
|
75 |
+
'rounded-lg shadow-lg',
|
76 |
+
'border border-gray-200/50 dark:border-gray-800/50',
|
77 |
+
'animate-in fade-in-0 zoom-in-95',
|
78 |
+
'py-1',
|
79 |
+
)}
|
80 |
+
sideOffset={5}
|
81 |
+
align="end"
|
82 |
+
>
|
83 |
+
<div
|
84 |
+
className={classNames(
|
85 |
+
'px-4 py-3 flex items-center gap-3',
|
86 |
+
'border-b border-gray-200/50 dark:border-gray-800/50',
|
87 |
+
)}
|
88 |
+
>
|
89 |
+
<div className="w-10 h-10 rounded-full overflow-hidden bg-gray-100/50 dark:bg-gray-800/50 flex-shrink-0">
|
90 |
+
{profile?.avatar ? (
|
91 |
+
<img
|
92 |
+
src={profile.avatar}
|
93 |
+
alt={profile?.username || 'Profile'}
|
94 |
+
className={classNames('w-full h-full', 'object-cover', 'transform-gpu', 'image-rendering-crisp')}
|
95 |
+
loading="eager"
|
96 |
+
decoding="sync"
|
97 |
+
/>
|
98 |
+
) : (
|
99 |
+
<div className="w-full h-full flex items-center justify-center">
|
100 |
+
<div className="i-ph:robot-fill w-6 h-6 text-gray-400 dark:text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
|
101 |
+
</div>
|
102 |
+
)}
|
103 |
+
</div>
|
104 |
+
<div className="flex-1 min-w-0">
|
105 |
+
<div className="font-medium text-sm text-gray-900 dark:text-white truncate">
|
106 |
+
{profile?.username || 'Guest User'}
|
107 |
+
</div>
|
108 |
+
{profile?.bio && <div className="text-xs text-gray-500 dark:text-gray-400 truncate">{profile.bio}</div>}
|
109 |
+
</div>
|
110 |
+
</div>
|
111 |
+
|
112 |
+
<DropdownMenu.Item
|
113 |
+
className={classNames(
|
114 |
+
'flex items-center gap-2 px-4 py-2.5',
|
115 |
+
'text-sm text-gray-700 dark:text-gray-200',
|
116 |
+
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
|
117 |
+
'hover:text-purple-500 dark:hover:text-purple-400',
|
118 |
+
'cursor-pointer transition-all duration-200',
|
119 |
+
'outline-none',
|
120 |
+
'group',
|
121 |
+
)}
|
122 |
+
onClick={() => onSelectTab('profile')}
|
123 |
+
>
|
124 |
+
<div className="i-ph:robot-fill w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
|
125 |
+
Edit Profile
|
126 |
+
</DropdownMenu.Item>
|
127 |
+
|
128 |
+
<DropdownMenu.Item
|
129 |
+
className={classNames(
|
130 |
+
'flex items-center gap-2 px-4 py-2.5',
|
131 |
+
'text-sm text-gray-700 dark:text-gray-200',
|
132 |
+
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
|
133 |
+
'hover:text-purple-500 dark:hover:text-purple-400',
|
134 |
+
'cursor-pointer transition-all duration-200',
|
135 |
+
'outline-none',
|
136 |
+
'group',
|
137 |
+
)}
|
138 |
+
onClick={() => onSelectTab('settings')}
|
139 |
+
>
|
140 |
+
<div className="i-ph:gear-six-fill w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
|
141 |
+
Settings
|
142 |
+
</DropdownMenu.Item>
|
143 |
+
|
144 |
+
<div className="my-1 border-t border-gray-200/50 dark:border-gray-800/50" />
|
145 |
+
|
146 |
+
<DropdownMenu.Item
|
147 |
+
className={classNames(
|
148 |
+
'flex items-center gap-2 px-4 py-2.5',
|
149 |
+
'text-sm text-gray-700 dark:text-gray-200',
|
150 |
+
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
|
151 |
+
'hover:text-purple-500 dark:hover:text-purple-400',
|
152 |
+
'cursor-pointer transition-all duration-200',
|
153 |
+
'outline-none',
|
154 |
+
'group',
|
155 |
+
)}
|
156 |
+
onClick={() => onSelectTab('task-manager')}
|
157 |
+
>
|
158 |
+
<div className="i-ph:activity-fill w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
|
159 |
+
Task Manager
|
160 |
+
</DropdownMenu.Item>
|
161 |
+
|
162 |
+
<DropdownMenu.Item
|
163 |
+
className={classNames(
|
164 |
+
'flex items-center gap-2 px-4 py-2.5',
|
165 |
+
'text-sm text-gray-700 dark:text-gray-200',
|
166 |
+
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
|
167 |
+
'hover:text-purple-500 dark:hover:text-purple-400',
|
168 |
+
'cursor-pointer transition-all duration-200',
|
169 |
+
'outline-none',
|
170 |
+
'group',
|
171 |
+
)}
|
172 |
+
onClick={() => onSelectTab('service-status')}
|
173 |
+
>
|
174 |
+
<div className="i-ph:heartbeat-fill w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
|
175 |
+
Service Status
|
176 |
+
</DropdownMenu.Item>
|
177 |
+
</DropdownMenu.Content>
|
178 |
+
</DropdownMenu.Portal>
|
179 |
+
</DropdownMenu.Root>
|
180 |
+
);
|
181 |
+
};
|
@@ -3,37 +3,36 @@ 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 { DndProvider } from 'react-dnd';
|
7 |
-
import { HTML5Backend } from 'react-dnd-html5-backend';
|
8 |
import { classNames } from '~/utils/classNames';
|
9 |
-
import { TabManagement } from '
|
10 |
-
import { TabTile } from '
|
11 |
import { useUpdateCheck } from '~/lib/hooks/useUpdateCheck';
|
12 |
import { useFeatures } from '~/lib/hooks/useFeatures';
|
13 |
import { useNotifications } from '~/lib/hooks/useNotifications';
|
14 |
import { useConnectionStatus } from '~/lib/hooks/useConnectionStatus';
|
15 |
import { useDebugStatus } from '~/lib/hooks/useDebugStatus';
|
16 |
import { tabConfigurationStore, developerModeStore, setDeveloperMode } from '~/lib/stores/settings';
|
17 |
-
import
|
18 |
-
import {
|
|
|
19 |
import { resetTabConfiguration } from '~/lib/stores/settings';
|
20 |
import { DialogTitle } from '~/components/ui/Dialog';
|
21 |
-
import {
|
22 |
|
23 |
// Import all tab components
|
24 |
-
import ProfileTab from '
|
25 |
-
import SettingsTab from '
|
26 |
-
import NotificationsTab from '
|
27 |
-
import FeaturesTab from '
|
28 |
-
import DataTab from '
|
29 |
-
import DebugTab from '
|
30 |
-
import { EventLogsTab } from '
|
31 |
-
import UpdateTab from '
|
32 |
-
import ConnectionsTab from '
|
33 |
-
import CloudProvidersTab from '
|
34 |
-
import ServiceStatusTab from '
|
35 |
-
import LocalProvidersTab from '
|
36 |
-
import TaskManagerTab from '
|
37 |
|
38 |
interface ControlPanelProps {
|
39 |
open: boolean;
|
@@ -58,124 +57,7 @@ const TAB_DESCRIPTIONS: Record<TabType, string> = {
|
|
58 |
'event-logs': 'View system events and logs',
|
59 |
update: 'Check for updates and release notes',
|
60 |
'task-manager': 'Monitor system resources and processes',
|
61 |
-
|
62 |
-
|
63 |
-
// Add DraggableTabTile component before the ControlPanel component
|
64 |
-
const DraggableTabTile = ({
|
65 |
-
tab,
|
66 |
-
index,
|
67 |
-
moveTab,
|
68 |
-
...props
|
69 |
-
}: {
|
70 |
-
tab: TabWithDevType;
|
71 |
-
index: number;
|
72 |
-
moveTab: (dragIndex: number, hoverIndex: number) => void;
|
73 |
-
onClick: () => void;
|
74 |
-
isActive: boolean;
|
75 |
-
hasUpdate: boolean;
|
76 |
-
statusMessage: string;
|
77 |
-
description: string;
|
78 |
-
isLoading?: boolean;
|
79 |
-
}) => {
|
80 |
-
const [{ isDragging }, drag] = useDrag({
|
81 |
-
type: 'tab',
|
82 |
-
item: { index, id: tab.id },
|
83 |
-
collect: (monitor) => ({
|
84 |
-
isDragging: monitor.isDragging(),
|
85 |
-
}),
|
86 |
-
});
|
87 |
-
|
88 |
-
const [{ isOver, canDrop }, drop] = useDrop({
|
89 |
-
accept: 'tab',
|
90 |
-
hover: (item: { index: number; id: string }, monitor) => {
|
91 |
-
if (!monitor.isOver({ shallow: true })) {
|
92 |
-
return;
|
93 |
-
}
|
94 |
-
|
95 |
-
if (item.id === tab.id) {
|
96 |
-
return;
|
97 |
-
}
|
98 |
-
|
99 |
-
if (item.index === index) {
|
100 |
-
return;
|
101 |
-
}
|
102 |
-
|
103 |
-
// Only move when hovering over the middle section
|
104 |
-
const hoverBoundingRect = monitor.getSourceClientOffset();
|
105 |
-
const clientOffset = monitor.getClientOffset();
|
106 |
-
|
107 |
-
if (!hoverBoundingRect || !clientOffset) {
|
108 |
-
return;
|
109 |
-
}
|
110 |
-
|
111 |
-
const hoverMiddleX = hoverBoundingRect.x + 150; // Half of typical card width
|
112 |
-
const hoverClientX = clientOffset.x;
|
113 |
-
|
114 |
-
// Only perform the move when the mouse has crossed half of the items width
|
115 |
-
if (item.index < index && hoverClientX < hoverMiddleX) {
|
116 |
-
return;
|
117 |
-
}
|
118 |
-
|
119 |
-
if (item.index > index && hoverClientX > hoverMiddleX) {
|
120 |
-
return;
|
121 |
-
}
|
122 |
-
|
123 |
-
moveTab(item.index, index);
|
124 |
-
item.index = index;
|
125 |
-
},
|
126 |
-
collect: (monitor) => ({
|
127 |
-
isOver: monitor.isOver({ shallow: true }),
|
128 |
-
canDrop: monitor.canDrop(),
|
129 |
-
}),
|
130 |
-
});
|
131 |
-
|
132 |
-
const dropIndicatorClasses = classNames('rounded-xl border-2 border-transparent transition-all duration-200', {
|
133 |
-
'ring-2 ring-purple-500 ring-opacity-50 bg-purple-50 dark:bg-purple-900/20': isOver,
|
134 |
-
'hover:ring-2 hover:ring-purple-500/30': canDrop && !isOver,
|
135 |
-
});
|
136 |
-
|
137 |
-
return (
|
138 |
-
<motion.div
|
139 |
-
ref={(node) => drag(drop(node))}
|
140 |
-
style={{
|
141 |
-
opacity: isDragging ? 0.5 : 1,
|
142 |
-
cursor: 'move',
|
143 |
-
position: 'relative',
|
144 |
-
zIndex: isDragging ? 100 : isOver ? 50 : 1,
|
145 |
-
}}
|
146 |
-
animate={{
|
147 |
-
scale: isDragging ? 1.02 : isOver ? 1.05 : 1,
|
148 |
-
boxShadow: isDragging
|
149 |
-
? '0 8px 24px rgba(0, 0, 0, 0.15)'
|
150 |
-
: isOver
|
151 |
-
? '0 4px 12px rgba(147, 51, 234, 0.3)'
|
152 |
-
: '0 0 0 rgba(0, 0, 0, 0)',
|
153 |
-
borderColor: isOver ? 'rgb(147, 51, 234)' : isDragging ? 'rgba(147, 51, 234, 0.5)' : 'transparent',
|
154 |
-
y: isOver ? -2 : 0,
|
155 |
-
}}
|
156 |
-
transition={{
|
157 |
-
type: 'spring',
|
158 |
-
stiffness: 500,
|
159 |
-
damping: 30,
|
160 |
-
mass: 0.8,
|
161 |
-
}}
|
162 |
-
className={dropIndicatorClasses}
|
163 |
-
>
|
164 |
-
<TabTile {...props} tab={tab} />
|
165 |
-
{isOver && (
|
166 |
-
<motion.div
|
167 |
-
className="absolute inset-0 rounded-xl pointer-events-none"
|
168 |
-
initial={{ opacity: 0 }}
|
169 |
-
animate={{ opacity: 1 }}
|
170 |
-
exit={{ opacity: 0 }}
|
171 |
-
transition={{ duration: 0.2 }}
|
172 |
-
>
|
173 |
-
<div className="absolute inset-0 bg-gradient-to-r from-purple-500/10 to-purple-500/20 rounded-xl" />
|
174 |
-
<div className="absolute inset-0 border-2 border-purple-500/50 rounded-xl" />
|
175 |
-
</motion.div>
|
176 |
-
)}
|
177 |
-
</motion.div>
|
178 |
-
);
|
179 |
};
|
180 |
|
181 |
export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
@@ -183,11 +65,11 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
|
183 |
const [activeTab, setActiveTab] = useState<TabType | null>(null);
|
184 |
const [loadingTab, setLoadingTab] = useState<TabType | null>(null);
|
185 |
const [showTabManagement, setShowTabManagement] = useState(false);
|
186 |
-
const [profile, setProfile] = useState({ avatar: null, notifications: true });
|
187 |
|
188 |
// Store values
|
189 |
const tabConfiguration = useStore(tabConfigurationStore);
|
190 |
const developerMode = useStore(developerModeStore);
|
|
|
191 |
|
192 |
// Status hooks
|
193 |
const { hasUpdate, currentVersion, acknowledgeUpdate } = useUpdateCheck();
|
@@ -196,24 +78,6 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
|
196 |
const { hasConnectionIssues, currentIssue, acknowledgeIssue } = useConnectionStatus();
|
197 |
const { hasActiveWarnings, activeIssues, acknowledgeAllIssues } = useDebugStatus();
|
198 |
|
199 |
-
// Initialize profile from localStorage on mount
|
200 |
-
useEffect(() => {
|
201 |
-
if (typeof window === 'undefined') {
|
202 |
-
return;
|
203 |
-
}
|
204 |
-
|
205 |
-
const saved = localStorage.getItem('bolt_user_profile');
|
206 |
-
|
207 |
-
if (saved) {
|
208 |
-
try {
|
209 |
-
const parsedProfile = JSON.parse(saved);
|
210 |
-
setProfile(parsedProfile);
|
211 |
-
} catch (error) {
|
212 |
-
console.warn('Failed to parse profile from localStorage:', error);
|
213 |
-
}
|
214 |
-
}
|
215 |
-
}, []);
|
216 |
-
|
217 |
// Add visibleTabs logic using useMemo
|
218 |
const visibleTabs = useMemo(() => {
|
219 |
if (!tabConfiguration?.userTabs || !Array.isArray(tabConfiguration.userTabs)) {
|
@@ -248,10 +112,22 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
|
248 |
};
|
249 |
});
|
250 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
251 |
return devTabs.sort((a, b) => a.order - b.order);
|
252 |
}
|
253 |
|
254 |
// In user mode, only show visible user tabs
|
|
|
|
|
255 |
return tabConfiguration.userTabs
|
256 |
.filter((tab) => {
|
257 |
if (!tab || typeof tab.id !== 'string') {
|
@@ -259,8 +135,8 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
|
259 |
return false;
|
260 |
}
|
261 |
|
262 |
-
// Hide notifications tab if notifications are disabled
|
263 |
-
if (tab.id === 'notifications' &&
|
264 |
return false;
|
265 |
}
|
266 |
|
@@ -268,38 +144,7 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
|
268 |
return tab.visible && tab.window === 'user';
|
269 |
})
|
270 |
.sort((a, b) => a.order - b.order);
|
271 |
-
}, [tabConfiguration, profile
|
272 |
-
|
273 |
-
// Add moveTab handler
|
274 |
-
const moveTab = (dragIndex: number, hoverIndex: number) => {
|
275 |
-
const newTabs = [...visibleTabs];
|
276 |
-
const dragTab = newTabs[dragIndex];
|
277 |
-
newTabs.splice(dragIndex, 1);
|
278 |
-
newTabs.splice(hoverIndex, 0, dragTab);
|
279 |
-
|
280 |
-
// Update the order of the tabs
|
281 |
-
const updatedTabs = newTabs.map((tab, index) => ({
|
282 |
-
...tab,
|
283 |
-
order: index,
|
284 |
-
window: 'developer' as const,
|
285 |
-
visible: true,
|
286 |
-
}));
|
287 |
-
|
288 |
-
// Update the tab configuration store directly
|
289 |
-
if (developerMode) {
|
290 |
-
// In developer mode, update developerTabs while preserving configuration
|
291 |
-
tabConfigurationStore.set({
|
292 |
-
...tabConfiguration,
|
293 |
-
developerTabs: updatedTabs,
|
294 |
-
});
|
295 |
-
} else {
|
296 |
-
// In user mode, update userTabs
|
297 |
-
tabConfigurationStore.set({
|
298 |
-
...tabConfiguration,
|
299 |
-
userTabs: updatedTabs.map((tab) => ({ ...tab, window: 'user' as const })),
|
300 |
-
});
|
301 |
-
}
|
302 |
-
};
|
303 |
|
304 |
// Handlers
|
305 |
const handleBack = () => {
|
@@ -320,8 +165,12 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
|
320 |
console.log('Current developer mode:', developerMode);
|
321 |
}, [developerMode]);
|
322 |
|
323 |
-
const getTabComponent = () => {
|
324 |
-
|
|
|
|
|
|
|
|
|
325 |
case 'profile':
|
326 |
return <ProfileTab />;
|
327 |
case 'settings':
|
@@ -398,6 +247,7 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
|
398 |
const handleTabClick = (tabId: TabType) => {
|
399 |
setLoadingTab(tabId);
|
400 |
setActiveTab(tabId);
|
|
|
401 |
|
402 |
// Acknowledge notifications based on tab
|
403 |
switch (tabId) {
|
@@ -423,84 +273,75 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
|
423 |
};
|
424 |
|
425 |
return (
|
426 |
-
<
|
427 |
-
<RadixDialog.
|
428 |
-
<
|
429 |
-
<
|
430 |
-
<
|
431 |
-
|
432 |
-
|
433 |
-
|
434 |
-
|
435 |
-
|
436 |
-
|
437 |
-
|
438 |
-
|
439 |
-
|
440 |
-
|
441 |
-
|
442 |
-
|
443 |
-
|
444 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
445 |
>
|
446 |
-
|
447 |
-
|
448 |
-
|
449 |
-
|
450 |
-
|
451 |
-
|
452 |
-
|
453 |
-
|
454 |
-
|
455 |
-
|
456 |
-
|
457 |
-
|
458 |
-
|
459 |
-
|
460 |
-
|
461 |
-
|
462 |
-
|
463 |
-
|
464 |
-
|
465 |
-
|
466 |
-
|
467 |
-
|
468 |
-
|
469 |
-
|
470 |
-
|
471 |
-
|
472 |
-
|
473 |
-
|
474 |
-
|
475 |
-
|
476 |
-
|
477 |
-
duration: 2,
|
478 |
-
ease: 'easeInOut',
|
479 |
-
}}
|
480 |
-
/>
|
481 |
-
)}
|
482 |
-
<DialogTitle className="text-xl font-semibold text-gray-900 dark:text-white">
|
483 |
-
{showTabManagement ? 'Tab Management' : activeTab ? TAB_LABELS[activeTab] : 'Control Panel'}
|
484 |
-
</DialogTitle>
|
485 |
-
</div>
|
486 |
|
487 |
-
|
488 |
-
|
489 |
-
|
490 |
-
|
491 |
-
|
492 |
-
className="flex items-center space-x-2 px-3 py-1.5 rounded-lg bg-gray-100 dark:bg-gray-800 hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
|
493 |
-
whileHover={{ scale: 1.05 }}
|
494 |
-
whileTap={{ scale: 0.95 }}
|
495 |
-
>
|
496 |
-
<div className="i-ph:sliders-horizontal w-4 h-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
497 |
-
<span className="text-sm text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors">
|
498 |
-
Manage Tabs
|
499 |
-
</span>
|
500 |
-
</motion.button>
|
501 |
-
)}
|
502 |
-
|
503 |
-
<div className="flex items-center gap-2">
|
504 |
<Switch
|
505 |
id="developer-mode"
|
506 |
checked={developerMode}
|
@@ -521,87 +362,98 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
|
521 |
)}
|
522 |
/>
|
523 |
</Switch>
|
524 |
-
<
|
525 |
-
|
526 |
-
|
527 |
-
|
528 |
-
|
529 |
-
|
|
|
|
|
530 |
</div>
|
|
|
531 |
|
532 |
-
|
533 |
-
|
534 |
-
|
535 |
-
>
|
536 |
-
<div className="i-ph:x w-4 h-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
537 |
-
</button>
|
538 |
</div>
|
539 |
-
</div>
|
540 |
|
541 |
-
|
542 |
-
|
543 |
-
|
544 |
-
|
545 |
-
'overflow-y-auto',
|
546 |
-
'hover:overflow-y-auto',
|
547 |
-
'scrollbar scrollbar-w-2',
|
548 |
-
'scrollbar-track-transparent',
|
549 |
-
'scrollbar-thumb-[#E5E5E5] hover:scrollbar-thumb-[#CCCCCC]',
|
550 |
-
'dark:scrollbar-thumb-[#333333] dark:hover:scrollbar-thumb-[#444444]',
|
551 |
-
'will-change-scroll',
|
552 |
-
'touch-auto',
|
553 |
-
)}
|
554 |
-
>
|
555 |
-
<motion.div
|
556 |
-
key={activeTab || 'home'}
|
557 |
-
initial={{ opacity: 0 }}
|
558 |
-
animate={{ opacity: 1 }}
|
559 |
-
exit={{ opacity: 0 }}
|
560 |
-
transition={{ duration: 0.2 }}
|
561 |
-
className="p-6"
|
562 |
>
|
563 |
-
|
564 |
-
|
565 |
-
) : activeTab ? (
|
566 |
-
getTabComponent()
|
567 |
-
) : (
|
568 |
-
<motion.div className="grid grid-cols-4 gap-4">
|
569 |
-
<AnimatePresence mode="popLayout">
|
570 |
-
{visibleTabs.map((tab: TabWithDevType, index: number) => (
|
571 |
-
<motion.div
|
572 |
-
key={tab.id}
|
573 |
-
layout
|
574 |
-
initial={{ opacity: 0, scale: 0.8, y: 20 }}
|
575 |
-
animate={{ opacity: 1, scale: 1, y: 0 }}
|
576 |
-
exit={{ opacity: 0, scale: 0.8, y: -20 }}
|
577 |
-
transition={{
|
578 |
-
duration: 0.2,
|
579 |
-
delay: index * 0.05,
|
580 |
-
}}
|
581 |
-
>
|
582 |
-
<DraggableTabTile
|
583 |
-
tab={tab}
|
584 |
-
index={index}
|
585 |
-
moveTab={moveTab}
|
586 |
-
onClick={() => handleTabClick(tab.id)}
|
587 |
-
isActive={activeTab === tab.id}
|
588 |
-
hasUpdate={getTabUpdateStatus(tab.id)}
|
589 |
-
statusMessage={getStatusMessage(tab.id)}
|
590 |
-
description={TAB_DESCRIPTIONS[tab.id]}
|
591 |
-
isLoading={loadingTab === tab.id}
|
592 |
-
/>
|
593 |
-
</motion.div>
|
594 |
-
))}
|
595 |
-
</AnimatePresence>
|
596 |
-
</motion.div>
|
597 |
-
)}
|
598 |
-
</motion.div>
|
599 |
</div>
|
600 |
-
</
|
601 |
-
|
602 |
-
|
603 |
-
|
604 |
-
|
605 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
606 |
);
|
607 |
};
|
|
|
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 { tabConfigurationStore, developerModeStore, setDeveloperMode } from '~/lib/stores/settings';
|
15 |
+
import { profileStore } from '~/lib/stores/profile';
|
16 |
+
import type { TabType, TabVisibilityConfig, DevTabConfig, Profile } from './types';
|
17 |
+
import { TAB_LABELS, DEFAULT_TAB_CONFIG } from './constants';
|
18 |
import { resetTabConfiguration } from '~/lib/stores/settings';
|
19 |
import { DialogTitle } from '~/components/ui/Dialog';
|
20 |
+
import { AvatarDropdown } from './AvatarDropdown';
|
21 |
|
22 |
// Import all tab components
|
23 |
+
import ProfileTab from '~/components/@settings/tabs/profile/ProfileTab';
|
24 |
+
import SettingsTab from '~/components/@settings/tabs/settings/SettingsTab';
|
25 |
+
import NotificationsTab from '~/components/@settings/tabs/notifications/NotificationsTab';
|
26 |
+
import FeaturesTab from '~/components/@settings/tabs/features/FeaturesTab';
|
27 |
+
import DataTab from '~/components/@settings/tabs/data/DataTab';
|
28 |
+
import DebugTab from '~/components/@settings/tabs/debug/DebugTab';
|
29 |
+
import { EventLogsTab } from '~/components/@settings/tabs/event-logs/EventLogsTab';
|
30 |
+
import UpdateTab from '~/components/@settings/tabs/update/UpdateTab';
|
31 |
+
import ConnectionsTab from '~/components/@settings/tabs/connections/ConnectionsTab';
|
32 |
+
import CloudProvidersTab from '~/components/@settings/tabs/providers/cloud/CloudProvidersTab';
|
33 |
+
import ServiceStatusTab from '~/components/@settings/tabs/providers/status/ServiceStatusTab';
|
34 |
+
import LocalProvidersTab from '~/components/@settings/tabs/providers/local/LocalProvidersTab';
|
35 |
+
import TaskManagerTab from '~/components/@settings/tabs/task-manager/TaskManagerTab';
|
36 |
|
37 |
interface ControlPanelProps {
|
38 |
open: boolean;
|
|
|
57 |
'event-logs': 'View system events and logs',
|
58 |
update: 'Check for updates and release notes',
|
59 |
'task-manager': 'Monitor system resources and processes',
|
60 |
+
'tab-management': 'Configure visible tabs and their order',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
61 |
};
|
62 |
|
63 |
export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
|
|
65 |
const [activeTab, setActiveTab] = useState<TabType | null>(null);
|
66 |
const [loadingTab, setLoadingTab] = useState<TabType | null>(null);
|
67 |
const [showTabManagement, setShowTabManagement] = useState(false);
|
|
|
68 |
|
69 |
// Store values
|
70 |
const tabConfiguration = useStore(tabConfigurationStore);
|
71 |
const developerMode = useStore(developerModeStore);
|
72 |
+
const profile = useStore(profileStore) as Profile;
|
73 |
|
74 |
// Status hooks
|
75 |
const { hasUpdate, currentVersion, acknowledgeUpdate } = useUpdateCheck();
|
|
|
78 |
const { hasConnectionIssues, currentIssue, acknowledgeIssue } = useConnectionStatus();
|
79 |
const { hasActiveWarnings, activeIssues, acknowledgeAllIssues } = useDebugStatus();
|
80 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
81 |
// Add visibleTabs logic using useMemo
|
82 |
const visibleTabs = useMemo(() => {
|
83 |
if (!tabConfiguration?.userTabs || !Array.isArray(tabConfiguration.userTabs)) {
|
|
|
112 |
};
|
113 |
});
|
114 |
|
115 |
+
// Add Tab Management tile for developer mode
|
116 |
+
const tabManagementConfig: DevTabConfig = {
|
117 |
+
id: 'tab-management',
|
118 |
+
visible: true,
|
119 |
+
window: 'developer',
|
120 |
+
order: devTabs.length,
|
121 |
+
isExtraDevTab: true,
|
122 |
+
};
|
123 |
+
devTabs.push(tabManagementConfig);
|
124 |
+
|
125 |
return devTabs.sort((a, b) => a.order - b.order);
|
126 |
}
|
127 |
|
128 |
// In user mode, only show visible user tabs
|
129 |
+
const notificationsDisabled = profile?.preferences?.notifications === false;
|
130 |
+
|
131 |
return tabConfiguration.userTabs
|
132 |
.filter((tab) => {
|
133 |
if (!tab || typeof tab.id !== 'string') {
|
|
|
135 |
return false;
|
136 |
}
|
137 |
|
138 |
+
// Hide notifications tab if notifications are disabled in user preferences
|
139 |
+
if (tab.id === 'notifications' && notificationsDisabled) {
|
140 |
return false;
|
141 |
}
|
142 |
|
|
|
144 |
return tab.visible && tab.window === 'user';
|
145 |
})
|
146 |
.sort((a, b) => a.order - b.order);
|
147 |
+
}, [tabConfiguration, developerMode, profile?.preferences?.notifications]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
148 |
|
149 |
// Handlers
|
150 |
const handleBack = () => {
|
|
|
165 |
console.log('Current developer mode:', developerMode);
|
166 |
}, [developerMode]);
|
167 |
|
168 |
+
const getTabComponent = (tabId: TabType | 'tab-management') => {
|
169 |
+
if (tabId === 'tab-management') {
|
170 |
+
return <TabManagement />;
|
171 |
+
}
|
172 |
+
|
173 |
+
switch (tabId) {
|
174 |
case 'profile':
|
175 |
return <ProfileTab />;
|
176 |
case 'settings':
|
|
|
247 |
const handleTabClick = (tabId: TabType) => {
|
248 |
setLoadingTab(tabId);
|
249 |
setActiveTab(tabId);
|
250 |
+
setShowTabManagement(false);
|
251 |
|
252 |
// Acknowledge notifications based on tab
|
253 |
switch (tabId) {
|
|
|
273 |
};
|
274 |
|
275 |
return (
|
276 |
+
<RadixDialog.Root open={open}>
|
277 |
+
<RadixDialog.Portal>
|
278 |
+
<div className="fixed inset-0 flex items-center justify-center z-[100]">
|
279 |
+
<RadixDialog.Overlay asChild>
|
280 |
+
<motion.div
|
281 |
+
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
282 |
+
initial={{ opacity: 0 }}
|
283 |
+
animate={{ opacity: 1 }}
|
284 |
+
exit={{ opacity: 0 }}
|
285 |
+
transition={{ duration: 0.2 }}
|
286 |
+
/>
|
287 |
+
</RadixDialog.Overlay>
|
288 |
+
|
289 |
+
<RadixDialog.Content
|
290 |
+
aria-describedby={undefined}
|
291 |
+
onEscapeKeyDown={onClose}
|
292 |
+
onPointerDownOutside={onClose}
|
293 |
+
className="relative z-[101]"
|
294 |
+
>
|
295 |
+
<motion.div
|
296 |
+
className={classNames(
|
297 |
+
'w-[1200px] h-[90vh]',
|
298 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
299 |
+
'rounded-2xl shadow-2xl',
|
300 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
301 |
+
'flex flex-col overflow-hidden',
|
302 |
+
)}
|
303 |
+
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
304 |
+
animate={{ opacity: 1, scale: 1, y: 0 }}
|
305 |
+
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
306 |
+
transition={{ duration: 0.2 }}
|
307 |
>
|
308 |
+
{/* Header */}
|
309 |
+
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
310 |
+
<div className="flex items-center space-x-4">
|
311 |
+
{activeTab || showTabManagement ? (
|
312 |
+
<button
|
313 |
+
onClick={handleBack}
|
314 |
+
className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-800 hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
|
315 |
+
>
|
316 |
+
<div className="i-ph:arrow-left w-4 h-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
317 |
+
</button>
|
318 |
+
) : (
|
319 |
+
<motion.div
|
320 |
+
className="w-7 h-7"
|
321 |
+
initial={{ rotate: -5 }}
|
322 |
+
animate={{ rotate: 5 }}
|
323 |
+
transition={{
|
324 |
+
repeat: Infinity,
|
325 |
+
repeatType: 'reverse',
|
326 |
+
duration: 2,
|
327 |
+
ease: 'easeInOut',
|
328 |
+
}}
|
329 |
+
>
|
330 |
+
<div className="w-full h-full flex items-center justify-center bg-gray-100/50 dark:bg-gray-800/50 rounded-full">
|
331 |
+
<div className="i-ph:robot-fill w-5 h-5 text-gray-400 dark:text-gray-400 transition-colors" />
|
332 |
+
</div>
|
333 |
+
</motion.div>
|
334 |
+
)}
|
335 |
+
<DialogTitle className="text-xl font-semibold text-gray-900 dark:text-white">
|
336 |
+
{showTabManagement ? 'Tab Management' : activeTab ? TAB_LABELS[activeTab] : 'Control Panel'}
|
337 |
+
</DialogTitle>
|
338 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
339 |
|
340 |
+
<div className="flex items-center gap-6">
|
341 |
+
{/* Developer Mode Controls */}
|
342 |
+
<div className="flex items-center gap-6">
|
343 |
+
{/* Mode Toggle */}
|
344 |
+
<div className="flex items-center gap-2 min-w-[140px] border-r border-gray-200 dark:border-gray-800 pr-6">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
345 |
<Switch
|
346 |
id="developer-mode"
|
347 |
checked={developerMode}
|
|
|
362 |
)}
|
363 |
/>
|
364 |
</Switch>
|
365 |
+
<div className="flex items-center gap-2">
|
366 |
+
<label
|
367 |
+
htmlFor="developer-mode"
|
368 |
+
className="text-sm text-gray-500 dark:text-gray-400 select-none cursor-pointer whitespace-nowrap w-[88px]"
|
369 |
+
>
|
370 |
+
{developerMode ? 'Developer Mode' : 'User Mode'}
|
371 |
+
</label>
|
372 |
+
</div>
|
373 |
</div>
|
374 |
+
</div>
|
375 |
|
376 |
+
{/* Avatar and Dropdown */}
|
377 |
+
<div className="border-l border-gray-200 dark:border-gray-800 pl-6">
|
378 |
+
<AvatarDropdown onSelectTab={handleTabClick} />
|
|
|
|
|
|
|
379 |
</div>
|
|
|
380 |
|
381 |
+
{/* Close Button */}
|
382 |
+
<button
|
383 |
+
onClick={onClose}
|
384 |
+
className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-800 hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
385 |
>
|
386 |
+
<div className="i-ph:x w-4 h-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
387 |
+
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
388 |
</div>
|
389 |
+
</div>
|
390 |
+
|
391 |
+
{/* Content */}
|
392 |
+
<div
|
393 |
+
className={classNames(
|
394 |
+
'flex-1',
|
395 |
+
'overflow-y-auto',
|
396 |
+
'hover:overflow-y-auto',
|
397 |
+
'scrollbar scrollbar-w-2',
|
398 |
+
'scrollbar-track-transparent',
|
399 |
+
'scrollbar-thumb-[#E5E5E5] hover:scrollbar-thumb-[#CCCCCC]',
|
400 |
+
'dark:scrollbar-thumb-[#333333] dark:hover:scrollbar-thumb-[#444444]',
|
401 |
+
'will-change-scroll',
|
402 |
+
'touch-auto',
|
403 |
+
)}
|
404 |
+
>
|
405 |
+
<motion.div
|
406 |
+
key={activeTab || 'home'}
|
407 |
+
initial={{ opacity: 0 }}
|
408 |
+
animate={{ opacity: 1 }}
|
409 |
+
exit={{ opacity: 0 }}
|
410 |
+
transition={{ duration: 0.2 }}
|
411 |
+
className="p-6"
|
412 |
+
>
|
413 |
+
{showTabManagement ? (
|
414 |
+
<TabManagement />
|
415 |
+
) : activeTab ? (
|
416 |
+
getTabComponent(activeTab)
|
417 |
+
) : (
|
418 |
+
<motion.div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 relative">
|
419 |
+
<AnimatePresence mode="popLayout">
|
420 |
+
{(visibleTabs as TabWithDevType[]).map((tab: TabWithDevType) => (
|
421 |
+
<motion.div
|
422 |
+
key={tab.id}
|
423 |
+
layout
|
424 |
+
initial={{ opacity: 0, scale: 0.8 }}
|
425 |
+
animate={{ opacity: 1, scale: 1 }}
|
426 |
+
exit={{ opacity: 0, scale: 0.8 }}
|
427 |
+
transition={{
|
428 |
+
type: 'spring',
|
429 |
+
stiffness: 400,
|
430 |
+
damping: 30,
|
431 |
+
mass: 0.8,
|
432 |
+
duration: 0.3,
|
433 |
+
}}
|
434 |
+
className="aspect-[1.5/1]"
|
435 |
+
>
|
436 |
+
<TabTile
|
437 |
+
tab={tab}
|
438 |
+
onClick={() => handleTabClick(tab.id as TabType)}
|
439 |
+
isActive={activeTab === tab.id}
|
440 |
+
hasUpdate={getTabUpdateStatus(tab.id)}
|
441 |
+
statusMessage={getStatusMessage(tab.id)}
|
442 |
+
description={TAB_DESCRIPTIONS[tab.id]}
|
443 |
+
isLoading={loadingTab === tab.id}
|
444 |
+
className="h-full"
|
445 |
+
/>
|
446 |
+
</motion.div>
|
447 |
+
))}
|
448 |
+
</AnimatePresence>
|
449 |
+
</motion.div>
|
450 |
+
)}
|
451 |
+
</motion.div>
|
452 |
+
</div>
|
453 |
+
</motion.div>
|
454 |
+
</RadixDialog.Content>
|
455 |
+
</div>
|
456 |
+
</RadixDialog.Portal>
|
457 |
+
</RadixDialog.Root>
|
458 |
);
|
459 |
};
|
@@ -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 |
+
];
|
@@ -10,12 +10,13 @@ export type TabType =
|
|
10 |
| 'data'
|
11 |
| 'cloud-providers'
|
12 |
| 'local-providers'
|
|
|
13 |
| 'connection'
|
14 |
| 'debug'
|
15 |
| 'event-logs'
|
16 |
| 'update'
|
17 |
| 'task-manager'
|
18 |
-
| '
|
19 |
|
20 |
export type WindowType = 'user' | 'developer';
|
21 |
|
@@ -46,14 +47,23 @@ export interface SettingItem {
|
|
46 |
export interface TabVisibilityConfig {
|
47 |
id: TabType;
|
48 |
visible: boolean;
|
49 |
-
window:
|
50 |
order: number;
|
|
|
51 |
locked?: boolean;
|
52 |
}
|
53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
54 |
export interface TabWindowConfig {
|
55 |
-
userTabs:
|
56 |
-
developerTabs:
|
57 |
}
|
58 |
|
59 |
export const TAB_LABELS: Record<TabType, string> = {
|
@@ -61,47 +71,18 @@ export const TAB_LABELS: Record<TabType, string> = {
|
|
61 |
settings: 'Settings',
|
62 |
notifications: 'Notifications',
|
63 |
features: 'Features',
|
64 |
-
data: 'Data',
|
65 |
'cloud-providers': 'Cloud Providers',
|
66 |
'local-providers': 'Local Providers',
|
67 |
-
|
|
|
68 |
debug: 'Debug',
|
69 |
'event-logs': 'Event Logs',
|
70 |
-
update: '
|
71 |
'task-manager': 'Task Manager',
|
72 |
-
'
|
73 |
};
|
74 |
|
75 |
-
export const DEFAULT_TAB_CONFIG: TabVisibilityConfig[] = [
|
76 |
-
// User Window Tabs (Visible by default)
|
77 |
-
{ id: 'features', visible: true, window: 'user', order: 0 },
|
78 |
-
{ id: 'data', visible: true, window: 'user', order: 1 },
|
79 |
-
{ id: 'cloud-providers', visible: true, window: 'user', order: 2 },
|
80 |
-
{ id: 'local-providers', visible: true, window: 'user', order: 3 },
|
81 |
-
{ id: 'connection', visible: true, window: 'user', order: 4 },
|
82 |
-
{ id: 'debug', visible: true, window: 'user', order: 5 },
|
83 |
-
|
84 |
-
// User Window Tabs (Hidden by default)
|
85 |
-
{ id: 'profile', visible: false, window: 'user', order: 6 },
|
86 |
-
{ id: 'settings', visible: false, window: 'user', order: 7 },
|
87 |
-
{ id: 'notifications', visible: false, window: 'user', order: 8 },
|
88 |
-
{ id: 'event-logs', visible: false, window: 'user', order: 9 },
|
89 |
-
{ id: 'update', visible: false, window: 'user', order: 10 },
|
90 |
-
{ id: 'service-status', visible: false, window: 'user', order: 11 },
|
91 |
-
|
92 |
-
// Developer Window Tabs (All visible by default)
|
93 |
-
{ id: 'features', visible: true, window: 'developer', order: 0 },
|
94 |
-
{ id: 'data', visible: true, window: 'developer', order: 1 },
|
95 |
-
{ id: 'cloud-providers', visible: true, window: 'developer', order: 2 },
|
96 |
-
{ id: 'local-providers', visible: true, window: 'developer', order: 3 },
|
97 |
-
{ id: 'connection', visible: true, window: 'developer', order: 4 },
|
98 |
-
{ id: 'debug', visible: true, window: 'developer', order: 5 },
|
99 |
-
{ id: 'task-manager', visible: true, window: 'developer', order: 6 },
|
100 |
-
{ id: 'settings', visible: true, window: 'developer', order: 7 },
|
101 |
-
{ id: 'notifications', visible: true, window: 'developer', order: 8 },
|
102 |
-
{ id: 'service-status', visible: true, window: 'developer', order: 9 },
|
103 |
-
];
|
104 |
-
|
105 |
export const categoryLabels: Record<SettingCategory, string> = {
|
106 |
profile: 'Profile & Account',
|
107 |
file_sharing: 'File Sharing',
|
@@ -119,3 +100,15 @@ export const categoryIcons: Record<SettingCategory, string> = {
|
|
119 |
services: 'i-ph:cube',
|
120 |
preferences: 'i-ph:sliders',
|
121 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
|
|
|
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> = {
|
|
|
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',
|
|
|
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 |
+
}
|
@@ -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';
|
@@ -1,8 +1,8 @@
|
|
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
|
5 |
-
import { TAB_LABELS } from '~/components
|
6 |
import { Switch } from '~/components/ui/Switch';
|
7 |
|
8 |
interface DraggableTabListProps {
|
|
|
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 {
|
@@ -0,0 +1,259 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useState } from 'react';
|
2 |
+
import { motion } from 'framer-motion';
|
3 |
+
import { useStore } from '@nanostores/react';
|
4 |
+
import { Switch } from '@radix-ui/react-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 |
+
|
12 |
+
// Define tab icons mapping
|
13 |
+
const TAB_ICONS: Record<TabType, string> = {
|
14 |
+
profile: 'i-ph:user-circle-fill',
|
15 |
+
settings: 'i-ph:gear-six-fill',
|
16 |
+
notifications: 'i-ph:bell-fill',
|
17 |
+
features: 'i-ph:star-fill',
|
18 |
+
data: 'i-ph:database-fill',
|
19 |
+
'cloud-providers': 'i-ph:cloud-fill',
|
20 |
+
'local-providers': 'i-ph:desktop-fill',
|
21 |
+
'service-status': 'i-ph:activity-fill',
|
22 |
+
connection: 'i-ph:wifi-high-fill',
|
23 |
+
debug: 'i-ph:bug-fill',
|
24 |
+
'event-logs': 'i-ph:list-bullets-fill',
|
25 |
+
update: 'i-ph:arrow-clockwise-fill',
|
26 |
+
'task-manager': 'i-ph:chart-line-fill',
|
27 |
+
'tab-management': 'i-ph:squares-four-fill',
|
28 |
+
};
|
29 |
+
|
30 |
+
// Define which tabs are default in user mode
|
31 |
+
const DEFAULT_USER_TABS: TabType[] = [
|
32 |
+
'features',
|
33 |
+
'data',
|
34 |
+
'cloud-providers',
|
35 |
+
'local-providers',
|
36 |
+
'connection',
|
37 |
+
'notifications',
|
38 |
+
'event-logs',
|
39 |
+
];
|
40 |
+
|
41 |
+
// Define which tabs can be added to user mode
|
42 |
+
const OPTIONAL_USER_TABS: TabType[] = ['profile', 'settings', 'task-manager', 'service-status', 'debug', 'update'];
|
43 |
+
|
44 |
+
// All available tabs for user mode
|
45 |
+
const ALL_USER_TABS = [...DEFAULT_USER_TABS, ...OPTIONAL_USER_TABS];
|
46 |
+
|
47 |
+
export const TabManagement = () => {
|
48 |
+
const [searchQuery, setSearchQuery] = useState('');
|
49 |
+
const tabConfiguration = useStore(tabConfigurationStore);
|
50 |
+
|
51 |
+
const handleTabVisibilityChange = (tabId: TabType, checked: boolean) => {
|
52 |
+
// Get current tab configuration
|
53 |
+
const currentTab = tabConfiguration.userTabs.find((tab) => tab.id === tabId);
|
54 |
+
|
55 |
+
// If tab doesn't exist in configuration, create it
|
56 |
+
if (!currentTab) {
|
57 |
+
const newTab = {
|
58 |
+
id: tabId,
|
59 |
+
visible: checked,
|
60 |
+
window: 'user' as const,
|
61 |
+
order: tabConfiguration.userTabs.length,
|
62 |
+
};
|
63 |
+
|
64 |
+
const updatedTabs = [...tabConfiguration.userTabs, newTab];
|
65 |
+
|
66 |
+
tabConfigurationStore.set({
|
67 |
+
...tabConfiguration,
|
68 |
+
userTabs: updatedTabs,
|
69 |
+
});
|
70 |
+
|
71 |
+
toast.success(`Tab ${checked ? 'enabled' : 'disabled'} successfully`);
|
72 |
+
|
73 |
+
return;
|
74 |
+
}
|
75 |
+
|
76 |
+
// Check if tab can be enabled in user mode
|
77 |
+
const canBeEnabled = DEFAULT_USER_TABS.includes(tabId) || OPTIONAL_USER_TABS.includes(tabId);
|
78 |
+
|
79 |
+
if (!canBeEnabled && checked) {
|
80 |
+
toast.error('This tab cannot be enabled in user mode');
|
81 |
+
return;
|
82 |
+
}
|
83 |
+
|
84 |
+
// Update tab visibility
|
85 |
+
const updatedTabs = tabConfiguration.userTabs.map((tab) => {
|
86 |
+
if (tab.id === tabId) {
|
87 |
+
return { ...tab, visible: checked };
|
88 |
+
}
|
89 |
+
|
90 |
+
return tab;
|
91 |
+
});
|
92 |
+
|
93 |
+
// Update store
|
94 |
+
tabConfigurationStore.set({
|
95 |
+
...tabConfiguration,
|
96 |
+
userTabs: updatedTabs,
|
97 |
+
});
|
98 |
+
|
99 |
+
// Show success message
|
100 |
+
toast.success(`Tab ${checked ? 'enabled' : 'disabled'} successfully`);
|
101 |
+
};
|
102 |
+
|
103 |
+
// Create a map of existing tab configurations
|
104 |
+
const tabConfigMap = new Map(tabConfiguration.userTabs.map((tab) => [tab.id, tab]));
|
105 |
+
|
106 |
+
// Generate the complete list of tabs, including those not in the configuration
|
107 |
+
const allTabs = ALL_USER_TABS.map((tabId) => {
|
108 |
+
return (
|
109 |
+
tabConfigMap.get(tabId) || {
|
110 |
+
id: tabId,
|
111 |
+
visible: false,
|
112 |
+
window: 'user' as const,
|
113 |
+
order: -1,
|
114 |
+
}
|
115 |
+
);
|
116 |
+
});
|
117 |
+
|
118 |
+
// Filter tabs based on search query
|
119 |
+
const filteredTabs = allTabs.filter((tab) => TAB_LABELS[tab.id].toLowerCase().includes(searchQuery.toLowerCase()));
|
120 |
+
|
121 |
+
return (
|
122 |
+
<div className="space-y-6">
|
123 |
+
<motion.div
|
124 |
+
className="space-y-4"
|
125 |
+
initial={{ opacity: 0, y: 20 }}
|
126 |
+
animate={{ opacity: 1, y: 0 }}
|
127 |
+
transition={{ duration: 0.3 }}
|
128 |
+
>
|
129 |
+
{/* Header */}
|
130 |
+
<div className="flex items-center justify-between gap-4 mt-8 mb-4">
|
131 |
+
<div className="flex items-center gap-2">
|
132 |
+
<div
|
133 |
+
className={classNames(
|
134 |
+
'w-8 h-8 flex items-center justify-center rounded-lg',
|
135 |
+
'bg-bolt-elements-background-depth-3',
|
136 |
+
'text-purple-500',
|
137 |
+
)}
|
138 |
+
>
|
139 |
+
<TbLayoutGrid className="w-5 h-5" />
|
140 |
+
</div>
|
141 |
+
<div>
|
142 |
+
<h4 className="text-md font-medium text-bolt-elements-textPrimary">Tab Management</h4>
|
143 |
+
<p className="text-sm text-bolt-elements-textSecondary">Configure visible tabs and their order</p>
|
144 |
+
</div>
|
145 |
+
</div>
|
146 |
+
|
147 |
+
{/* Search */}
|
148 |
+
<div className="relative w-64">
|
149 |
+
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
150 |
+
<div className="i-ph:magnifying-glass w-4 h-4 text-gray-400" />
|
151 |
+
</div>
|
152 |
+
<input
|
153 |
+
type="text"
|
154 |
+
value={searchQuery}
|
155 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
156 |
+
placeholder="Search tabs..."
|
157 |
+
className={classNames(
|
158 |
+
'w-full pl-10 pr-4 py-2 rounded-lg',
|
159 |
+
'bg-bolt-elements-background-depth-2',
|
160 |
+
'border border-bolt-elements-borderColor',
|
161 |
+
'text-bolt-elements-textPrimary',
|
162 |
+
'placeholder-bolt-elements-textTertiary',
|
163 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
|
164 |
+
'transition-all duration-200',
|
165 |
+
)}
|
166 |
+
/>
|
167 |
+
</div>
|
168 |
+
</div>
|
169 |
+
|
170 |
+
{/* Tab Grid */}
|
171 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
172 |
+
{filteredTabs.map((tab, index) => (
|
173 |
+
<motion.div
|
174 |
+
key={tab.id}
|
175 |
+
className={classNames(
|
176 |
+
'rounded-lg border bg-bolt-elements-background text-bolt-elements-textPrimary',
|
177 |
+
'bg-bolt-elements-background-depth-2',
|
178 |
+
'hover:bg-bolt-elements-background-depth-3',
|
179 |
+
'transition-all duration-200',
|
180 |
+
'relative overflow-hidden group',
|
181 |
+
)}
|
182 |
+
initial={{ opacity: 0, y: 20 }}
|
183 |
+
animate={{ opacity: 1, y: 0 }}
|
184 |
+
transition={{ delay: index * 0.1 }}
|
185 |
+
whileHover={{ scale: 1.02 }}
|
186 |
+
>
|
187 |
+
{/* Status Badges */}
|
188 |
+
<div className="absolute top-2 right-2 flex gap-1">
|
189 |
+
{DEFAULT_USER_TABS.includes(tab.id) && (
|
190 |
+
<span className="px-2 py-0.5 text-xs rounded-full bg-purple-500/10 text-purple-500 font-medium">
|
191 |
+
Default
|
192 |
+
</span>
|
193 |
+
)}
|
194 |
+
{OPTIONAL_USER_TABS.includes(tab.id) && (
|
195 |
+
<span className="px-2 py-0.5 text-xs rounded-full bg-blue-500/10 text-blue-500 font-medium">
|
196 |
+
Optional
|
197 |
+
</span>
|
198 |
+
)}
|
199 |
+
</div>
|
200 |
+
|
201 |
+
<div className="flex items-start gap-4 p-4">
|
202 |
+
<motion.div
|
203 |
+
className={classNames(
|
204 |
+
'w-10 h-10 flex items-center justify-center rounded-xl',
|
205 |
+
'bg-bolt-elements-background-depth-3 group-hover:bg-bolt-elements-background-depth-4',
|
206 |
+
'transition-all duration-200',
|
207 |
+
tab.visible ? 'text-purple-500' : 'text-bolt-elements-textSecondary',
|
208 |
+
)}
|
209 |
+
whileHover={{ scale: 1.1 }}
|
210 |
+
whileTap={{ scale: 0.9 }}
|
211 |
+
>
|
212 |
+
<div className={classNames('w-6 h-6', 'transition-transform duration-200', 'group-hover:rotate-12')}>
|
213 |
+
<div className={classNames(TAB_ICONS[tab.id], 'w-full h-full')} />
|
214 |
+
</div>
|
215 |
+
</motion.div>
|
216 |
+
|
217 |
+
<div className="flex-1 min-w-0">
|
218 |
+
<div className="flex items-center justify-between gap-4">
|
219 |
+
<div>
|
220 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-purple-500 transition-colors">
|
221 |
+
{TAB_LABELS[tab.id]}
|
222 |
+
</h4>
|
223 |
+
<p className="text-xs text-bolt-elements-textSecondary mt-0.5">
|
224 |
+
{tab.visible ? 'Visible in user mode' : 'Hidden in user mode'}
|
225 |
+
</p>
|
226 |
+
</div>
|
227 |
+
<Switch
|
228 |
+
checked={tab.visible}
|
229 |
+
onCheckedChange={(checked) => handleTabVisibilityChange(tab.id, checked)}
|
230 |
+
disabled={!DEFAULT_USER_TABS.includes(tab.id) && !OPTIONAL_USER_TABS.includes(tab.id)}
|
231 |
+
className={classNames(
|
232 |
+
'relative inline-flex h-5 w-9 items-center rounded-full',
|
233 |
+
'transition-colors duration-200',
|
234 |
+
tab.visible ? 'bg-purple-500' : 'bg-bolt-elements-background-depth-4',
|
235 |
+
{
|
236 |
+
'opacity-50 cursor-not-allowed':
|
237 |
+
!DEFAULT_USER_TABS.includes(tab.id) && !OPTIONAL_USER_TABS.includes(tab.id),
|
238 |
+
},
|
239 |
+
)}
|
240 |
+
/>
|
241 |
+
</div>
|
242 |
+
</div>
|
243 |
+
</div>
|
244 |
+
|
245 |
+
<motion.div
|
246 |
+
className="absolute inset-0 border-2 border-purple-500/0 rounded-lg pointer-events-none"
|
247 |
+
animate={{
|
248 |
+
borderColor: tab.visible ? 'rgba(168, 85, 247, 0.2)' : 'rgba(168, 85, 247, 0)',
|
249 |
+
scale: tab.visible ? 1 : 0.98,
|
250 |
+
}}
|
251 |
+
transition={{ duration: 0.2 }}
|
252 |
+
/>
|
253 |
+
</motion.div>
|
254 |
+
))}
|
255 |
+
</div>
|
256 |
+
</motion.div>
|
257 |
+
</div>
|
258 |
+
);
|
259 |
+
};
|
@@ -1,134 +1,104 @@
|
|
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
|
5 |
-
import { TAB_LABELS } from '~/components
|
6 |
-
|
7 |
-
const TAB_ICONS = {
|
8 |
-
profile: 'i-ph:user',
|
9 |
-
settings: 'i-ph:gear',
|
10 |
-
notifications: 'i-ph:bell',
|
11 |
-
features: 'i-ph:star',
|
12 |
-
data: 'i-ph:database',
|
13 |
-
providers: 'i-ph:plug',
|
14 |
-
connection: 'i-ph:wifi-high',
|
15 |
-
debug: 'i-ph:bug',
|
16 |
-
'event-logs': 'i-ph:list-bullets',
|
17 |
-
update: 'i-ph:arrow-clockwise',
|
18 |
-
'task-manager': 'i-ph:activity',
|
19 |
-
'cloud-providers': 'i-ph:cloud',
|
20 |
-
'local-providers': 'i-ph:desktop',
|
21 |
-
'service-status': 'i-ph:activity-bold',
|
22 |
-
};
|
23 |
|
24 |
interface TabTileProps {
|
25 |
tab: TabVisibilityConfig;
|
26 |
-
onClick
|
27 |
isActive?: boolean;
|
28 |
hasUpdate?: boolean;
|
29 |
statusMessage?: string;
|
30 |
description?: string;
|
31 |
isLoading?: boolean;
|
|
|
32 |
}
|
33 |
|
34 |
export const TabTile = ({
|
35 |
tab,
|
36 |
onClick,
|
37 |
-
isActive
|
38 |
-
hasUpdate
|
39 |
statusMessage,
|
40 |
description,
|
41 |
-
isLoading
|
|
|
42 |
}: TabTileProps) => {
|
43 |
return (
|
44 |
<Tooltip.Provider delayDuration={200}>
|
45 |
<Tooltip.Root>
|
46 |
<Tooltip.Trigger asChild>
|
47 |
-
<motion.
|
48 |
onClick={onClick}
|
49 |
-
disabled={isLoading}
|
50 |
className={classNames(
|
51 |
-
'relative flex flex-col items-center
|
52 |
'w-full h-full min-h-[160px]',
|
53 |
-
|
54 |
-
// Background and border styles
|
55 |
'bg-white dark:bg-[#141414]',
|
56 |
-
'border border-[#E5E5E5]
|
57 |
-
|
58 |
-
// Shadow and glass effect
|
59 |
-
'shadow-sm',
|
60 |
-
'dark:shadow-[0_0_15px_rgba(0,0,0,0.1)]',
|
61 |
-
'dark:bg-opacity-50',
|
62 |
-
|
63 |
-
// Hover effects
|
64 |
-
'hover:border-purple-500/30 dark:hover:border-purple-500/30',
|
65 |
-
'hover:bg-gradient-to-br hover:from-purple-50/50 hover:to-white dark:hover:from-purple-500/5 dark:hover:to-[#141414]',
|
66 |
-
'hover:shadow-md hover:shadow-purple-500/5',
|
67 |
-
'dark:hover:shadow-purple-500/10',
|
68 |
-
|
69 |
-
// Focus states for keyboard navigation
|
70 |
-
'focus:outline-none',
|
71 |
-
'focus:ring-2 focus:ring-purple-500/50 focus:ring-offset-2',
|
72 |
-
'dark:focus:ring-offset-[#141414]',
|
73 |
-
'focus:border-purple-500/30',
|
74 |
-
|
75 |
-
// Active state
|
76 |
-
isActive
|
77 |
-
? [
|
78 |
-
'border-purple-500/50 dark:border-purple-500/50',
|
79 |
-
'bg-gradient-to-br from-purple-50 to-white dark:from-purple-500/10 dark:to-[#141414]',
|
80 |
-
'shadow-md shadow-purple-500/10',
|
81 |
-
]
|
82 |
-
: '',
|
83 |
-
|
84 |
-
// Loading state
|
85 |
-
isLoading ? 'cursor-wait opacity-70' : '',
|
86 |
-
|
87 |
-
// Transitions
|
88 |
-
'transition-all duration-300 ease-out',
|
89 |
'group',
|
|
|
|
|
|
|
|
|
|
|
90 |
)}
|
91 |
-
whileHover={
|
92 |
-
!isLoading
|
93 |
-
? {
|
94 |
-
scale: 1.02,
|
95 |
-
transition: { duration: 0.2, ease: 'easeOut' },
|
96 |
-
}
|
97 |
-
: {}
|
98 |
-
}
|
99 |
-
whileTap={
|
100 |
-
!isLoading
|
101 |
-
? {
|
102 |
-
scale: 0.98,
|
103 |
-
transition: { duration: 0.1, ease: 'easeIn' },
|
104 |
-
}
|
105 |
-
: {}
|
106 |
-
}
|
107 |
>
|
108 |
-
{/*
|
109 |
-
|
|
|
110 |
<motion.div
|
111 |
className={classNames(
|
112 |
-
'
|
113 |
-
'
|
114 |
-
'backdrop-blur-sm',
|
115 |
'flex items-center justify-center',
|
|
|
|
|
|
|
|
|
|
|
|
|
116 |
)}
|
117 |
-
initial={{ opacity: 0 }}
|
118 |
-
animate={{ opacity: 1 }}
|
119 |
-
transition={{ duration: 0.2 }}
|
120 |
>
|
121 |
<motion.div
|
122 |
-
className={classNames(
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
|
127 |
-
|
128 |
-
}
|
129 |
/>
|
130 |
</motion.div>
|
131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
132 |
|
133 |
{/* Status Indicator */}
|
134 |
{hasUpdate && (
|
@@ -136,9 +106,8 @@ export const TabTile = ({
|
|
136 |
className={classNames(
|
137 |
'absolute top-3 right-3',
|
138 |
'w-2.5 h-2.5 rounded-full',
|
139 |
-
'bg-
|
140 |
-
'
|
141 |
-
'ring-4 ring-green-500/20',
|
142 |
)}
|
143 |
initial={{ scale: 0 }}
|
144 |
animate={{ scale: 1 }}
|
@@ -146,70 +115,30 @@ export const TabTile = ({
|
|
146 |
/>
|
147 |
)}
|
148 |
|
149 |
-
{/*
|
150 |
-
|
151 |
-
|
152 |
-
'absolute inset-0 rounded-xl opacity-0 group-hover:opacity-100',
|
153 |
-
'bg-gradient-to-br from-purple-500/5 to-transparent dark:from-purple-500/10',
|
154 |
-
'transition-opacity duration-300',
|
155 |
-
isActive ? 'opacity-100' : '',
|
156 |
-
)}
|
157 |
-
/>
|
158 |
-
|
159 |
-
{/* Icon */}
|
160 |
-
<div
|
161 |
-
className={classNames(
|
162 |
-
TAB_ICONS[tab.id],
|
163 |
-
'w-12 h-12',
|
164 |
-
'relative',
|
165 |
-
'text-gray-600 dark:text-gray-300',
|
166 |
-
'group-hover:text-purple-500 dark:group-hover:text-purple-400',
|
167 |
-
'transition-all duration-300',
|
168 |
-
isActive ? 'text-purple-500 dark:text-purple-400 scale-110' : '',
|
169 |
-
)}
|
170 |
-
/>
|
171 |
-
|
172 |
-
{/* Label and Description */}
|
173 |
-
<div className="relative flex flex-col items-center text-center">
|
174 |
-
<div
|
175 |
className={classNames(
|
176 |
-
'
|
177 |
-
'
|
178 |
-
'
|
179 |
-
'transition-colors duration-300',
|
180 |
-
isActive ? 'text-purple-500 dark:text-purple-400' : '',
|
181 |
)}
|
|
|
|
|
|
|
182 |
>
|
183 |
-
|
184 |
-
|
185 |
-
|
186 |
-
|
187 |
-
|
188 |
-
|
189 |
-
|
190 |
-
|
191 |
-
|
192 |
-
|
193 |
-
|
194 |
-
|
195 |
-
>
|
196 |
-
{description}
|
197 |
-
</div>
|
198 |
-
)}
|
199 |
-
</div>
|
200 |
-
|
201 |
-
{/* Bottom indicator line */}
|
202 |
-
<div
|
203 |
-
className={classNames(
|
204 |
-
'absolute bottom-0 left-1/2 -translate-x-1/2',
|
205 |
-
'w-12 h-0.5 rounded-full',
|
206 |
-
'bg-purple-500/0 group-hover:bg-purple-500/50',
|
207 |
-
'transition-all duration-300 ease-out',
|
208 |
-
'transform scale-x-0 group-hover:scale-x-100',
|
209 |
-
isActive ? 'bg-purple-500 scale-x-100' : '',
|
210 |
-
)}
|
211 |
-
/>
|
212 |
-
</motion.button>
|
213 |
</Tooltip.Trigger>
|
214 |
<Tooltip.Portal>
|
215 |
<Tooltip.Content
|
@@ -217,7 +146,6 @@ export const TabTile = ({
|
|
217 |
'px-3 py-1.5 rounded-lg',
|
218 |
'bg-[#18181B] text-white',
|
219 |
'text-sm font-medium',
|
220 |
-
'shadow-xl',
|
221 |
'select-none',
|
222 |
'z-[100]',
|
223 |
)}
|
|
|
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 |
}
|
17 |
|
18 |
export const TabTile = ({
|
19 |
tab,
|
20 |
onClick,
|
21 |
+
isActive,
|
22 |
+
hasUpdate,
|
23 |
statusMessage,
|
24 |
description,
|
25 |
+
isLoading,
|
26 |
+
className,
|
27 |
}: TabTileProps) => {
|
28 |
return (
|
29 |
<Tooltip.Provider delayDuration={200}>
|
30 |
<Tooltip.Root>
|
31 |
<Tooltip.Trigger asChild>
|
32 |
+
<motion.div
|
33 |
onClick={onClick}
|
|
|
34 |
className={classNames(
|
35 |
+
'relative flex flex-col items-center p-6 rounded-xl',
|
36 |
'w-full h-full min-h-[160px]',
|
|
|
|
|
37 |
'bg-white dark:bg-[#141414]',
|
38 |
+
'border border-[#E5E5E5] dark:border-[#333333]',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
39 |
'group',
|
40 |
+
'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
|
41 |
+
'hover:border-purple-200 dark:hover:border-purple-900/30',
|
42 |
+
isActive ? 'border-purple-500 dark:border-purple-500/50 bg-purple-500/5 dark:bg-purple-500/10' : '',
|
43 |
+
isLoading ? 'cursor-wait opacity-70' : '',
|
44 |
+
className || '',
|
45 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
46 |
>
|
47 |
+
{/* Main Content */}
|
48 |
+
<div className="flex flex-col items-center justify-center flex-1 w-full">
|
49 |
+
{/* Icon */}
|
50 |
<motion.div
|
51 |
className={classNames(
|
52 |
+
'relative',
|
53 |
+
'w-14 h-14',
|
|
|
54 |
'flex items-center justify-center',
|
55 |
+
'rounded-xl',
|
56 |
+
'bg-gray-100 dark:bg-gray-800',
|
57 |
+
'ring-1 ring-gray-200 dark:ring-gray-700',
|
58 |
+
'group-hover:bg-purple-100 dark:group-hover:bg-gray-700/80',
|
59 |
+
'group-hover:ring-purple-200 dark:group-hover:ring-purple-800/30',
|
60 |
+
isActive ? 'bg-purple-500/10 dark:bg-purple-500/10 ring-purple-500/30 dark:ring-purple-500/20' : '',
|
61 |
)}
|
|
|
|
|
|
|
62 |
>
|
63 |
<motion.div
|
64 |
+
className={classNames(
|
65 |
+
TAB_ICONS[tab.id],
|
66 |
+
'w-8 h-8',
|
67 |
+
'text-gray-600 dark:text-gray-300',
|
68 |
+
'group-hover:text-purple-500 dark:group-hover:text-purple-400/80',
|
69 |
+
isActive ? 'text-purple-500 dark:text-purple-400/90' : '',
|
70 |
+
)}
|
71 |
/>
|
72 |
</motion.div>
|
73 |
+
|
74 |
+
{/* Label and Description */}
|
75 |
+
<div className="flex flex-col items-center mt-5 w-full">
|
76 |
+
<h3
|
77 |
+
className={classNames(
|
78 |
+
'text-[15px] font-medium leading-snug mb-2',
|
79 |
+
'text-gray-700 dark:text-gray-200',
|
80 |
+
'group-hover:text-purple-600 dark:group-hover:text-purple-300/90',
|
81 |
+
isActive ? 'text-purple-500 dark:text-purple-400/90' : '',
|
82 |
+
)}
|
83 |
+
>
|
84 |
+
{TAB_LABELS[tab.id]}
|
85 |
+
</h3>
|
86 |
+
{description && (
|
87 |
+
<p
|
88 |
+
className={classNames(
|
89 |
+
'text-[13px] leading-relaxed',
|
90 |
+
'text-gray-500 dark:text-gray-400',
|
91 |
+
'max-w-[85%]',
|
92 |
+
'text-center',
|
93 |
+
'group-hover:text-purple-500 dark:group-hover:text-purple-400/70',
|
94 |
+
isActive ? 'text-purple-400 dark:text-purple-400/80' : '',
|
95 |
+
)}
|
96 |
+
>
|
97 |
+
{description}
|
98 |
+
</p>
|
99 |
+
)}
|
100 |
+
</div>
|
101 |
+
</div>
|
102 |
|
103 |
{/* Status Indicator */}
|
104 |
{hasUpdate && (
|
|
|
106 |
className={classNames(
|
107 |
'absolute top-3 right-3',
|
108 |
'w-2.5 h-2.5 rounded-full',
|
109 |
+
'bg-purple-500',
|
110 |
+
'ring-4 ring-purple-500',
|
|
|
111 |
)}
|
112 |
initial={{ scale: 0 }}
|
113 |
animate={{ scale: 1 }}
|
|
|
115 |
/>
|
116 |
)}
|
117 |
|
118 |
+
{/* Loading Overlay */}
|
119 |
+
{isLoading && (
|
120 |
+
<motion.div
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
121 |
className={classNames(
|
122 |
+
'absolute inset-0 rounded-xl z-10',
|
123 |
+
'bg-white dark:bg-black',
|
124 |
+
'flex items-center justify-center',
|
|
|
|
|
125 |
)}
|
126 |
+
initial={{ opacity: 0 }}
|
127 |
+
animate={{ opacity: 1 }}
|
128 |
+
transition={{ duration: 0.2 }}
|
129 |
>
|
130 |
+
<motion.div
|
131 |
+
className={classNames('w-8 h-8 rounded-full', 'border-2 border-purple-500', 'border-t-purple-500')}
|
132 |
+
animate={{ rotate: 360 }}
|
133 |
+
transition={{
|
134 |
+
duration: 1,
|
135 |
+
repeat: Infinity,
|
136 |
+
ease: 'linear',
|
137 |
+
}}
|
138 |
+
/>
|
139 |
+
</motion.div>
|
140 |
+
)}
|
141 |
+
</motion.div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
142 |
</Tooltip.Trigger>
|
143 |
<Tooltip.Portal>
|
144 |
<Tooltip.Content
|
|
|
146 |
'px-3 py-1.5 rounded-lg',
|
147 |
'bg-[#18181B] text-white',
|
148 |
'text-sm font-medium',
|
|
|
149 |
'select-none',
|
150 |
'z-[100]',
|
151 |
)}
|
File without changes
|
@@ -1,6 +1,6 @@
|
|
1 |
import React, { useEffect } from 'react';
|
2 |
import { classNames } from '~/utils/classNames';
|
3 |
-
import type { GitHubAuthState } from '~/components
|
4 |
import Cookies from 'js-cookie';
|
5 |
import { getLocalStorage } from '~/lib/persistence';
|
6 |
|
|
|
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 |
|
@@ -1,7 +1,7 @@
|
|
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
|
5 |
import { GitBranch } from '@phosphor-icons/react';
|
6 |
|
7 |
interface GitHubBranch {
|
|
|
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 {
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
@@ -131,6 +131,13 @@ interface WebAppInfo {
|
|
131 |
gitInfo: GitInfo;
|
132 |
}
|
133 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
134 |
const DependencySection = ({
|
135 |
title,
|
136 |
deps,
|
@@ -146,7 +153,17 @@ const DependencySection = ({
|
|
146 |
|
147 |
return (
|
148 |
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
149 |
-
<CollapsibleTrigger
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
150 |
<div className="flex items-center gap-3">
|
151 |
<div className="i-ph:package text-bolt-elements-textSecondary w-4 h-4" />
|
152 |
<span className="text-base text-bolt-elements-textPrimary">
|
@@ -157,15 +174,22 @@ const DependencySection = ({
|
|
157 |
<span className="text-sm text-bolt-elements-textSecondary">{isOpen ? 'Hide' : 'Show'}</span>
|
158 |
<div
|
159 |
className={classNames(
|
160 |
-
'i-ph:caret-down w-4 h-4 transform transition-transform duration-200',
|
161 |
isOpen ? 'rotate-180' : '',
|
162 |
)}
|
163 |
/>
|
164 |
</div>
|
165 |
</CollapsibleTrigger>
|
166 |
<CollapsibleContent>
|
167 |
-
<ScrollArea
|
168 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
169 |
{deps.map((dep) => (
|
170 |
<div key={dep.name} className="flex items-center justify-between text-sm">
|
171 |
<span className="text-bolt-elements-textPrimary">{dep.name}</span>
|
@@ -182,6 +206,10 @@ const DependencySection = ({
|
|
182 |
export default function DebugTab() {
|
183 |
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null);
|
184 |
const [webAppInfo, setWebAppInfo] = useState<WebAppInfo | null>(null);
|
|
|
|
|
|
|
|
|
185 |
const [loading, setLoading] = useState({
|
186 |
systemInfo: false,
|
187 |
webAppInfo: false,
|
@@ -259,7 +287,8 @@ export default function DebugTab() {
|
|
259 |
return undefined;
|
260 |
}
|
261 |
|
262 |
-
|
|
|
263 |
try {
|
264 |
const response = await fetch('/api/system/git-info');
|
265 |
const updatedGitInfo = (await response.json()) as GitInfo;
|
@@ -269,21 +298,27 @@ export default function DebugTab() {
|
|
269 |
return null;
|
270 |
}
|
271 |
|
|
|
|
|
|
|
|
|
|
|
272 |
return {
|
273 |
...prev,
|
274 |
gitInfo: updatedGitInfo,
|
275 |
};
|
276 |
});
|
277 |
} catch (error) {
|
278 |
-
console.error('Failed to
|
279 |
}
|
280 |
-
}, 5000);
|
281 |
-
|
282 |
-
const cleanup = () => {
|
283 |
-
clearInterval(interval);
|
284 |
};
|
285 |
|
286 |
-
|
|
|
|
|
|
|
|
|
|
|
287 |
}, [openSections.webapp]);
|
288 |
|
289 |
const getSystemInfo = async () => {
|
@@ -616,11 +651,68 @@ export default function DebugTab() {
|
|
616 |
}
|
617 |
};
|
618 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
619 |
return (
|
620 |
<div className="flex flex-col gap-6 max-w-7xl mx-auto p-4">
|
621 |
{/* Quick Stats Banner */}
|
622 |
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
623 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
624 |
<div className="text-sm text-bolt-elements-textSecondary">Memory Usage</div>
|
625 |
<div className="text-2xl font-semibold text-bolt-elements-textPrimary mt-1">
|
626 |
{systemInfo?.memory.percentage}%
|
@@ -628,7 +720,7 @@ export default function DebugTab() {
|
|
628 |
<Progress value={systemInfo?.memory.percentage || 0} className="mt-2" />
|
629 |
</div>
|
630 |
|
631 |
-
<div className="p-4 rounded-xl bg-
|
632 |
<div className="text-sm text-bolt-elements-textSecondary">Page Load Time</div>
|
633 |
<div className="text-2xl font-semibold text-bolt-elements-textPrimary mt-1">
|
634 |
{systemInfo ? (systemInfo.performance.timing.loadTime / 1000).toFixed(2) + 's' : '-'}
|
@@ -638,7 +730,7 @@ export default function DebugTab() {
|
|
638 |
</div>
|
639 |
</div>
|
640 |
|
641 |
-
<div className="p-4 rounded-xl bg-
|
642 |
<div className="text-sm text-bolt-elements-textSecondary">Network Speed</div>
|
643 |
<div className="text-2xl font-semibold text-bolt-elements-textPrimary mt-1">
|
644 |
{systemInfo?.network.downlink || '-'} Mbps
|
@@ -646,7 +738,7 @@ export default function DebugTab() {
|
|
646 |
<div className="text-xs text-bolt-elements-textSecondary mt-2">RTT: {systemInfo?.network.rtt || '-'} ms</div>
|
647 |
</div>
|
648 |
|
649 |
-
<div className="p-4 rounded-xl bg-
|
650 |
<div className="text-sm text-bolt-elements-textSecondary">Errors</div>
|
651 |
<div className="text-2xl font-semibold text-bolt-elements-textPrimary mt-1">{errorLogs.length}</div>
|
652 |
</div>
|
@@ -659,10 +751,11 @@ export default function DebugTab() {
|
|
659 |
disabled={loading.systemInfo}
|
660 |
className={classNames(
|
661 |
'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
|
662 |
-
'bg-
|
663 |
-
'
|
664 |
-
'
|
665 |
-
'
|
|
|
666 |
{ 'opacity-50 cursor-not-allowed': loading.systemInfo },
|
667 |
)}
|
668 |
>
|
@@ -679,10 +772,11 @@ export default function DebugTab() {
|
|
679 |
disabled={loading.performance}
|
680 |
className={classNames(
|
681 |
'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
|
682 |
-
'bg-
|
683 |
-
'
|
684 |
-
'
|
685 |
-
'
|
|
|
686 |
{ 'opacity-50 cursor-not-allowed': loading.performance },
|
687 |
)}
|
688 |
>
|
@@ -699,10 +793,11 @@ export default function DebugTab() {
|
|
699 |
disabled={loading.errors}
|
700 |
className={classNames(
|
701 |
'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
|
702 |
-
'bg-
|
703 |
-
'
|
704 |
-
'
|
705 |
-
'
|
|
|
706 |
{ 'opacity-50 cursor-not-allowed': loading.errors },
|
707 |
)}
|
708 |
>
|
@@ -719,10 +814,11 @@ export default function DebugTab() {
|
|
719 |
disabled={loading.webAppInfo}
|
720 |
className={classNames(
|
721 |
'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
|
722 |
-
'bg-
|
723 |
-
'
|
724 |
-
'
|
725 |
-
'
|
|
|
726 |
{ 'opacity-50 cursor-not-allowed': loading.webAppInfo },
|
727 |
)}
|
728 |
>
|
@@ -738,10 +834,11 @@ export default function DebugTab() {
|
|
738 |
onClick={exportDebugInfo}
|
739 |
className={classNames(
|
740 |
'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
|
741 |
-
'bg-
|
742 |
-
'
|
743 |
-
'
|
744 |
-
'
|
|
|
745 |
)}
|
746 |
>
|
747 |
<div className="i-ph:download w-4 h-4" />
|
@@ -1152,7 +1249,7 @@ export default function DebugTab() {
|
|
1152 |
{webAppInfo && (
|
1153 |
<div className="mt-6">
|
1154 |
<h3 className="mb-4 text-base font-medium text-bolt-elements-textPrimary">Dependencies</h3>
|
1155 |
-
<div className="
|
1156 |
<DependencySection title="Production" deps={webAppInfo.dependencies.production} />
|
1157 |
<DependencySection title="Development" deps={webAppInfo.dependencies.development} />
|
1158 |
<DependencySection title="Peer" deps={webAppInfo.dependencies.peer} />
|
|
|
131 |
gitInfo: GitInfo;
|
132 |
}
|
133 |
|
134 |
+
// Add Ollama service status interface
|
135 |
+
interface OllamaServiceStatus {
|
136 |
+
isRunning: boolean;
|
137 |
+
lastChecked: Date;
|
138 |
+
error?: string;
|
139 |
+
}
|
140 |
+
|
141 |
const DependencySection = ({
|
142 |
title,
|
143 |
deps,
|
|
|
153 |
|
154 |
return (
|
155 |
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
156 |
+
<CollapsibleTrigger
|
157 |
+
className={classNames(
|
158 |
+
'flex w-full items-center justify-between p-4',
|
159 |
+
'bg-white dark:bg-[#0A0A0A]',
|
160 |
+
'hover:bg-purple-50/50 dark:hover:bg-[#1a1a1a]',
|
161 |
+
'border-b border-[#E5E5E5] dark:border-[#1A1A1A]',
|
162 |
+
'transition-colors duration-200',
|
163 |
+
'first:rounded-t-lg last:rounded-b-lg',
|
164 |
+
{ 'hover:rounded-lg': !isOpen },
|
165 |
+
)}
|
166 |
+
>
|
167 |
<div className="flex items-center gap-3">
|
168 |
<div className="i-ph:package text-bolt-elements-textSecondary w-4 h-4" />
|
169 |
<span className="text-base text-bolt-elements-textPrimary">
|
|
|
174 |
<span className="text-sm text-bolt-elements-textSecondary">{isOpen ? 'Hide' : 'Show'}</span>
|
175 |
<div
|
176 |
className={classNames(
|
177 |
+
'i-ph:caret-down w-4 h-4 transform transition-transform duration-200 text-bolt-elements-textSecondary',
|
178 |
isOpen ? 'rotate-180' : '',
|
179 |
)}
|
180 |
/>
|
181 |
</div>
|
182 |
</CollapsibleTrigger>
|
183 |
<CollapsibleContent>
|
184 |
+
<ScrollArea
|
185 |
+
className={classNames(
|
186 |
+
'h-[200px] w-full',
|
187 |
+
'bg-white dark:bg-[#0A0A0A]',
|
188 |
+
'border-b border-[#E5E5E5] dark:border-[#1A1A1A]',
|
189 |
+
'last:rounded-b-lg last:border-b-0',
|
190 |
+
)}
|
191 |
+
>
|
192 |
+
<div className="space-y-2 p-4">
|
193 |
{deps.map((dep) => (
|
194 |
<div key={dep.name} className="flex items-center justify-between text-sm">
|
195 |
<span className="text-bolt-elements-textPrimary">{dep.name}</span>
|
|
|
206 |
export default function DebugTab() {
|
207 |
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null);
|
208 |
const [webAppInfo, setWebAppInfo] = useState<WebAppInfo | null>(null);
|
209 |
+
const [ollamaStatus, setOllamaStatus] = useState<OllamaServiceStatus>({
|
210 |
+
isRunning: false,
|
211 |
+
lastChecked: new Date(),
|
212 |
+
});
|
213 |
const [loading, setLoading] = useState({
|
214 |
systemInfo: false,
|
215 |
webAppInfo: false,
|
|
|
287 |
return undefined;
|
288 |
}
|
289 |
|
290 |
+
// Initial fetch
|
291 |
+
const fetchGitInfo = async () => {
|
292 |
try {
|
293 |
const response = await fetch('/api/system/git-info');
|
294 |
const updatedGitInfo = (await response.json()) as GitInfo;
|
|
|
298 |
return null;
|
299 |
}
|
300 |
|
301 |
+
// Only update if the data has changed
|
302 |
+
if (JSON.stringify(prev.gitInfo) === JSON.stringify(updatedGitInfo)) {
|
303 |
+
return prev;
|
304 |
+
}
|
305 |
+
|
306 |
return {
|
307 |
...prev,
|
308 |
gitInfo: updatedGitInfo,
|
309 |
};
|
310 |
});
|
311 |
} catch (error) {
|
312 |
+
console.error('Failed to fetch git info:', error);
|
313 |
}
|
|
|
|
|
|
|
|
|
314 |
};
|
315 |
|
316 |
+
fetchGitInfo();
|
317 |
+
|
318 |
+
// Refresh every 5 minutes instead of every second
|
319 |
+
const interval = setInterval(fetchGitInfo, 5 * 60 * 1000);
|
320 |
+
|
321 |
+
return () => clearInterval(interval);
|
322 |
}, [openSections.webapp]);
|
323 |
|
324 |
const getSystemInfo = async () => {
|
|
|
651 |
}
|
652 |
};
|
653 |
|
654 |
+
// Add Ollama health check function
|
655 |
+
const checkOllamaHealth = async () => {
|
656 |
+
try {
|
657 |
+
const response = await fetch('http://127.0.0.1:11434/api/version');
|
658 |
+
const isHealthy = response.ok;
|
659 |
+
|
660 |
+
setOllamaStatus({
|
661 |
+
isRunning: isHealthy,
|
662 |
+
lastChecked: new Date(),
|
663 |
+
error: isHealthy ? undefined : 'Ollama service is not responding',
|
664 |
+
});
|
665 |
+
|
666 |
+
return isHealthy;
|
667 |
+
} catch {
|
668 |
+
setOllamaStatus({
|
669 |
+
isRunning: false,
|
670 |
+
lastChecked: new Date(),
|
671 |
+
error: 'Failed to connect to Ollama service',
|
672 |
+
});
|
673 |
+
return false;
|
674 |
+
}
|
675 |
+
};
|
676 |
+
|
677 |
+
// Add Ollama health check effect
|
678 |
+
useEffect(() => {
|
679 |
+
const checkHealth = async () => {
|
680 |
+
await checkOllamaHealth();
|
681 |
+
};
|
682 |
+
|
683 |
+
checkHealth();
|
684 |
+
|
685 |
+
const interval = setInterval(checkHealth, 30000); // Check every 30 seconds
|
686 |
+
|
687 |
+
return () => clearInterval(interval);
|
688 |
+
}, []);
|
689 |
+
|
690 |
return (
|
691 |
<div className="flex flex-col gap-6 max-w-7xl mx-auto p-4">
|
692 |
{/* Quick Stats Banner */}
|
693 |
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
694 |
+
{/* Add Ollama Service Status Card */}
|
695 |
+
<div className="p-4 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
|
696 |
+
<div className="text-sm text-bolt-elements-textSecondary">Ollama Service</div>
|
697 |
+
<div className="flex items-center gap-2 mt-2">
|
698 |
+
<div
|
699 |
+
className={classNames(
|
700 |
+
'w-2 h-2 rounded-full animate-pulse',
|
701 |
+
ollamaStatus.isRunning ? 'bg-green-500' : 'bg-red-500',
|
702 |
+
)}
|
703 |
+
/>
|
704 |
+
<span
|
705 |
+
className={classNames('text-sm font-medium', ollamaStatus.isRunning ? 'text-green-500' : 'text-red-500')}
|
706 |
+
>
|
707 |
+
{ollamaStatus.isRunning ? 'Running' : 'Not Running'}
|
708 |
+
</span>
|
709 |
+
</div>
|
710 |
+
<div className="text-xs text-bolt-elements-textSecondary mt-2">
|
711 |
+
Last checked: {ollamaStatus.lastChecked.toLocaleTimeString()}
|
712 |
+
</div>
|
713 |
+
</div>
|
714 |
+
|
715 |
+
<div className="p-4 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
|
716 |
<div className="text-sm text-bolt-elements-textSecondary">Memory Usage</div>
|
717 |
<div className="text-2xl font-semibold text-bolt-elements-textPrimary mt-1">
|
718 |
{systemInfo?.memory.percentage}%
|
|
|
720 |
<Progress value={systemInfo?.memory.percentage || 0} className="mt-2" />
|
721 |
</div>
|
722 |
|
723 |
+
<div className="p-4 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
|
724 |
<div className="text-sm text-bolt-elements-textSecondary">Page Load Time</div>
|
725 |
<div className="text-2xl font-semibold text-bolt-elements-textPrimary mt-1">
|
726 |
{systemInfo ? (systemInfo.performance.timing.loadTime / 1000).toFixed(2) + 's' : '-'}
|
|
|
730 |
</div>
|
731 |
</div>
|
732 |
|
733 |
+
<div className="p-4 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
|
734 |
<div className="text-sm text-bolt-elements-textSecondary">Network Speed</div>
|
735 |
<div className="text-2xl font-semibold text-bolt-elements-textPrimary mt-1">
|
736 |
{systemInfo?.network.downlink || '-'} Mbps
|
|
|
738 |
<div className="text-xs text-bolt-elements-textSecondary mt-2">RTT: {systemInfo?.network.rtt || '-'} ms</div>
|
739 |
</div>
|
740 |
|
741 |
+
<div className="p-4 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
|
742 |
<div className="text-sm text-bolt-elements-textSecondary">Errors</div>
|
743 |
<div className="text-2xl font-semibold text-bolt-elements-textPrimary mt-1">{errorLogs.length}</div>
|
744 |
</div>
|
|
|
751 |
disabled={loading.systemInfo}
|
752 |
className={classNames(
|
753 |
'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
|
754 |
+
'bg-white dark:bg-[#0A0A0A]',
|
755 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
756 |
+
'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
|
757 |
+
'hover:border-purple-200 dark:hover:border-purple-900/30',
|
758 |
+
'text-bolt-elements-textPrimary',
|
759 |
{ 'opacity-50 cursor-not-allowed': loading.systemInfo },
|
760 |
)}
|
761 |
>
|
|
|
772 |
disabled={loading.performance}
|
773 |
className={classNames(
|
774 |
'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
|
775 |
+
'bg-white dark:bg-[#0A0A0A]',
|
776 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
777 |
+
'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
|
778 |
+
'hover:border-purple-200 dark:hover:border-purple-900/30',
|
779 |
+
'text-bolt-elements-textPrimary',
|
780 |
{ 'opacity-50 cursor-not-allowed': loading.performance },
|
781 |
)}
|
782 |
>
|
|
|
793 |
disabled={loading.errors}
|
794 |
className={classNames(
|
795 |
'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
|
796 |
+
'bg-white dark:bg-[#0A0A0A]',
|
797 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
798 |
+
'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
|
799 |
+
'hover:border-purple-200 dark:hover:border-purple-900/30',
|
800 |
+
'text-bolt-elements-textPrimary',
|
801 |
{ 'opacity-50 cursor-not-allowed': loading.errors },
|
802 |
)}
|
803 |
>
|
|
|
814 |
disabled={loading.webAppInfo}
|
815 |
className={classNames(
|
816 |
'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
|
817 |
+
'bg-white dark:bg-[#0A0A0A]',
|
818 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
819 |
+
'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
|
820 |
+
'hover:border-purple-200 dark:hover:border-purple-900/30',
|
821 |
+
'text-bolt-elements-textPrimary',
|
822 |
{ 'opacity-50 cursor-not-allowed': loading.webAppInfo },
|
823 |
)}
|
824 |
>
|
|
|
834 |
onClick={exportDebugInfo}
|
835 |
className={classNames(
|
836 |
'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
|
837 |
+
'bg-white dark:bg-[#0A0A0A]',
|
838 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
839 |
+
'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
|
840 |
+
'hover:border-purple-200 dark:hover:border-purple-900/30',
|
841 |
+
'text-bolt-elements-textPrimary',
|
842 |
)}
|
843 |
>
|
844 |
<div className="i-ph:download w-4 h-4" />
|
|
|
1249 |
{webAppInfo && (
|
1250 |
<div className="mt-6">
|
1251 |
<h3 className="mb-4 text-base font-medium text-bolt-elements-textPrimary">Dependencies</h3>
|
1252 |
+
<div className="bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] rounded-lg divide-y divide-[#E5E5E5] dark:divide-[#1A1A1A]">
|
1253 |
<DependencySection title="Production" deps={webAppInfo.dependencies.production} />
|
1254 |
<DependencySection title="Development" deps={webAppInfo.dependencies.development} />
|
1255 |
<DependencySection title="Peer" deps={webAppInfo.dependencies.peer} />
|
@@ -0,0 +1,613 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
+
|
9 |
+
interface SelectOption {
|
10 |
+
value: string;
|
11 |
+
label: string;
|
12 |
+
icon?: string;
|
13 |
+
color?: string;
|
14 |
+
}
|
15 |
+
|
16 |
+
const logLevelOptions: SelectOption[] = [
|
17 |
+
{
|
18 |
+
value: 'all',
|
19 |
+
label: 'All Types',
|
20 |
+
icon: 'i-ph:funnel',
|
21 |
+
color: '#9333ea',
|
22 |
+
},
|
23 |
+
{
|
24 |
+
value: 'provider',
|
25 |
+
label: 'LLM',
|
26 |
+
icon: 'i-ph:robot',
|
27 |
+
color: '#10b981',
|
28 |
+
},
|
29 |
+
{
|
30 |
+
value: 'api',
|
31 |
+
label: 'API',
|
32 |
+
icon: 'i-ph:cloud',
|
33 |
+
color: '#3b82f6',
|
34 |
+
},
|
35 |
+
{
|
36 |
+
value: 'error',
|
37 |
+
label: 'Errors',
|
38 |
+
icon: 'i-ph:warning-circle',
|
39 |
+
color: '#ef4444',
|
40 |
+
},
|
41 |
+
{
|
42 |
+
value: 'warning',
|
43 |
+
label: 'Warnings',
|
44 |
+
icon: 'i-ph:warning',
|
45 |
+
color: '#f59e0b',
|
46 |
+
},
|
47 |
+
{
|
48 |
+
value: 'info',
|
49 |
+
label: 'Info',
|
50 |
+
icon: 'i-ph:info',
|
51 |
+
color: '#3b82f6',
|
52 |
+
},
|
53 |
+
{
|
54 |
+
value: 'debug',
|
55 |
+
label: 'Debug',
|
56 |
+
icon: 'i-ph:bug',
|
57 |
+
color: '#6b7280',
|
58 |
+
},
|
59 |
+
];
|
60 |
+
|
61 |
+
interface LogEntryItemProps {
|
62 |
+
log: LogEntry;
|
63 |
+
isExpanded: boolean;
|
64 |
+
use24Hour: boolean;
|
65 |
+
showTimestamp: boolean;
|
66 |
+
}
|
67 |
+
|
68 |
+
const LogEntryItem = ({ log, isExpanded: forceExpanded, use24Hour, showTimestamp }: LogEntryItemProps) => {
|
69 |
+
const [localExpanded, setLocalExpanded] = useState(forceExpanded);
|
70 |
+
|
71 |
+
useEffect(() => {
|
72 |
+
setLocalExpanded(forceExpanded);
|
73 |
+
}, [forceExpanded]);
|
74 |
+
|
75 |
+
const timestamp = useMemo(() => {
|
76 |
+
const date = new Date(log.timestamp);
|
77 |
+
return date.toLocaleTimeString('en-US', { hour12: !use24Hour });
|
78 |
+
}, [log.timestamp, use24Hour]);
|
79 |
+
|
80 |
+
const style = useMemo(() => {
|
81 |
+
if (log.category === 'provider') {
|
82 |
+
return {
|
83 |
+
icon: 'i-ph:robot',
|
84 |
+
color: 'text-emerald-500 dark:text-emerald-400',
|
85 |
+
bg: 'hover:bg-emerald-500/10 dark:hover:bg-emerald-500/20',
|
86 |
+
badge: 'text-emerald-500 bg-emerald-50 dark:bg-emerald-500/10',
|
87 |
+
};
|
88 |
+
}
|
89 |
+
|
90 |
+
if (log.category === 'api') {
|
91 |
+
return {
|
92 |
+
icon: 'i-ph:cloud',
|
93 |
+
color: 'text-blue-500 dark:text-blue-400',
|
94 |
+
bg: 'hover:bg-blue-500/10 dark:hover:bg-blue-500/20',
|
95 |
+
badge: 'text-blue-500 bg-blue-50 dark:bg-blue-500/10',
|
96 |
+
};
|
97 |
+
}
|
98 |
+
|
99 |
+
switch (log.level) {
|
100 |
+
case 'error':
|
101 |
+
return {
|
102 |
+
icon: 'i-ph:warning-circle',
|
103 |
+
color: 'text-red-500 dark:text-red-400',
|
104 |
+
bg: 'hover:bg-red-500/10 dark:hover:bg-red-500/20',
|
105 |
+
badge: 'text-red-500 bg-red-50 dark:bg-red-500/10',
|
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 |
+
badge: 'text-yellow-500 bg-yellow-50 dark:bg-yellow-500/10',
|
113 |
+
};
|
114 |
+
case 'debug':
|
115 |
+
return {
|
116 |
+
icon: 'i-ph:bug',
|
117 |
+
color: 'text-gray-500 dark:text-gray-400',
|
118 |
+
bg: 'hover:bg-gray-500/10 dark:hover:bg-gray-500/20',
|
119 |
+
badge: 'text-gray-500 bg-gray-50 dark:bg-gray-500/10',
|
120 |
+
};
|
121 |
+
default:
|
122 |
+
return {
|
123 |
+
icon: 'i-ph:info',
|
124 |
+
color: 'text-blue-500 dark:text-blue-400',
|
125 |
+
bg: 'hover:bg-blue-500/10 dark:hover:bg-blue-500/20',
|
126 |
+
badge: 'text-blue-500 bg-blue-50 dark:bg-blue-500/10',
|
127 |
+
};
|
128 |
+
}
|
129 |
+
}, [log.level, log.category]);
|
130 |
+
|
131 |
+
const renderDetails = (details: any) => {
|
132 |
+
if (log.category === 'provider') {
|
133 |
+
return (
|
134 |
+
<div className="flex flex-col gap-2">
|
135 |
+
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
136 |
+
<span>Model: {details.model}</span>
|
137 |
+
<span>β’</span>
|
138 |
+
<span>Tokens: {details.totalTokens}</span>
|
139 |
+
<span>β’</span>
|
140 |
+
<span>Duration: {details.duration}ms</span>
|
141 |
+
</div>
|
142 |
+
{details.prompt && (
|
143 |
+
<div className="flex flex-col gap-1">
|
144 |
+
<div className="text-xs font-medium text-gray-700 dark:text-gray-300">Prompt:</div>
|
145 |
+
<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">
|
146 |
+
{details.prompt}
|
147 |
+
</pre>
|
148 |
+
</div>
|
149 |
+
)}
|
150 |
+
{details.response && (
|
151 |
+
<div className="flex flex-col gap-1">
|
152 |
+
<div className="text-xs font-medium text-gray-700 dark:text-gray-300">Response:</div>
|
153 |
+
<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">
|
154 |
+
{details.response}
|
155 |
+
</pre>
|
156 |
+
</div>
|
157 |
+
)}
|
158 |
+
</div>
|
159 |
+
);
|
160 |
+
}
|
161 |
+
|
162 |
+
if (log.category === 'api') {
|
163 |
+
return (
|
164 |
+
<div className="flex flex-col gap-2">
|
165 |
+
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
166 |
+
<span className={details.method === 'GET' ? 'text-green-500' : 'text-blue-500'}>{details.method}</span>
|
167 |
+
<span>β’</span>
|
168 |
+
<span>Status: {details.statusCode}</span>
|
169 |
+
<span>β’</span>
|
170 |
+
<span>Duration: {details.duration}ms</span>
|
171 |
+
</div>
|
172 |
+
<div className="text-xs text-gray-600 dark:text-gray-400 break-all">{details.url}</div>
|
173 |
+
{details.request && (
|
174 |
+
<div className="flex flex-col gap-1">
|
175 |
+
<div className="text-xs font-medium text-gray-700 dark:text-gray-300">Request:</div>
|
176 |
+
<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">
|
177 |
+
{JSON.stringify(details.request, null, 2)}
|
178 |
+
</pre>
|
179 |
+
</div>
|
180 |
+
)}
|
181 |
+
{details.response && (
|
182 |
+
<div className="flex flex-col gap-1">
|
183 |
+
<div className="text-xs font-medium text-gray-700 dark:text-gray-300">Response:</div>
|
184 |
+
<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">
|
185 |
+
{JSON.stringify(details.response, null, 2)}
|
186 |
+
</pre>
|
187 |
+
</div>
|
188 |
+
)}
|
189 |
+
{details.error && (
|
190 |
+
<div className="flex flex-col gap-1">
|
191 |
+
<div className="text-xs font-medium text-red-500">Error:</div>
|
192 |
+
<pre className="text-xs text-red-400 bg-red-50 dark:bg-red-500/10 rounded p-2 whitespace-pre-wrap">
|
193 |
+
{JSON.stringify(details.error, null, 2)}
|
194 |
+
</pre>
|
195 |
+
</div>
|
196 |
+
)}
|
197 |
+
</div>
|
198 |
+
);
|
199 |
+
}
|
200 |
+
|
201 |
+
return (
|
202 |
+
<pre className="text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded whitespace-pre-wrap">
|
203 |
+
{JSON.stringify(details, null, 2)}
|
204 |
+
</pre>
|
205 |
+
);
|
206 |
+
};
|
207 |
+
|
208 |
+
return (
|
209 |
+
<motion.div
|
210 |
+
initial={{ opacity: 0, y: 20 }}
|
211 |
+
animate={{ opacity: 1, y: 0 }}
|
212 |
+
className={classNames(
|
213 |
+
'flex flex-col gap-2',
|
214 |
+
'rounded-lg p-4',
|
215 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
216 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
217 |
+
style.bg,
|
218 |
+
'transition-all duration-200',
|
219 |
+
)}
|
220 |
+
>
|
221 |
+
<div className="flex items-start justify-between gap-4">
|
222 |
+
<div className="flex items-start gap-3">
|
223 |
+
<span className={classNames('text-lg', style.icon, style.color)} />
|
224 |
+
<div className="flex flex-col gap-1">
|
225 |
+
<div className="text-sm font-medium text-gray-900 dark:text-white">{log.message}</div>
|
226 |
+
{log.details && (
|
227 |
+
<>
|
228 |
+
<button
|
229 |
+
onClick={() => setLocalExpanded(!localExpanded)}
|
230 |
+
className="text-xs text-gray-500 dark:text-gray-400 hover:text-purple-500 dark:hover:text-purple-400 transition-colors"
|
231 |
+
>
|
232 |
+
{localExpanded ? 'Hide' : 'Show'} Details
|
233 |
+
</button>
|
234 |
+
{localExpanded && renderDetails(log.details)}
|
235 |
+
</>
|
236 |
+
)}
|
237 |
+
<div className="flex items-center gap-2">
|
238 |
+
<div className={classNames('px-2 py-0.5 rounded text-xs font-medium uppercase', style.badge)}>
|
239 |
+
{log.level}
|
240 |
+
</div>
|
241 |
+
{log.category && (
|
242 |
+
<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">
|
243 |
+
{log.category}
|
244 |
+
</div>
|
245 |
+
)}
|
246 |
+
</div>
|
247 |
+
</div>
|
248 |
+
</div>
|
249 |
+
{showTimestamp && <time className="shrink-0 text-xs text-gray-500 dark:text-gray-400">{timestamp}</time>}
|
250 |
+
</div>
|
251 |
+
</motion.div>
|
252 |
+
);
|
253 |
+
};
|
254 |
+
|
255 |
+
export function EventLogsTab() {
|
256 |
+
const logs = useStore(logStore.logs);
|
257 |
+
const [selectedLevel, setSelectedLevel] = useState<'all' | string>('all');
|
258 |
+
const [searchQuery, setSearchQuery] = useState('');
|
259 |
+
const [use24Hour, setUse24Hour] = useState(false);
|
260 |
+
const [autoExpand, setAutoExpand] = useState(false);
|
261 |
+
const [showTimestamps, setShowTimestamps] = useState(true);
|
262 |
+
const [showLevelFilter, setShowLevelFilter] = useState(false);
|
263 |
+
const [isRefreshing, setIsRefreshing] = useState(false);
|
264 |
+
const levelFilterRef = useRef<HTMLDivElement>(null);
|
265 |
+
|
266 |
+
const filteredLogs = useMemo(() => {
|
267 |
+
const allLogs = Object.values(logs);
|
268 |
+
|
269 |
+
if (selectedLevel === 'all') {
|
270 |
+
return allLogs.filter((log) =>
|
271 |
+
searchQuery ? log.message.toLowerCase().includes(searchQuery.toLowerCase()) : true,
|
272 |
+
);
|
273 |
+
}
|
274 |
+
|
275 |
+
return allLogs.filter((log) => {
|
276 |
+
const matchesType = log.category === selectedLevel || log.level === selectedLevel;
|
277 |
+
const matchesSearch = searchQuery ? log.message.toLowerCase().includes(searchQuery.toLowerCase()) : true;
|
278 |
+
|
279 |
+
return matchesType && matchesSearch;
|
280 |
+
});
|
281 |
+
}, [logs, selectedLevel, searchQuery]);
|
282 |
+
|
283 |
+
// Add performance tracking on mount
|
284 |
+
useEffect(() => {
|
285 |
+
const startTime = performance.now();
|
286 |
+
|
287 |
+
logStore.logInfo('Event Logs tab mounted', {
|
288 |
+
type: 'component_mount',
|
289 |
+
message: 'Event Logs tab component mounted',
|
290 |
+
component: 'EventLogsTab',
|
291 |
+
});
|
292 |
+
|
293 |
+
return () => {
|
294 |
+
const duration = performance.now() - startTime;
|
295 |
+
logStore.logPerformanceMetric('EventLogsTab', 'mount-duration', duration);
|
296 |
+
};
|
297 |
+
}, []);
|
298 |
+
|
299 |
+
// Log filter changes
|
300 |
+
const handleLevelFilterChange = useCallback(
|
301 |
+
(newLevel: string) => {
|
302 |
+
logStore.logInfo('Log level filter changed', {
|
303 |
+
type: 'filter_change',
|
304 |
+
message: `Log level filter changed from ${selectedLevel} to ${newLevel}`,
|
305 |
+
component: 'EventLogsTab',
|
306 |
+
previousLevel: selectedLevel,
|
307 |
+
newLevel,
|
308 |
+
});
|
309 |
+
setSelectedLevel(newLevel as string);
|
310 |
+
setShowLevelFilter(false);
|
311 |
+
},
|
312 |
+
[selectedLevel],
|
313 |
+
);
|
314 |
+
|
315 |
+
// Log search changes with debounce
|
316 |
+
useEffect(() => {
|
317 |
+
const timeoutId = setTimeout(() => {
|
318 |
+
if (searchQuery) {
|
319 |
+
logStore.logInfo('Log search performed', {
|
320 |
+
type: 'search',
|
321 |
+
message: `Search performed with query "${searchQuery}" (${filteredLogs.length} results)`,
|
322 |
+
component: 'EventLogsTab',
|
323 |
+
query: searchQuery,
|
324 |
+
resultsCount: filteredLogs.length,
|
325 |
+
});
|
326 |
+
}
|
327 |
+
}, 1000);
|
328 |
+
|
329 |
+
return () => clearTimeout(timeoutId);
|
330 |
+
}, [searchQuery, filteredLogs.length]);
|
331 |
+
|
332 |
+
// Enhanced export logs handler
|
333 |
+
const handleExportLogs = useCallback(() => {
|
334 |
+
const startTime = performance.now();
|
335 |
+
|
336 |
+
try {
|
337 |
+
const exportData = {
|
338 |
+
timestamp: new Date().toISOString(),
|
339 |
+
logs: filteredLogs,
|
340 |
+
filters: {
|
341 |
+
level: selectedLevel,
|
342 |
+
searchQuery,
|
343 |
+
},
|
344 |
+
};
|
345 |
+
|
346 |
+
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
347 |
+
const url = URL.createObjectURL(blob);
|
348 |
+
const a = document.createElement('a');
|
349 |
+
a.href = url;
|
350 |
+
a.download = `bolt-logs-${new Date().toISOString()}.json`;
|
351 |
+
document.body.appendChild(a);
|
352 |
+
a.click();
|
353 |
+
document.body.removeChild(a);
|
354 |
+
URL.revokeObjectURL(url);
|
355 |
+
|
356 |
+
const duration = performance.now() - startTime;
|
357 |
+
logStore.logSuccess('Logs exported successfully', {
|
358 |
+
type: 'export',
|
359 |
+
message: `Successfully exported ${filteredLogs.length} logs`,
|
360 |
+
component: 'EventLogsTab',
|
361 |
+
exportedCount: filteredLogs.length,
|
362 |
+
filters: {
|
363 |
+
level: selectedLevel,
|
364 |
+
searchQuery,
|
365 |
+
},
|
366 |
+
duration,
|
367 |
+
});
|
368 |
+
} catch (error) {
|
369 |
+
logStore.logError('Failed to export logs', error, {
|
370 |
+
type: 'export_error',
|
371 |
+
message: 'Failed to export logs',
|
372 |
+
component: 'EventLogsTab',
|
373 |
+
});
|
374 |
+
}
|
375 |
+
}, [filteredLogs, selectedLevel, searchQuery]);
|
376 |
+
|
377 |
+
// Enhanced refresh handler
|
378 |
+
const handleRefresh = useCallback(async () => {
|
379 |
+
const startTime = performance.now();
|
380 |
+
setIsRefreshing(true);
|
381 |
+
|
382 |
+
try {
|
383 |
+
await logStore.refreshLogs();
|
384 |
+
|
385 |
+
const duration = performance.now() - startTime;
|
386 |
+
|
387 |
+
logStore.logSuccess('Logs refreshed successfully', {
|
388 |
+
type: 'refresh',
|
389 |
+
message: `Successfully refreshed ${Object.keys(logs).length} logs`,
|
390 |
+
component: 'EventLogsTab',
|
391 |
+
duration,
|
392 |
+
logsCount: Object.keys(logs).length,
|
393 |
+
});
|
394 |
+
} catch (error) {
|
395 |
+
logStore.logError('Failed to refresh logs', error, {
|
396 |
+
type: 'refresh_error',
|
397 |
+
message: 'Failed to refresh logs',
|
398 |
+
component: 'EventLogsTab',
|
399 |
+
});
|
400 |
+
} finally {
|
401 |
+
setTimeout(() => setIsRefreshing(false), 500);
|
402 |
+
}
|
403 |
+
}, [logs]);
|
404 |
+
|
405 |
+
// Log preference changes
|
406 |
+
const handlePreferenceChange = useCallback((type: string, value: boolean) => {
|
407 |
+
logStore.logInfo('Log preference changed', {
|
408 |
+
type: 'preference_change',
|
409 |
+
message: `Log preference "${type}" changed to ${value}`,
|
410 |
+
component: 'EventLogsTab',
|
411 |
+
preference: type,
|
412 |
+
value,
|
413 |
+
});
|
414 |
+
|
415 |
+
switch (type) {
|
416 |
+
case 'timestamps':
|
417 |
+
setShowTimestamps(value);
|
418 |
+
break;
|
419 |
+
case '24hour':
|
420 |
+
setUse24Hour(value);
|
421 |
+
break;
|
422 |
+
case 'autoExpand':
|
423 |
+
setAutoExpand(value);
|
424 |
+
break;
|
425 |
+
}
|
426 |
+
}, []);
|
427 |
+
|
428 |
+
// Close filters when clicking outside
|
429 |
+
useEffect(() => {
|
430 |
+
const handleClickOutside = (event: MouseEvent) => {
|
431 |
+
if (levelFilterRef.current && !levelFilterRef.current.contains(event.target as Node)) {
|
432 |
+
setShowLevelFilter(false);
|
433 |
+
}
|
434 |
+
};
|
435 |
+
|
436 |
+
document.addEventListener('mousedown', handleClickOutside);
|
437 |
+
|
438 |
+
return () => {
|
439 |
+
document.removeEventListener('mousedown', handleClickOutside);
|
440 |
+
};
|
441 |
+
}, []);
|
442 |
+
|
443 |
+
const selectedLevelOption = logLevelOptions.find((opt) => opt.value === selectedLevel);
|
444 |
+
|
445 |
+
return (
|
446 |
+
<div className="flex h-full flex-col gap-6">
|
447 |
+
<div className="flex items-center justify-between">
|
448 |
+
<DropdownMenu.Root open={showLevelFilter} onOpenChange={setShowLevelFilter}>
|
449 |
+
<DropdownMenu.Trigger asChild>
|
450 |
+
<button
|
451 |
+
className={classNames(
|
452 |
+
'flex items-center gap-2',
|
453 |
+
'rounded-lg px-3 py-1.5',
|
454 |
+
'text-sm text-gray-900 dark:text-white',
|
455 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
456 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
457 |
+
'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
|
458 |
+
'transition-all duration-200',
|
459 |
+
)}
|
460 |
+
>
|
461 |
+
<span
|
462 |
+
className={classNames('text-lg', selectedLevelOption?.icon || 'i-ph:funnel')}
|
463 |
+
style={{ color: selectedLevelOption?.color }}
|
464 |
+
/>
|
465 |
+
{selectedLevelOption?.label || 'All Types'}
|
466 |
+
<span className="i-ph:caret-down text-lg text-gray-500 dark:text-gray-400" />
|
467 |
+
</button>
|
468 |
+
</DropdownMenu.Trigger>
|
469 |
+
|
470 |
+
<DropdownMenu.Portal>
|
471 |
+
<DropdownMenu.Content
|
472 |
+
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]"
|
473 |
+
sideOffset={5}
|
474 |
+
align="start"
|
475 |
+
side="bottom"
|
476 |
+
>
|
477 |
+
{logLevelOptions.map((option) => (
|
478 |
+
<DropdownMenu.Item
|
479 |
+
key={option.value}
|
480 |
+
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"
|
481 |
+
onClick={() => handleLevelFilterChange(option.value)}
|
482 |
+
>
|
483 |
+
<div className="mr-3 flex h-5 w-5 items-center justify-center">
|
484 |
+
<div
|
485 |
+
className={classNames(option.icon, 'text-lg group-hover:text-purple-500 transition-colors')}
|
486 |
+
style={{ color: option.color }}
|
487 |
+
/>
|
488 |
+
</div>
|
489 |
+
<span className="group-hover:text-purple-500 transition-colors">{option.label}</span>
|
490 |
+
</DropdownMenu.Item>
|
491 |
+
))}
|
492 |
+
</DropdownMenu.Content>
|
493 |
+
</DropdownMenu.Portal>
|
494 |
+
</DropdownMenu.Root>
|
495 |
+
|
496 |
+
<div className="flex items-center gap-4">
|
497 |
+
<div className="flex items-center gap-2">
|
498 |
+
<Switch
|
499 |
+
checked={showTimestamps}
|
500 |
+
onCheckedChange={(value) => handlePreferenceChange('timestamps', value)}
|
501 |
+
className="data-[state=checked]:bg-purple-500"
|
502 |
+
/>
|
503 |
+
<span className="text-sm text-gray-500 dark:text-gray-400">Show Timestamps</span>
|
504 |
+
</div>
|
505 |
+
|
506 |
+
<div className="flex items-center gap-2">
|
507 |
+
<Switch
|
508 |
+
checked={use24Hour}
|
509 |
+
onCheckedChange={(value) => handlePreferenceChange('24hour', value)}
|
510 |
+
className="data-[state=checked]:bg-purple-500"
|
511 |
+
/>
|
512 |
+
<span className="text-sm text-gray-500 dark:text-gray-400">24h Time</span>
|
513 |
+
</div>
|
514 |
+
|
515 |
+
<div className="flex items-center gap-2">
|
516 |
+
<Switch
|
517 |
+
checked={autoExpand}
|
518 |
+
onCheckedChange={(value) => handlePreferenceChange('autoExpand', value)}
|
519 |
+
className="data-[state=checked]:bg-purple-500"
|
520 |
+
/>
|
521 |
+
<span className="text-sm text-gray-500 dark:text-gray-400">Auto Expand</span>
|
522 |
+
</div>
|
523 |
+
|
524 |
+
<div className="w-px h-4 bg-gray-200 dark:bg-gray-700" />
|
525 |
+
|
526 |
+
<button
|
527 |
+
onClick={handleRefresh}
|
528 |
+
className={classNames(
|
529 |
+
'group flex items-center gap-2',
|
530 |
+
'rounded-lg px-3 py-1.5',
|
531 |
+
'text-sm text-gray-900 dark:text-white',
|
532 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
533 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
534 |
+
'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
|
535 |
+
'transition-all duration-200',
|
536 |
+
{ 'animate-spin': isRefreshing },
|
537 |
+
)}
|
538 |
+
>
|
539 |
+
<span className="i-ph:arrows-clockwise text-lg text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
540 |
+
Refresh
|
541 |
+
</button>
|
542 |
+
|
543 |
+
<button
|
544 |
+
onClick={handleExportLogs}
|
545 |
+
className={classNames(
|
546 |
+
'group flex items-center gap-2',
|
547 |
+
'rounded-lg px-3 py-1.5',
|
548 |
+
'text-sm text-gray-900 dark:text-white',
|
549 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
550 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
551 |
+
'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
|
552 |
+
'transition-all duration-200',
|
553 |
+
)}
|
554 |
+
>
|
555 |
+
<span className="i-ph:download text-lg text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
556 |
+
Export
|
557 |
+
</button>
|
558 |
+
</div>
|
559 |
+
</div>
|
560 |
+
|
561 |
+
<div className="flex flex-col gap-4">
|
562 |
+
<div className="relative">
|
563 |
+
<input
|
564 |
+
type="text"
|
565 |
+
placeholder="Search logs..."
|
566 |
+
value={searchQuery}
|
567 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
568 |
+
className={classNames(
|
569 |
+
'w-full px-4 py-2 pl-10 rounded-lg',
|
570 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
571 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
572 |
+
'text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400',
|
573 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500',
|
574 |
+
'transition-all duration-200',
|
575 |
+
)}
|
576 |
+
/>
|
577 |
+
<div className="absolute left-3 top-1/2 -translate-y-1/2">
|
578 |
+
<div className="i-ph:magnifying-glass text-lg text-gray-500 dark:text-gray-400" />
|
579 |
+
</div>
|
580 |
+
</div>
|
581 |
+
|
582 |
+
{filteredLogs.length === 0 ? (
|
583 |
+
<motion.div
|
584 |
+
initial={{ opacity: 0, y: 20 }}
|
585 |
+
animate={{ opacity: 1, y: 0 }}
|
586 |
+
className={classNames(
|
587 |
+
'flex flex-col items-center justify-center gap-4',
|
588 |
+
'rounded-lg p-8 text-center',
|
589 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
590 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
591 |
+
)}
|
592 |
+
>
|
593 |
+
<span className="i-ph:clipboard-text text-4xl text-gray-400 dark:text-gray-600" />
|
594 |
+
<div className="flex flex-col gap-1">
|
595 |
+
<h3 className="text-sm font-medium text-gray-900 dark:text-white">No Logs Found</h3>
|
596 |
+
<p className="text-sm text-gray-500 dark:text-gray-400">Try adjusting your search or filters</p>
|
597 |
+
</div>
|
598 |
+
</motion.div>
|
599 |
+
) : (
|
600 |
+
filteredLogs.map((log) => (
|
601 |
+
<LogEntryItem
|
602 |
+
key={log.id}
|
603 |
+
log={log}
|
604 |
+
isExpanded={autoExpand}
|
605 |
+
use24Hour={use24Hour}
|
606 |
+
showTimestamp={showTimestamps}
|
607 |
+
/>
|
608 |
+
))
|
609 |
+
)}
|
610 |
+
</div>
|
611 |
+
</div>
|
612 |
+
);
|
613 |
+
}
|
@@ -111,44 +111,66 @@ export default function FeaturesTab() {
|
|
111 |
isLatestBranch,
|
112 |
contextOptimizationEnabled,
|
113 |
eventLogs,
|
114 |
-
isLocalModel,
|
115 |
setAutoSelectTemplate,
|
116 |
enableLatestBranch,
|
117 |
enableContextOptimization,
|
118 |
setEventLogs,
|
119 |
-
enableLocalModels,
|
120 |
setPromptId,
|
121 |
promptId,
|
122 |
} = useSettings();
|
123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
124 |
const handleToggleFeature = useCallback(
|
125 |
(id: string, enabled: boolean) => {
|
126 |
switch (id) {
|
127 |
-
case 'latestBranch':
|
128 |
enableLatestBranch(enabled);
|
129 |
toast.success(`Main branch updates ${enabled ? 'enabled' : 'disabled'}`);
|
130 |
break;
|
131 |
-
|
|
|
|
|
132 |
setAutoSelectTemplate(enabled);
|
133 |
toast.success(`Auto select template ${enabled ? 'enabled' : 'disabled'}`);
|
134 |
break;
|
135 |
-
|
|
|
|
|
136 |
enableContextOptimization(enabled);
|
137 |
toast.success(`Context optimization ${enabled ? 'enabled' : 'disabled'}`);
|
138 |
break;
|
139 |
-
|
|
|
|
|
140 |
setEventLogs(enabled);
|
141 |
toast.success(`Event logging ${enabled ? 'enabled' : 'disabled'}`);
|
142 |
break;
|
143 |
-
|
144 |
-
|
145 |
-
toast.success(`Experimental providers ${enabled ? 'enabled' : 'disabled'}`);
|
146 |
-
break;
|
147 |
default:
|
148 |
break;
|
149 |
}
|
150 |
},
|
151 |
-
[enableLatestBranch, setAutoSelectTemplate, enableContextOptimization, setEventLogs
|
152 |
);
|
153 |
|
154 |
const features = {
|
@@ -159,7 +181,7 @@ export default function FeaturesTab() {
|
|
159 |
description: 'Get the latest updates from the main branch',
|
160 |
icon: 'i-ph:git-branch',
|
161 |
enabled: isLatestBranch,
|
162 |
-
tooltip: '
|
163 |
},
|
164 |
{
|
165 |
id: 'autoSelectTemplate',
|
@@ -167,7 +189,7 @@ export default function FeaturesTab() {
|
|
167 |
description: 'Automatically select starter template',
|
168 |
icon: 'i-ph:selection',
|
169 |
enabled: autoSelectTemplate,
|
170 |
-
tooltip: '
|
171 |
},
|
172 |
{
|
173 |
id: 'contextOptimization',
|
@@ -175,7 +197,7 @@ export default function FeaturesTab() {
|
|
175 |
description: 'Optimize context for better responses',
|
176 |
icon: 'i-ph:brain',
|
177 |
enabled: contextOptimizationEnabled,
|
178 |
-
tooltip: '
|
179 |
},
|
180 |
{
|
181 |
id: 'eventLogs',
|
@@ -183,30 +205,19 @@ export default function FeaturesTab() {
|
|
183 |
description: 'Enable detailed event logging and history',
|
184 |
icon: 'i-ph:list-bullets',
|
185 |
enabled: eventLogs,
|
186 |
-
tooltip: '
|
187 |
},
|
188 |
],
|
189 |
beta: [],
|
190 |
-
experimental: [
|
191 |
-
{
|
192 |
-
id: 'localModels',
|
193 |
-
title: 'Experimental Providers',
|
194 |
-
description: 'Enable experimental providers like Ollama, LMStudio, and OpenAILike',
|
195 |
-
icon: 'i-ph:robot',
|
196 |
-
enabled: isLocalModel,
|
197 |
-
experimental: true,
|
198 |
-
tooltip: 'Try out new AI providers and models in development',
|
199 |
-
},
|
200 |
-
],
|
201 |
};
|
202 |
|
203 |
return (
|
204 |
<div className="flex flex-col gap-8">
|
205 |
<FeatureSection
|
206 |
-
title="
|
207 |
features={features.stable}
|
208 |
icon="i-ph:check-circle"
|
209 |
-
description="
|
210 |
onToggleFeature={handleToggleFeature}
|
211 |
/>
|
212 |
|
@@ -220,16 +231,6 @@ export default function FeaturesTab() {
|
|
220 |
/>
|
221 |
)}
|
222 |
|
223 |
-
{features.experimental.length > 0 && (
|
224 |
-
<FeatureSection
|
225 |
-
title="Experimental Features"
|
226 |
-
features={features.experimental}
|
227 |
-
icon="i-ph:flask"
|
228 |
-
description="Features in early development that may be unstable or require additional setup"
|
229 |
-
onToggleFeature={handleToggleFeature}
|
230 |
-
/>
|
231 |
-
)}
|
232 |
-
|
233 |
<motion.div
|
234 |
layout
|
235 |
className={classNames(
|
|
|
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 enable if they haven't been explicitly set before
|
125 |
+
if (isLatestBranch === undefined) {
|
126 |
+
enableLatestBranch(true);
|
127 |
+
}
|
128 |
+
|
129 |
+
if (contextOptimizationEnabled === undefined) {
|
130 |
+
enableContextOptimization(true);
|
131 |
+
}
|
132 |
+
|
133 |
+
if (autoSelectTemplate === undefined) {
|
134 |
+
setAutoSelectTemplate(true);
|
135 |
+
}
|
136 |
+
|
137 |
+
if (eventLogs === undefined) {
|
138 |
+
setEventLogs(true);
|
139 |
+
}
|
140 |
+
}, []); // Only run once on component mount
|
141 |
+
|
142 |
const handleToggleFeature = useCallback(
|
143 |
(id: string, enabled: boolean) => {
|
144 |
switch (id) {
|
145 |
+
case 'latestBranch': {
|
146 |
enableLatestBranch(enabled);
|
147 |
toast.success(`Main branch updates ${enabled ? 'enabled' : 'disabled'}`);
|
148 |
break;
|
149 |
+
}
|
150 |
+
|
151 |
+
case 'autoSelectTemplate': {
|
152 |
setAutoSelectTemplate(enabled);
|
153 |
toast.success(`Auto select template ${enabled ? 'enabled' : 'disabled'}`);
|
154 |
break;
|
155 |
+
}
|
156 |
+
|
157 |
+
case 'contextOptimization': {
|
158 |
enableContextOptimization(enabled);
|
159 |
toast.success(`Context optimization ${enabled ? 'enabled' : 'disabled'}`);
|
160 |
break;
|
161 |
+
}
|
162 |
+
|
163 |
+
case 'eventLogs': {
|
164 |
setEventLogs(enabled);
|
165 |
toast.success(`Event logging ${enabled ? 'enabled' : 'disabled'}`);
|
166 |
break;
|
167 |
+
}
|
168 |
+
|
|
|
|
|
169 |
default:
|
170 |
break;
|
171 |
}
|
172 |
},
|
173 |
+
[enableLatestBranch, setAutoSelectTemplate, enableContextOptimization, setEventLogs],
|
174 |
);
|
175 |
|
176 |
const features = {
|
|
|
181 |
description: 'Get the latest updates from the main branch',
|
182 |
icon: 'i-ph:git-branch',
|
183 |
enabled: isLatestBranch,
|
184 |
+
tooltip: 'Enabled by default to receive updates from the main development branch',
|
185 |
},
|
186 |
{
|
187 |
id: 'autoSelectTemplate',
|
|
|
189 |
description: 'Automatically select starter template',
|
190 |
icon: 'i-ph:selection',
|
191 |
enabled: autoSelectTemplate,
|
192 |
+
tooltip: 'Enabled by default to automatically select the most appropriate starter template',
|
193 |
},
|
194 |
{
|
195 |
id: 'contextOptimization',
|
|
|
197 |
description: 'Optimize context for better responses',
|
198 |
icon: 'i-ph:brain',
|
199 |
enabled: contextOptimizationEnabled,
|
200 |
+
tooltip: 'Enabled by default for improved AI responses',
|
201 |
},
|
202 |
{
|
203 |
id: 'eventLogs',
|
|
|
205 |
description: 'Enable detailed event logging and history',
|
206 |
icon: 'i-ph:list-bullets',
|
207 |
enabled: eventLogs,
|
208 |
+
tooltip: 'Enabled by default to record detailed logs of system events and user actions',
|
209 |
},
|
210 |
],
|
211 |
beta: [],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
212 |
};
|
213 |
|
214 |
return (
|
215 |
<div className="flex flex-col gap-8">
|
216 |
<FeatureSection
|
217 |
+
title="Core Features"
|
218 |
features={features.stable}
|
219 |
icon="i-ph:check-circle"
|
220 |
+
description="Essential features that are enabled by default for optimal performance"
|
221 |
onToggleFeature={handleToggleFeature}
|
222 |
/>
|
223 |
|
|
|
231 |
/>
|
232 |
)}
|
233 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
234 |
<motion.div
|
235 |
layout
|
236 |
className={classNames(
|
File without changes
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useState } 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 |
+
|
7 |
+
export default function ProfileTab() {
|
8 |
+
const profile = useStore(profileStore);
|
9 |
+
const [isUploading, setIsUploading] = useState(false);
|
10 |
+
|
11 |
+
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
12 |
+
const file = e.target.files?.[0];
|
13 |
+
|
14 |
+
if (!file) {
|
15 |
+
return;
|
16 |
+
}
|
17 |
+
|
18 |
+
try {
|
19 |
+
setIsUploading(true);
|
20 |
+
|
21 |
+
// Convert the file to base64
|
22 |
+
const reader = new FileReader();
|
23 |
+
|
24 |
+
reader.onloadend = () => {
|
25 |
+
const base64String = reader.result as string;
|
26 |
+
updateProfile({ avatar: base64String });
|
27 |
+
setIsUploading(false);
|
28 |
+
toast.success('Profile picture updated');
|
29 |
+
};
|
30 |
+
|
31 |
+
reader.onerror = () => {
|
32 |
+
console.error('Error reading file:', reader.error);
|
33 |
+
setIsUploading(false);
|
34 |
+
toast.error('Failed to update profile picture');
|
35 |
+
};
|
36 |
+
reader.readAsDataURL(file);
|
37 |
+
} catch (error) {
|
38 |
+
console.error('Error uploading avatar:', error);
|
39 |
+
setIsUploading(false);
|
40 |
+
toast.error('Failed to update profile picture');
|
41 |
+
}
|
42 |
+
};
|
43 |
+
|
44 |
+
const handleProfileUpdate = (field: 'username' | 'bio', value: string) => {
|
45 |
+
updateProfile({ [field]: value });
|
46 |
+
|
47 |
+
// Only show toast for completed typing (after 1 second of no typing)
|
48 |
+
const debounceToast = setTimeout(() => {
|
49 |
+
toast.success(`${field.charAt(0).toUpperCase() + field.slice(1)} updated`);
|
50 |
+
}, 1000);
|
51 |
+
|
52 |
+
return () => clearTimeout(debounceToast);
|
53 |
+
};
|
54 |
+
|
55 |
+
return (
|
56 |
+
<div className="max-w-2xl mx-auto">
|
57 |
+
<div className="space-y-6">
|
58 |
+
{/* Personal Information Section */}
|
59 |
+
<div>
|
60 |
+
{/* Avatar Upload */}
|
61 |
+
<div className="flex items-start gap-6 mb-8">
|
62 |
+
<div
|
63 |
+
className={classNames(
|
64 |
+
'w-24 h-24 rounded-full overflow-hidden',
|
65 |
+
'bg-gray-100 dark:bg-gray-800/50',
|
66 |
+
'flex items-center justify-center',
|
67 |
+
'ring-1 ring-gray-200 dark:ring-gray-700',
|
68 |
+
'relative group',
|
69 |
+
'transition-all duration-300 ease-out',
|
70 |
+
'hover:ring-purple-500/30 dark:hover:ring-purple-500/30',
|
71 |
+
'hover:shadow-lg hover:shadow-purple-500/10',
|
72 |
+
)}
|
73 |
+
>
|
74 |
+
{profile.avatar ? (
|
75 |
+
<img
|
76 |
+
src={profile.avatar}
|
77 |
+
alt="Profile"
|
78 |
+
className={classNames(
|
79 |
+
'w-full h-full object-cover',
|
80 |
+
'transition-all duration-300 ease-out',
|
81 |
+
'group-hover:scale-105 group-hover:brightness-90',
|
82 |
+
)}
|
83 |
+
/>
|
84 |
+
) : (
|
85 |
+
<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" />
|
86 |
+
)}
|
87 |
+
|
88 |
+
<label
|
89 |
+
className={classNames(
|
90 |
+
'absolute inset-0',
|
91 |
+
'flex items-center justify-center',
|
92 |
+
'bg-black/0 group-hover:bg-black/40',
|
93 |
+
'cursor-pointer transition-all duration-300 ease-out',
|
94 |
+
isUploading ? 'cursor-wait' : '',
|
95 |
+
)}
|
96 |
+
>
|
97 |
+
<input
|
98 |
+
type="file"
|
99 |
+
accept="image/*"
|
100 |
+
className="hidden"
|
101 |
+
onChange={handleAvatarUpload}
|
102 |
+
disabled={isUploading}
|
103 |
+
/>
|
104 |
+
{isUploading ? (
|
105 |
+
<div className="i-ph:spinner-gap w-6 h-6 text-white animate-spin" />
|
106 |
+
) : (
|
107 |
+
<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" />
|
108 |
+
)}
|
109 |
+
</label>
|
110 |
+
</div>
|
111 |
+
|
112 |
+
<div className="flex-1 pt-1">
|
113 |
+
<label className="block text-base font-medium text-gray-900 dark:text-gray-100 mb-1">
|
114 |
+
Profile Picture
|
115 |
+
</label>
|
116 |
+
<p className="text-sm text-gray-500 dark:text-gray-400">Upload a profile picture or avatar</p>
|
117 |
+
</div>
|
118 |
+
</div>
|
119 |
+
|
120 |
+
{/* Username Input */}
|
121 |
+
<div className="mb-6">
|
122 |
+
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Username</label>
|
123 |
+
<div className="relative group">
|
124 |
+
<div className="absolute left-3.5 top-1/2 -translate-y-1/2">
|
125 |
+
<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" />
|
126 |
+
</div>
|
127 |
+
<input
|
128 |
+
type="text"
|
129 |
+
value={profile.username}
|
130 |
+
onChange={(e) => handleProfileUpdate('username', e.target.value)}
|
131 |
+
className={classNames(
|
132 |
+
'w-full pl-11 pr-4 py-2.5 rounded-xl',
|
133 |
+
'bg-white dark:bg-gray-800/50',
|
134 |
+
'border border-gray-200 dark:border-gray-700/50',
|
135 |
+
'text-gray-900 dark:text-white',
|
136 |
+
'placeholder-gray-400 dark:placeholder-gray-500',
|
137 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500/50',
|
138 |
+
'transition-all duration-300 ease-out',
|
139 |
+
)}
|
140 |
+
placeholder="Enter your username"
|
141 |
+
/>
|
142 |
+
</div>
|
143 |
+
</div>
|
144 |
+
|
145 |
+
{/* Bio Input */}
|
146 |
+
<div className="mb-8">
|
147 |
+
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Bio</label>
|
148 |
+
<div className="relative group">
|
149 |
+
<div className="absolute left-3.5 top-3">
|
150 |
+
<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" />
|
151 |
+
</div>
|
152 |
+
<textarea
|
153 |
+
value={profile.bio}
|
154 |
+
onChange={(e) => handleProfileUpdate('bio', e.target.value)}
|
155 |
+
className={classNames(
|
156 |
+
'w-full pl-11 pr-4 py-2.5 rounded-xl',
|
157 |
+
'bg-white dark:bg-gray-800/50',
|
158 |
+
'border border-gray-200 dark:border-gray-700/50',
|
159 |
+
'text-gray-900 dark:text-white',
|
160 |
+
'placeholder-gray-400 dark:placeholder-gray-500',
|
161 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500/50',
|
162 |
+
'transition-all duration-300 ease-out',
|
163 |
+
'resize-none',
|
164 |
+
'h-32',
|
165 |
+
)}
|
166 |
+
placeholder="Tell us about yourself"
|
167 |
+
/>
|
168 |
+
</div>
|
169 |
+
</div>
|
170 |
+
</div>
|
171 |
+
</div>
|
172 |
+
</div>
|
173 |
+
);
|
174 |
+
}
|
File without changes
|
@@ -4,7 +4,7 @@ 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 } from 'framer-motion';
|
8 |
import { classNames } from '~/utils/classNames';
|
9 |
import { BsRobot } from 'react-icons/bs';
|
10 |
import type { IconType } from 'react-icons';
|
@@ -12,6 +12,8 @@ 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 |
|
16 |
// Add type for provider names to ensure type safety
|
17 |
type ProviderName = 'Ollama' | 'LMStudio' | 'OpenAILike';
|
@@ -53,12 +55,6 @@ interface OllamaModel {
|
|
53 |
};
|
54 |
}
|
55 |
|
56 |
-
interface OllamaServiceStatus {
|
57 |
-
isRunning: boolean;
|
58 |
-
lastChecked: Date;
|
59 |
-
error?: string;
|
60 |
-
}
|
61 |
-
|
62 |
interface OllamaPullResponse {
|
63 |
status: string;
|
64 |
completed?: number;
|
@@ -75,33 +71,14 @@ const isOllamaPullResponse = (data: unknown): data is OllamaPullResponse => {
|
|
75 |
);
|
76 |
};
|
77 |
|
78 |
-
|
79 |
-
isOpen: boolean;
|
80 |
-
modelString: string;
|
81 |
-
}
|
82 |
-
|
83 |
-
export function LocalProvidersTab() {
|
84 |
-
const { success, error } = useToast();
|
85 |
const { providers, updateProviderSettings } = useSettings();
|
86 |
const [filteredProviders, setFilteredProviders] = useState<IProviderConfig[]>([]);
|
87 |
-
const [categoryEnabled, setCategoryEnabled] = useState
|
88 |
-
const [editingProvider, setEditingProvider] = useState<string | null>(null);
|
89 |
const [ollamaModels, setOllamaModels] = useState<OllamaModel[]>([]);
|
90 |
const [isLoadingModels, setIsLoadingModels] = useState(false);
|
91 |
-
const [
|
92 |
-
|
93 |
-
lastChecked: new Date(),
|
94 |
-
});
|
95 |
-
const [isInstallingModel, setIsInstallingModel] = useState<string | null>(null);
|
96 |
-
const [installProgress, setInstallProgress] = useState<{
|
97 |
-
model: string;
|
98 |
-
progress: number;
|
99 |
-
status: string;
|
100 |
-
} | null>(null);
|
101 |
-
const [manualInstall, setManualInstall] = useState<ManualInstallState>({
|
102 |
-
isOpen: false,
|
103 |
-
modelString: '',
|
104 |
-
});
|
105 |
|
106 |
// Effect to filter and sort providers
|
107 |
useEffect(() => {
|
@@ -166,12 +143,6 @@ export function LocalProvidersTab() {
|
|
166 |
setFilteredProviders(sorted);
|
167 |
}, [providers, updateProviderSettings]);
|
168 |
|
169 |
-
// Helper function to safely get environment URL
|
170 |
-
const getEnvUrl = (provider: IProviderConfig): string | undefined => {
|
171 |
-
const envKey = providerBaseUrlEnvKeys[provider.name]?.baseUrlKey;
|
172 |
-
return envKey ? (import.meta.env[envKey] as string | undefined) : undefined;
|
173 |
-
};
|
174 |
-
|
175 |
// Add effect to update category toggle state based on provider states
|
176 |
useEffect(() => {
|
177 |
const newCategoryState = filteredProviders.every((p) => p.settings.enabled);
|
@@ -207,7 +178,7 @@ export function LocalProvidersTab() {
|
|
207 |
}
|
208 |
};
|
209 |
|
210 |
-
const updateOllamaModel = async (modelName: string): Promise<
|
211 |
try {
|
212 |
const response = await fetch(`${OLLAMA_API_URL}/api/pull`, {
|
213 |
method: 'POST',
|
@@ -265,74 +236,54 @@ export function LocalProvidersTab() {
|
|
265 |
const updatedData = (await updatedResponse.json()) as { models: OllamaModel[] };
|
266 |
const updatedModel = updatedData.models.find((m) => m.name === modelName);
|
267 |
|
268 |
-
return
|
269 |
} catch (error) {
|
270 |
console.error(`Error updating ${modelName}:`, error);
|
271 |
-
return
|
272 |
}
|
273 |
};
|
274 |
|
275 |
const handleToggleCategory = useCallback(
|
276 |
-
(enabled: boolean) => {
|
277 |
-
setCategoryEnabled(enabled);
|
278 |
filteredProviders.forEach((provider) => {
|
279 |
updateProviderSettings(provider.name, { ...provider.settings, enabled });
|
280 |
});
|
281 |
-
|
282 |
},
|
283 |
-
[filteredProviders, updateProviderSettings
|
284 |
);
|
285 |
|
286 |
const handleToggleProvider = (provider: IProviderConfig, enabled: boolean) => {
|
287 |
-
updateProviderSettings(provider.name, {
|
|
|
|
|
|
|
288 |
|
289 |
if (enabled) {
|
290 |
logStore.logProvider(`Provider ${provider.name} enabled`, { provider: provider.name });
|
291 |
-
|
292 |
} else {
|
293 |
logStore.logProvider(`Provider ${provider.name} disabled`, { provider: provider.name });
|
294 |
-
|
295 |
}
|
296 |
};
|
297 |
|
298 |
-
const handleUpdateBaseUrl = (provider: IProviderConfig,
|
299 |
-
|
300 |
-
|
301 |
-
if (newBaseUrl && newBaseUrl.trim().length === 0) {
|
302 |
-
newBaseUrl = undefined;
|
303 |
-
}
|
304 |
-
|
305 |
-
updateProviderSettings(provider.name, { ...provider.settings, baseUrl: newBaseUrl });
|
306 |
-
logStore.logProvider(`Base URL updated for ${provider.name}`, {
|
307 |
-
provider: provider.name,
|
308 |
baseUrl: newBaseUrl,
|
309 |
});
|
310 |
-
|
311 |
setEditingProvider(null);
|
312 |
};
|
313 |
|
314 |
const handleUpdateOllamaModel = async (modelName: string) => {
|
315 |
-
|
316 |
-
|
317 |
-
const { success: updateSuccess, newDigest } = await updateOllamaModel(modelName);
|
318 |
-
|
319 |
-
setOllamaModels((current) =>
|
320 |
-
current.map((m) =>
|
321 |
-
m.name === modelName
|
322 |
-
? {
|
323 |
-
...m,
|
324 |
-
status: updateSuccess ? 'updated' : 'error',
|
325 |
-
error: updateSuccess ? undefined : 'Update failed',
|
326 |
-
newDigest,
|
327 |
-
}
|
328 |
-
: m,
|
329 |
-
),
|
330 |
-
);
|
331 |
|
332 |
if (updateSuccess) {
|
333 |
-
|
334 |
} else {
|
335 |
-
|
336 |
}
|
337 |
};
|
338 |
|
@@ -351,336 +302,194 @@ export function LocalProvidersTab() {
|
|
351 |
}
|
352 |
|
353 |
setOllamaModels((current) => current.filter((m) => m.name !== modelName));
|
354 |
-
|
355 |
} catch (err) {
|
356 |
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
357 |
console.error(`Error deleting ${modelName}:`, errorMessage);
|
358 |
-
|
359 |
-
}
|
360 |
-
};
|
361 |
-
|
362 |
-
// Health check function
|
363 |
-
const checkOllamaHealth = async () => {
|
364 |
-
try {
|
365 |
-
// Use the root endpoint instead of /api/health
|
366 |
-
const response = await fetch(OLLAMA_API_URL);
|
367 |
-
const text = await response.text();
|
368 |
-
const isRunning = text.includes('Ollama is running');
|
369 |
-
|
370 |
-
setServiceStatus({
|
371 |
-
isRunning,
|
372 |
-
lastChecked: new Date(),
|
373 |
-
});
|
374 |
-
|
375 |
-
if (isRunning) {
|
376 |
-
// If Ollama is running, fetch models
|
377 |
-
fetchOllamaModels();
|
378 |
-
}
|
379 |
-
|
380 |
-
return isRunning;
|
381 |
-
} catch (error) {
|
382 |
-
console.error('Health check error:', error);
|
383 |
-
setServiceStatus({
|
384 |
-
isRunning: false,
|
385 |
-
lastChecked: new Date(),
|
386 |
-
error: error instanceof Error ? error.message : 'Failed to connect to Ollama service',
|
387 |
-
});
|
388 |
-
|
389 |
-
return false;
|
390 |
-
}
|
391 |
-
};
|
392 |
-
|
393 |
-
// Update manual installation function
|
394 |
-
const handleManualInstall = async (modelString: string) => {
|
395 |
-
try {
|
396 |
-
setIsInstallingModel(modelString);
|
397 |
-
setInstallProgress({ model: modelString, progress: 0, status: 'Starting download...' });
|
398 |
-
setManualInstall((prev) => ({ ...prev, isOpen: false }));
|
399 |
-
|
400 |
-
const response = await fetch(`${OLLAMA_API_URL}/api/pull`, {
|
401 |
-
method: 'POST',
|
402 |
-
headers: {
|
403 |
-
'Content-Type': 'application/json',
|
404 |
-
},
|
405 |
-
body: JSON.stringify({ name: modelString }),
|
406 |
-
});
|
407 |
-
|
408 |
-
if (!response.ok) {
|
409 |
-
throw new Error(`Failed to install ${modelString}`);
|
410 |
-
}
|
411 |
-
|
412 |
-
const reader = response.body?.getReader();
|
413 |
-
|
414 |
-
if (!reader) {
|
415 |
-
throw new Error('No response reader available');
|
416 |
-
}
|
417 |
-
|
418 |
-
while (true) {
|
419 |
-
const { done, value } = await reader.read();
|
420 |
-
|
421 |
-
if (done) {
|
422 |
-
break;
|
423 |
-
}
|
424 |
-
|
425 |
-
const text = new TextDecoder().decode(value);
|
426 |
-
const lines = text.split('\n').filter(Boolean);
|
427 |
-
|
428 |
-
for (const line of lines) {
|
429 |
-
const rawData = JSON.parse(line);
|
430 |
-
|
431 |
-
if (!isOllamaPullResponse(rawData)) {
|
432 |
-
console.error('Invalid response format:', rawData);
|
433 |
-
continue;
|
434 |
-
}
|
435 |
-
|
436 |
-
setInstallProgress({
|
437 |
-
model: modelString,
|
438 |
-
progress: rawData.completed && rawData.total ? (rawData.completed / rawData.total) * 100 : 0,
|
439 |
-
status: rawData.status,
|
440 |
-
});
|
441 |
-
}
|
442 |
-
}
|
443 |
-
|
444 |
-
success(`Successfully installed ${modelString}`);
|
445 |
-
await fetchOllamaModels();
|
446 |
-
} catch (err) {
|
447 |
-
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
448 |
-
console.error(`Error installing ${modelString}:`, errorMessage);
|
449 |
-
error(`Failed to install ${modelString}`);
|
450 |
-
} finally {
|
451 |
-
setIsInstallingModel(null);
|
452 |
-
setInstallProgress(null);
|
453 |
}
|
454 |
};
|
455 |
|
456 |
-
//
|
457 |
-
|
458 |
-
|
459 |
-
|
460 |
-
|
461 |
-
|
462 |
-
|
463 |
-
|
464 |
-
|
465 |
-
|
466 |
-
|
467 |
-
|
468 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
469 |
|
470 |
-
|
471 |
-
|
472 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
473 |
|
474 |
return (
|
475 |
<div
|
476 |
className={classNames(
|
477 |
-
'rounded-lg
|
478 |
'hover:bg-bolt-elements-background-depth-2',
|
479 |
'transition-all duration-200',
|
480 |
)}
|
|
|
|
|
481 |
>
|
482 |
-
{/* Service Status Indicator - Move to top */}
|
483 |
-
<div
|
484 |
-
className={classNames(
|
485 |
-
'flex items-center gap-2 p-2 rounded-lg',
|
486 |
-
serviceStatus.isRunning ? 'bg-green-500/10 text-green-500' : 'bg-red-500/10 text-red-500',
|
487 |
-
)}
|
488 |
-
>
|
489 |
-
<div className={classNames('w-2 h-2 rounded-full', serviceStatus.isRunning ? 'bg-green-500' : 'bg-red-500')} />
|
490 |
-
<span className="text-sm">
|
491 |
-
{serviceStatus.isRunning ? 'Ollama service is running' : 'Ollama service is not running'}
|
492 |
-
</span>
|
493 |
-
<span className="text-xs text-bolt-elements-textSecondary ml-2">
|
494 |
-
Last checked: {serviceStatus.lastChecked.toLocaleTimeString()}
|
495 |
-
</span>
|
496 |
-
</div>
|
497 |
-
|
498 |
<motion.div
|
499 |
-
className="space-y-
|
500 |
initial={{ opacity: 0, y: 20 }}
|
501 |
animate={{ opacity: 1, y: 0 }}
|
502 |
transition={{ duration: 0.3 }}
|
503 |
>
|
504 |
-
|
505 |
-
|
506 |
-
|
|
|
507 |
className={classNames(
|
508 |
-
'w-
|
509 |
-
'bg-
|
510 |
-
'text-purple-500',
|
511 |
)}
|
|
|
512 |
>
|
513 |
-
<BiChip className="w-
|
514 |
-
</div>
|
515 |
<div>
|
516 |
-
<
|
517 |
-
<p className="text-sm text-bolt-elements-textSecondary">
|
518 |
-
Configure and update local AI models on your machine
|
519 |
-
</p>
|
520 |
</div>
|
521 |
</div>
|
522 |
|
523 |
<div className="flex items-center gap-2">
|
524 |
-
<span className="text-sm text-bolt-elements-textSecondary">Enable All
|
525 |
-
<Switch
|
|
|
|
|
|
|
|
|
526 |
</div>
|
527 |
</div>
|
528 |
|
529 |
-
|
530 |
-
|
|
|
|
|
531 |
<motion.div
|
532 |
key={provider.name}
|
533 |
className={classNames(
|
534 |
-
'bg-bolt-elements-background-depth-2',
|
535 |
'hover:bg-bolt-elements-background-depth-3',
|
536 |
-
'transition-all duration-200',
|
537 |
'relative overflow-hidden group',
|
538 |
-
'flex flex-col',
|
539 |
-
|
540 |
-
// Make Ollama span 2 rows
|
541 |
-
provider.name === 'Ollama' ? 'row-span-2' : '',
|
542 |
-
|
543 |
-
// Place Ollama in the second column
|
544 |
-
provider.name === 'Ollama' ? 'col-start-2' : 'col-start-1',
|
545 |
)}
|
546 |
initial={{ opacity: 0, y: 20 }}
|
547 |
animate={{ opacity: 1, y: 0 }}
|
548 |
-
|
549 |
-
whileHover={{ scale: 1.02 }}
|
550 |
>
|
551 |
-
|
552 |
-
|
553 |
-
|
554 |
-
|
555 |
-
|
556 |
-
|
557 |
-
|
558 |
-
|
559 |
-
|
560 |
-
|
561 |
-
className="px-2 py-0.5 text-xs rounded-full bg-purple-500/10 text-purple-500 font-medium"
|
562 |
-
whileHover={{ scale: 1.05 }}
|
563 |
-
whileTap={{ scale: 0.95 }}
|
564 |
>
|
565 |
-
Configurable
|
566 |
-
</motion.span>
|
567 |
-
)}
|
568 |
-
</div>
|
569 |
-
|
570 |
-
<div className="flex items-start gap-4 p-4">
|
571 |
-
<motion.div
|
572 |
-
className={classNames(
|
573 |
-
'w-10 h-10 flex items-center justify-center rounded-xl',
|
574 |
-
'bg-bolt-elements-background-depth-3 group-hover:bg-bolt-elements-background-depth-4',
|
575 |
-
'transition-all duration-200',
|
576 |
-
provider.settings.enabled ? 'text-purple-500' : 'text-bolt-elements-textSecondary',
|
577 |
-
)}
|
578 |
-
whileHover={{ scale: 1.1 }}
|
579 |
-
whileTap={{ scale: 0.9 }}
|
580 |
-
>
|
581 |
-
<div className={classNames('w-6 h-6', 'transition-transform duration-200', 'group-hover:rotate-12')}>
|
582 |
{React.createElement(PROVIDER_ICONS[provider.name as ProviderName] || BsRobot, {
|
583 |
-
className: 'w-
|
584 |
-
'aria-label': `${provider.name}
|
585 |
})}
|
586 |
-
</div>
|
587 |
-
|
588 |
-
|
589 |
-
|
590 |
-
|
591 |
-
<div>
|
592 |
-
<h4 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-purple-500 transition-colors">
|
593 |
-
{provider.name}
|
594 |
-
</h4>
|
595 |
-
<p className="text-xs text-bolt-elements-textSecondary mt-0.5">
|
596 |
-
{PROVIDER_DESCRIPTIONS[provider.name as ProviderName]}
|
597 |
-
</p>
|
598 |
</div>
|
599 |
-
<
|
600 |
-
|
601 |
-
|
602 |
-
/>
|
603 |
</div>
|
604 |
-
|
605 |
-
{provider.settings.enabled && URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
|
606 |
-
<motion.div
|
607 |
-
initial={{ opacity: 0, height: 0 }}
|
608 |
-
animate={{ opacity: 1, height: 'auto' }}
|
609 |
-
exit={{ opacity: 0, height: 0 }}
|
610 |
-
transition={{ duration: 0.2 }}
|
611 |
-
>
|
612 |
-
<div className="flex items-center gap-2 mt-4">
|
613 |
-
{editingProvider === provider.name ? (
|
614 |
-
<input
|
615 |
-
type="text"
|
616 |
-
defaultValue={provider.settings.baseUrl}
|
617 |
-
placeholder={`Enter ${provider.name} base URL`}
|
618 |
-
className={classNames(
|
619 |
-
'flex-1 px-3 py-1.5 rounded-lg text-sm',
|
620 |
-
'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
|
621 |
-
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
622 |
-
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
|
623 |
-
'transition-all duration-200',
|
624 |
-
)}
|
625 |
-
onKeyDown={(e) => {
|
626 |
-
if (e.key === 'Enter') {
|
627 |
-
handleUpdateBaseUrl(provider, e.currentTarget.value);
|
628 |
-
} else if (e.key === 'Escape') {
|
629 |
-
setEditingProvider(null);
|
630 |
-
}
|
631 |
-
}}
|
632 |
-
onBlur={(e) => handleUpdateBaseUrl(provider, e.target.value)}
|
633 |
-
autoFocus
|
634 |
-
/>
|
635 |
-
) : (
|
636 |
-
<div
|
637 |
-
className="flex-1 px-3 py-1.5 rounded-lg text-sm cursor-pointer group/url"
|
638 |
-
onClick={() => setEditingProvider(provider.name)}
|
639 |
-
>
|
640 |
-
<div className="flex items-center gap-2 text-bolt-elements-textSecondary">
|
641 |
-
<div className="i-ph:link text-sm" />
|
642 |
-
<span className="group-hover/url:text-purple-500 transition-colors">
|
643 |
-
{provider.settings.baseUrl || 'Click to set base URL'}
|
644 |
-
</span>
|
645 |
-
</div>
|
646 |
-
</div>
|
647 |
-
)}
|
648 |
-
|
649 |
-
{providerBaseUrlEnvKeys[provider.name]?.baseUrlKey && (
|
650 |
-
<div className="mt-2 text-xs">
|
651 |
-
<div className="flex items-center gap-1">
|
652 |
-
<div
|
653 |
-
className={
|
654 |
-
getEnvUrl(provider)
|
655 |
-
? 'i-ph:check-circle text-green-500'
|
656 |
-
: 'i-ph:warning-circle text-yellow-500'
|
657 |
-
}
|
658 |
-
/>
|
659 |
-
<span className={getEnvUrl(provider) ? 'text-green-500' : 'text-yellow-500'}>
|
660 |
-
{getEnvUrl(provider)
|
661 |
-
? 'Environment URL set in .env.local'
|
662 |
-
: 'Environment URL not set in .env.local'}
|
663 |
-
</span>
|
664 |
-
</div>
|
665 |
-
</div>
|
666 |
-
)}
|
667 |
-
</div>
|
668 |
-
</motion.div>
|
669 |
-
)}
|
670 |
</div>
|
|
|
|
|
|
|
|
|
|
|
671 |
</div>
|
672 |
|
673 |
-
{
|
674 |
-
|
|
|
675 |
<div className="flex items-center justify-between">
|
676 |
<div className="flex items-center gap-2">
|
677 |
<div className="i-ph:cube-duotone text-purple-500" />
|
678 |
-
<
|
679 |
</div>
|
680 |
{isLoadingModels ? (
|
681 |
-
<div className="flex items-center gap-2
|
682 |
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
683 |
-
Loading models
|
684 |
</div>
|
685 |
) : (
|
686 |
<span className="text-sm text-bolt-elements-textSecondary">
|
@@ -689,226 +498,221 @@ export function LocalProvidersTab() {
|
|
689 |
)}
|
690 |
</div>
|
691 |
|
692 |
-
<div className="space-y-
|
693 |
-
{
|
694 |
-
<div
|
695 |
-
|
696 |
-
|
697 |
-
|
698 |
-
|
699 |
-
|
700 |
-
|
701 |
-
|
702 |
-
|
703 |
-
|
704 |
-
|
705 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
706 |
</div>
|
707 |
-
|
708 |
-
<
|
709 |
-
|
710 |
-
|
711 |
-
|
712 |
-
|
713 |
-
|
714 |
-
|
715 |
-
|
716 |
-
|
717 |
-
|
718 |
-
|
719 |
-
|
720 |
-
|
721 |
-
|
722 |
-
|
723 |
-
|
724 |
-
|
725 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
726 |
</span>
|
727 |
)}
|
728 |
</div>
|
729 |
</div>
|
730 |
-
<
|
731 |
-
|
732 |
-
|
733 |
-
disabled={model.status === 'updating'}
|
734 |
-
className={classNames(
|
735 |
-
'rounded-md px-4 py-2 text-sm',
|
736 |
-
'bg-purple-500 text-white',
|
737 |
-
'hover:bg-purple-600',
|
738 |
-
'dark:bg-purple-500 dark:hover:bg-purple-600',
|
739 |
-
'transition-all duration-200',
|
740 |
-
)}
|
741 |
-
whileHover={{ scale: 1.02 }}
|
742 |
-
whileTap={{ scale: 0.98 }}
|
743 |
-
>
|
744 |
-
<div className="i-ph:arrows-clockwise" />
|
745 |
-
Update
|
746 |
-
</motion.button>
|
747 |
-
<motion.button
|
748 |
-
onClick={() => {
|
749 |
-
if (window.confirm(`Are you sure you want to delete ${model.name}?`)) {
|
750 |
-
handleDeleteOllamaModel(model.name);
|
751 |
-
}
|
752 |
-
}}
|
753 |
-
disabled={model.status === 'updating'}
|
754 |
-
className={classNames(
|
755 |
-
'rounded-md px-4 py-2 text-sm',
|
756 |
-
'bg-red-500 text-white',
|
757 |
-
'hover:bg-red-600',
|
758 |
-
'dark:bg-red-500 dark:hover:bg-red-600',
|
759 |
-
'transition-all duration-200',
|
760 |
-
)}
|
761 |
-
whileHover={{ scale: 1.02 }}
|
762 |
-
whileTap={{ scale: 0.98 }}
|
763 |
-
>
|
764 |
-
<div className="i-ph:trash" />
|
765 |
-
Delete
|
766 |
-
</motion.button>
|
767 |
-
</div>
|
768 |
</div>
|
769 |
-
|
|
|
|
|
|
|
|
|
|
|
770 |
</div>
|
771 |
-
</div>
|
772 |
-
)}
|
773 |
|
774 |
-
|
775 |
-
|
776 |
-
|
777 |
-
|
778 |
-
|
779 |
-
|
780 |
-
|
781 |
-
|
782 |
-
|
783 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
784 |
</div>
|
785 |
</motion.div>
|
|
|
|
|
|
|
786 |
|
787 |
-
|
788 |
-
|
789 |
-
|
790 |
-
|
791 |
-
|
792 |
-
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">Install New Model</h3>
|
793 |
-
<p className="text-sm text-bolt-elements-textSecondary">
|
794 |
-
Enter the model name exactly as shown (e.g., deepseek-r1:1.5b)
|
795 |
-
</p>
|
796 |
-
</div>
|
797 |
-
</div>
|
798 |
|
799 |
-
|
800 |
-
|
801 |
-
|
802 |
-
|
803 |
-
|
804 |
-
</div>
|
805 |
-
<div className="space-y-2 text-sm text-bolt-elements-textSecondary">
|
806 |
-
<p>
|
807 |
-
Browse available models at{' '}
|
808 |
-
<a
|
809 |
-
href="https://ollama.com/library"
|
810 |
-
target="_blank"
|
811 |
-
rel="noopener noreferrer"
|
812 |
-
className="text-purple-500 hover:underline"
|
813 |
-
>
|
814 |
-
ollama.com/library
|
815 |
-
</a>
|
816 |
-
</p>
|
817 |
-
<div className="space-y-1">
|
818 |
-
<p className="font-medium text-bolt-elements-textPrimary">Popular models:</p>
|
819 |
-
<ul className="list-disc list-inside space-y-1 ml-2">
|
820 |
-
<li>deepseek-r1:1.5b - DeepSeek's reasoning model</li>
|
821 |
-
<li>llama3:8b - Meta's Llama 3 (8B parameters)</li>
|
822 |
-
<li>mistral:7b - Mistral's 7B model</li>
|
823 |
-
<li>gemma:2b - Google's Gemma model</li>
|
824 |
-
<li>qwen2:7b - Alibaba's Qwen2 model</li>
|
825 |
-
</ul>
|
826 |
-
</div>
|
827 |
-
<p className="mt-2">
|
828 |
-
<span className="text-yellow-500">Note:</span> Copy the exact model name including the tag (e.g.,
|
829 |
-
'deepseek-r1:1.5b') from the library to ensure successful installation.
|
830 |
-
</p>
|
831 |
-
</div>
|
832 |
-
</div>
|
833 |
|
834 |
-
|
835 |
-
<div className="flex-1">
|
836 |
-
<input
|
837 |
-
type="text"
|
838 |
-
className="w-full px-3 py-2 rounded-md bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor text-bolt-elements-textPrimary"
|
839 |
-
placeholder="deepseek-r1:1.5b"
|
840 |
-
value={manualInstall.modelString}
|
841 |
-
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
842 |
-
setManualInstall((prev) => ({ ...prev, modelString: e.target.value }))
|
843 |
-
}
|
844 |
-
/>
|
845 |
-
</div>
|
846 |
-
<motion.button
|
847 |
-
onClick={() => handleManualInstall(manualInstall.modelString)}
|
848 |
-
disabled={!manualInstall.modelString || !!isInstallingModel}
|
849 |
-
className={classNames(
|
850 |
-
'rounded-md px-4 py-2 text-sm',
|
851 |
-
'bg-purple-500 text-white',
|
852 |
-
'hover:bg-purple-600',
|
853 |
-
'dark:bg-purple-500 dark:hover:bg-purple-600',
|
854 |
-
'transition-all duration-200',
|
855 |
-
)}
|
856 |
-
whileHover={{ scale: 1.02 }}
|
857 |
-
whileTap={{ scale: 0.98 }}
|
858 |
-
>
|
859 |
-
{isInstallingModel ? (
|
860 |
-
<div className="flex items-center justify-center gap-2">
|
861 |
-
<div className="i-ph:spinner-gap-bold animate-spin" />
|
862 |
-
Installing...
|
863 |
-
</div>
|
864 |
-
) : (
|
865 |
-
<>
|
866 |
-
<div className="i-ph:download" />
|
867 |
-
Install Model
|
868 |
-
</>
|
869 |
-
)}
|
870 |
-
</motion.button>
|
871 |
-
{isInstallingModel && (
|
872 |
-
<motion.button
|
873 |
-
onClick={() => {
|
874 |
-
setIsInstallingModel(null);
|
875 |
-
setInstallProgress(null);
|
876 |
-
error('Installation cancelled');
|
877 |
-
}}
|
878 |
-
className={classNames(
|
879 |
-
'rounded-md px-4 py-2 text-sm',
|
880 |
-
'bg-red-500 text-white',
|
881 |
-
'hover:bg-red-600',
|
882 |
-
'dark:bg-red-500 dark:hover:bg-red-600',
|
883 |
-
'transition-all duration-200',
|
884 |
-
)}
|
885 |
-
whileHover={{ scale: 1.02 }}
|
886 |
-
whileTap={{ scale: 0.98 }}
|
887 |
-
>
|
888 |
-
<div className="i-ph:x" />
|
889 |
-
Cancel
|
890 |
-
</motion.button>
|
891 |
-
)}
|
892 |
-
</div>
|
893 |
|
894 |
-
|
895 |
-
|
896 |
-
|
897 |
-
|
898 |
-
|
899 |
-
|
900 |
-
|
901 |
-
|
902 |
-
className="h-full bg-purple-500 transition-all duration-200"
|
903 |
-
style={{ width: `${installProgress.progress}%` }}
|
904 |
-
/>
|
905 |
-
</div>
|
906 |
-
</div>
|
907 |
-
)}
|
908 |
-
</div>
|
909 |
-
)}
|
910 |
-
</div>
|
911 |
);
|
912 |
}
|
913 |
-
|
914 |
-
export default LocalProvidersTab;
|
|
|
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';
|
|
|
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';
|
|
|
55 |
};
|
56 |
}
|
57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
58 |
interface OllamaPullResponse {
|
59 |
status: string;
|
60 |
completed?: number;
|
|
|
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(() => {
|
|
|
143 |
setFilteredProviders(sorted);
|
144 |
}, [providers, updateProviderSettings]);
|
145 |
|
|
|
|
|
|
|
|
|
|
|
|
|
146 |
// Add effect to update category toggle state based on provider states
|
147 |
useEffect(() => {
|
148 |
const newCategoryState = filteredProviders.every((p) => p.settings.enabled);
|
|
|
178 |
}
|
179 |
};
|
180 |
|
181 |
+
const updateOllamaModel = async (modelName: string): Promise<boolean> => {
|
182 |
try {
|
183 |
const response = await fetch(`${OLLAMA_API_URL}/api/pull`, {
|
184 |
method: 'POST',
|
|
|
236 |
const updatedData = (await updatedResponse.json()) as { models: OllamaModel[] };
|
237 |
const updatedModel = updatedData.models.find((m) => m.name === modelName);
|
238 |
|
239 |
+
return updatedModel !== undefined;
|
240 |
} catch (error) {
|
241 |
console.error(`Error updating ${modelName}:`, error);
|
242 |
+
return false;
|
243 |
}
|
244 |
};
|
245 |
|
246 |
const handleToggleCategory = useCallback(
|
247 |
+
async (enabled: boolean) => {
|
|
|
248 |
filteredProviders.forEach((provider) => {
|
249 |
updateProviderSettings(provider.name, { ...provider.settings, enabled });
|
250 |
});
|
251 |
+
toast(enabled ? 'All local providers enabled' : 'All local providers disabled');
|
252 |
},
|
253 |
+
[filteredProviders, updateProviderSettings],
|
254 |
);
|
255 |
|
256 |
const handleToggleProvider = (provider: IProviderConfig, enabled: boolean) => {
|
257 |
+
updateProviderSettings(provider.name, {
|
258 |
+
...provider.settings,
|
259 |
+
enabled,
|
260 |
+
});
|
261 |
|
262 |
if (enabled) {
|
263 |
logStore.logProvider(`Provider ${provider.name} enabled`, { provider: provider.name });
|
264 |
+
toast(`${provider.name} enabled`);
|
265 |
} else {
|
266 |
logStore.logProvider(`Provider ${provider.name} disabled`, { provider: provider.name });
|
267 |
+
toast(`${provider.name} disabled`);
|
268 |
}
|
269 |
};
|
270 |
|
271 |
+
const handleUpdateBaseUrl = (provider: IProviderConfig, newBaseUrl: string) => {
|
272 |
+
updateProviderSettings(provider.name, {
|
273 |
+
...provider.settings,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
274 |
baseUrl: newBaseUrl,
|
275 |
});
|
276 |
+
toast(`${provider.name} base URL updated`);
|
277 |
setEditingProvider(null);
|
278 |
};
|
279 |
|
280 |
const handleUpdateOllamaModel = async (modelName: string) => {
|
281 |
+
const updateSuccess = await updateOllamaModel(modelName);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
282 |
|
283 |
if (updateSuccess) {
|
284 |
+
toast(`Updated ${modelName}`);
|
285 |
} else {
|
286 |
+
toast(`Failed to update ${modelName}`);
|
287 |
}
|
288 |
};
|
289 |
|
|
|
302 |
}
|
303 |
|
304 |
setOllamaModels((current) => current.filter((m) => m.name !== modelName));
|
305 |
+
toast(`Deleted ${modelName}`);
|
306 |
} catch (err) {
|
307 |
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
308 |
console.error(`Error deleting ${modelName}:`, errorMessage);
|
309 |
+
toast(`Failed to delete ${modelName}`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
310 |
}
|
311 |
};
|
312 |
|
313 |
+
// Update model details display
|
314 |
+
const ModelDetails = ({ model }: { model: OllamaModel }) => (
|
315 |
+
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
|
316 |
+
<div className="flex items-center gap-1">
|
317 |
+
<div className="i-ph:code text-purple-500" />
|
318 |
+
<span>{model.digest.substring(0, 7)}</span>
|
319 |
+
</div>
|
320 |
+
{model.details && (
|
321 |
+
<>
|
322 |
+
<div className="flex items-center gap-1">
|
323 |
+
<div className="i-ph:database text-purple-500" />
|
324 |
+
<span>{model.details.parameter_size}</span>
|
325 |
+
</div>
|
326 |
+
<div className="flex items-center gap-1">
|
327 |
+
<div className="i-ph:cube text-purple-500" />
|
328 |
+
<span>{model.details.quantization_level}</span>
|
329 |
+
</div>
|
330 |
+
</>
|
331 |
+
)}
|
332 |
+
</div>
|
333 |
+
);
|
334 |
|
335 |
+
// Update model actions to not use Tooltip
|
336 |
+
const ModelActions = ({
|
337 |
+
model,
|
338 |
+
onUpdate,
|
339 |
+
onDelete,
|
340 |
+
}: {
|
341 |
+
model: OllamaModel;
|
342 |
+
onUpdate: () => void;
|
343 |
+
onDelete: () => void;
|
344 |
+
}) => (
|
345 |
+
<div className="flex items-center gap-2">
|
346 |
+
<motion.button
|
347 |
+
onClick={onUpdate}
|
348 |
+
disabled={model.status === 'updating'}
|
349 |
+
className={classNames(
|
350 |
+
'rounded-lg p-2',
|
351 |
+
'bg-purple-500/10 text-purple-500',
|
352 |
+
'hover:bg-purple-500/20',
|
353 |
+
'transition-all duration-200',
|
354 |
+
{ 'opacity-50 cursor-not-allowed': model.status === 'updating' },
|
355 |
+
)}
|
356 |
+
whileHover={{ scale: 1.05 }}
|
357 |
+
whileTap={{ scale: 0.95 }}
|
358 |
+
title="Update model"
|
359 |
+
>
|
360 |
+
{model.status === 'updating' ? (
|
361 |
+
<div className="flex items-center gap-2">
|
362 |
+
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
363 |
+
<span className="text-sm">Updating...</span>
|
364 |
+
</div>
|
365 |
+
) : (
|
366 |
+
<div className="i-ph:arrows-clockwise text-lg" />
|
367 |
+
)}
|
368 |
+
</motion.button>
|
369 |
+
<motion.button
|
370 |
+
onClick={onDelete}
|
371 |
+
disabled={model.status === 'updating'}
|
372 |
+
className={classNames(
|
373 |
+
'rounded-lg p-2',
|
374 |
+
'bg-red-500/10 text-red-500',
|
375 |
+
'hover:bg-red-500/20',
|
376 |
+
'transition-all duration-200',
|
377 |
+
{ 'opacity-50 cursor-not-allowed': model.status === 'updating' },
|
378 |
+
)}
|
379 |
+
whileHover={{ scale: 1.05 }}
|
380 |
+
whileTap={{ scale: 0.95 }}
|
381 |
+
title="Delete model"
|
382 |
+
>
|
383 |
+
<div className="i-ph:trash text-lg" />
|
384 |
+
</motion.button>
|
385 |
+
</div>
|
386 |
+
);
|
387 |
|
388 |
return (
|
389 |
<div
|
390 |
className={classNames(
|
391 |
+
'rounded-lg bg-bolt-elements-background text-bolt-elements-textPrimary shadow-sm p-4',
|
392 |
'hover:bg-bolt-elements-background-depth-2',
|
393 |
'transition-all duration-200',
|
394 |
)}
|
395 |
+
role="region"
|
396 |
+
aria-label="Local Providers Configuration"
|
397 |
>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
398 |
<motion.div
|
399 |
+
className="space-y-6"
|
400 |
initial={{ opacity: 0, y: 20 }}
|
401 |
animate={{ opacity: 1, y: 0 }}
|
402 |
transition={{ duration: 0.3 }}
|
403 |
>
|
404 |
+
{/* Header section */}
|
405 |
+
<div className="flex items-center justify-between gap-4 border-b border-bolt-elements-borderColor pb-4">
|
406 |
+
<div className="flex items-center gap-3">
|
407 |
+
<motion.div
|
408 |
className={classNames(
|
409 |
+
'w-10 h-10 flex items-center justify-center rounded-xl',
|
410 |
+
'bg-purple-500/10 text-purple-500',
|
|
|
411 |
)}
|
412 |
+
whileHover={{ scale: 1.05 }}
|
413 |
>
|
414 |
+
<BiChip className="w-6 h-6" />
|
415 |
+
</motion.div>
|
416 |
<div>
|
417 |
+
<h2 className="text-lg font-semibold text-bolt-elements-textPrimary">Local AI Models</h2>
|
418 |
+
<p className="text-sm text-bolt-elements-textSecondary">Configure and manage your local AI providers</p>
|
|
|
|
|
419 |
</div>
|
420 |
</div>
|
421 |
|
422 |
<div className="flex items-center gap-2">
|
423 |
+
<span className="text-sm text-bolt-elements-textSecondary">Enable All</span>
|
424 |
+
<Switch
|
425 |
+
checked={categoryEnabled}
|
426 |
+
onCheckedChange={handleToggleCategory}
|
427 |
+
aria-label="Toggle all local providers"
|
428 |
+
/>
|
429 |
</div>
|
430 |
</div>
|
431 |
|
432 |
+
{/* Ollama Section */}
|
433 |
+
{filteredProviders
|
434 |
+
.filter((provider) => provider.name === 'Ollama')
|
435 |
+
.map((provider) => (
|
436 |
<motion.div
|
437 |
key={provider.name}
|
438 |
className={classNames(
|
439 |
+
'bg-bolt-elements-background-depth-2 rounded-xl',
|
440 |
'hover:bg-bolt-elements-background-depth-3',
|
441 |
+
'transition-all duration-200 p-5',
|
442 |
'relative overflow-hidden group',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
443 |
)}
|
444 |
initial={{ opacity: 0, y: 20 }}
|
445 |
animate={{ opacity: 1, y: 0 }}
|
446 |
+
whileHover={{ scale: 1.01 }}
|
|
|
447 |
>
|
448 |
+
{/* Provider Header */}
|
449 |
+
<div className="flex items-start justify-between gap-4">
|
450 |
+
<div className="flex items-start gap-4">
|
451 |
+
<motion.div
|
452 |
+
className={classNames(
|
453 |
+
'w-12 h-12 flex items-center justify-center rounded-xl',
|
454 |
+
'bg-bolt-elements-background-depth-3',
|
455 |
+
provider.settings.enabled ? 'text-purple-500' : 'text-bolt-elements-textSecondary',
|
456 |
+
)}
|
457 |
+
whileHover={{ scale: 1.1, rotate: 5 }}
|
|
|
|
|
|
|
458 |
>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
459 |
{React.createElement(PROVIDER_ICONS[provider.name as ProviderName] || BsRobot, {
|
460 |
+
className: 'w-7 h-7',
|
461 |
+
'aria-label': `${provider.name} icon`,
|
462 |
})}
|
463 |
+
</motion.div>
|
464 |
+
<div>
|
465 |
+
<div className="flex items-center gap-2">
|
466 |
+
<h3 className="text-md font-semibold text-bolt-elements-textPrimary">{provider.name}</h3>
|
467 |
+
<span className="px-2 py-0.5 text-xs rounded-full bg-green-500/10 text-green-500">Local</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
468 |
</div>
|
469 |
+
<p className="text-sm text-bolt-elements-textSecondary mt-1">
|
470 |
+
{PROVIDER_DESCRIPTIONS[provider.name as ProviderName]}
|
471 |
+
</p>
|
|
|
472 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
473 |
</div>
|
474 |
+
<Switch
|
475 |
+
checked={provider.settings.enabled}
|
476 |
+
onCheckedChange={(checked) => handleToggleProvider(provider, checked)}
|
477 |
+
aria-label={`Toggle ${provider.name} provider`}
|
478 |
+
/>
|
479 |
</div>
|
480 |
|
481 |
+
{/* Ollama Models Section */}
|
482 |
+
{provider.settings.enabled && (
|
483 |
+
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="mt-6 space-y-4">
|
484 |
<div className="flex items-center justify-between">
|
485 |
<div className="flex items-center gap-2">
|
486 |
<div className="i-ph:cube-duotone text-purple-500" />
|
487 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">Installed Models</h4>
|
488 |
</div>
|
489 |
{isLoadingModels ? (
|
490 |
+
<div className="flex items-center gap-2">
|
491 |
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
492 |
+
<span className="text-sm text-bolt-elements-textSecondary">Loading models...</span>
|
493 |
</div>
|
494 |
) : (
|
495 |
<span className="text-sm text-bolt-elements-textSecondary">
|
|
|
498 |
)}
|
499 |
</div>
|
500 |
|
501 |
+
<div className="space-y-3">
|
502 |
+
{isLoadingModels ? (
|
503 |
+
<div className="space-y-3">
|
504 |
+
{Array.from({ length: 3 }).map((_, i) => (
|
505 |
+
<div
|
506 |
+
key={i}
|
507 |
+
className="h-20 w-full bg-bolt-elements-background-depth-3 rounded-lg animate-pulse"
|
508 |
+
/>
|
509 |
+
))}
|
510 |
+
</div>
|
511 |
+
) : ollamaModels.length === 0 ? (
|
512 |
+
<div className="text-center py-8 text-bolt-elements-textSecondary">
|
513 |
+
<div className="i-ph:cube-transparent text-4xl mx-auto mb-2" />
|
514 |
+
<p>No models installed yet</p>
|
515 |
+
<p className="text-sm">Install your first model below</p>
|
516 |
+
</div>
|
517 |
+
) : (
|
518 |
+
ollamaModels.map((model) => (
|
519 |
+
<motion.div
|
520 |
+
key={model.name}
|
521 |
+
className={classNames(
|
522 |
+
'p-4 rounded-xl',
|
523 |
+
'bg-bolt-elements-background-depth-3',
|
524 |
+
'hover:bg-bolt-elements-background-depth-4',
|
525 |
+
'transition-all duration-200',
|
526 |
+
)}
|
527 |
+
whileHover={{ scale: 1.01 }}
|
528 |
+
>
|
529 |
+
<div className="flex items-center justify-between">
|
530 |
+
<div className="space-y-2">
|
531 |
+
<div className="flex items-center gap-2">
|
532 |
+
<h5 className="text-sm font-medium text-bolt-elements-textPrimary">{model.name}</h5>
|
533 |
+
<ModelStatusBadge status={model.status} />
|
534 |
+
</div>
|
535 |
+
<ModelDetails model={model} />
|
536 |
+
</div>
|
537 |
+
<ModelActions
|
538 |
+
model={model}
|
539 |
+
onUpdate={() => handleUpdateOllamaModel(model.name)}
|
540 |
+
onDelete={() => {
|
541 |
+
if (window.confirm(`Are you sure you want to delete ${model.name}?`)) {
|
542 |
+
handleDeleteOllamaModel(model.name);
|
543 |
+
}
|
544 |
+
}}
|
545 |
+
/>
|
546 |
</div>
|
547 |
+
{model.progress && (
|
548 |
+
<div className="mt-3">
|
549 |
+
<Progress
|
550 |
+
value={Math.round((model.progress.current / model.progress.total) * 100)}
|
551 |
+
className="h-1"
|
552 |
+
/>
|
553 |
+
<div className="flex justify-between mt-1 text-xs text-bolt-elements-textSecondary">
|
554 |
+
<span>{model.progress.status}</span>
|
555 |
+
<span>{Math.round((model.progress.current / model.progress.total) * 100)}%</span>
|
556 |
+
</div>
|
557 |
+
</div>
|
558 |
+
)}
|
559 |
+
</motion.div>
|
560 |
+
))
|
561 |
+
)}
|
562 |
+
</div>
|
563 |
+
|
564 |
+
{/* Model Installation Section */}
|
565 |
+
<OllamaModelInstaller onModelInstalled={fetchOllamaModels} />
|
566 |
+
</motion.div>
|
567 |
+
)}
|
568 |
+
</motion.div>
|
569 |
+
))}
|
570 |
+
|
571 |
+
{/* Other Providers Section */}
|
572 |
+
<div className="border-t border-bolt-elements-borderColor pt-6 mt-8">
|
573 |
+
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary mb-4">Other Local Providers</h3>
|
574 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
575 |
+
{filteredProviders
|
576 |
+
.filter((provider) => provider.name !== 'Ollama')
|
577 |
+
.map((provider, index) => (
|
578 |
+
<motion.div
|
579 |
+
key={provider.name}
|
580 |
+
className={classNames(
|
581 |
+
'bg-bolt-elements-background-depth-2 rounded-xl',
|
582 |
+
'hover:bg-bolt-elements-background-depth-3',
|
583 |
+
'transition-all duration-200 p-5',
|
584 |
+
'relative overflow-hidden group',
|
585 |
+
)}
|
586 |
+
initial={{ opacity: 0, y: 20 }}
|
587 |
+
animate={{ opacity: 1, y: 0 }}
|
588 |
+
transition={{ delay: index * 0.1 }}
|
589 |
+
whileHover={{ scale: 1.01 }}
|
590 |
+
>
|
591 |
+
{/* Provider Header */}
|
592 |
+
<div className="flex items-start justify-between gap-4">
|
593 |
+
<div className="flex items-start gap-4">
|
594 |
+
<motion.div
|
595 |
+
className={classNames(
|
596 |
+
'w-12 h-12 flex items-center justify-center rounded-xl',
|
597 |
+
'bg-bolt-elements-background-depth-3',
|
598 |
+
provider.settings.enabled ? 'text-purple-500' : 'text-bolt-elements-textSecondary',
|
599 |
+
)}
|
600 |
+
whileHover={{ scale: 1.1, rotate: 5 }}
|
601 |
+
>
|
602 |
+
{React.createElement(PROVIDER_ICONS[provider.name as ProviderName] || BsRobot, {
|
603 |
+
className: 'w-7 h-7',
|
604 |
+
'aria-label': `${provider.name} icon`,
|
605 |
+
})}
|
606 |
+
</motion.div>
|
607 |
+
<div>
|
608 |
+
<div className="flex items-center gap-2">
|
609 |
+
<h3 className="text-md font-semibold text-bolt-elements-textPrimary">{provider.name}</h3>
|
610 |
+
<div className="flex gap-1">
|
611 |
+
<span className="px-2 py-0.5 text-xs rounded-full bg-green-500/10 text-green-500">
|
612 |
+
Local
|
613 |
+
</span>
|
614 |
+
{URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
|
615 |
+
<span className="px-2 py-0.5 text-xs rounded-full bg-purple-500/10 text-purple-500">
|
616 |
+
Configurable
|
617 |
</span>
|
618 |
)}
|
619 |
</div>
|
620 |
</div>
|
621 |
+
<p className="text-sm text-bolt-elements-textSecondary mt-1">
|
622 |
+
{PROVIDER_DESCRIPTIONS[provider.name as ProviderName]}
|
623 |
+
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
624 |
</div>
|
625 |
+
</div>
|
626 |
+
<Switch
|
627 |
+
checked={provider.settings.enabled}
|
628 |
+
onCheckedChange={(checked) => handleToggleProvider(provider, checked)}
|
629 |
+
aria-label={`Toggle ${provider.name} provider`}
|
630 |
+
/>
|
631 |
</div>
|
|
|
|
|
632 |
|
633 |
+
{/* URL Configuration Section */}
|
634 |
+
<AnimatePresence>
|
635 |
+
{provider.settings.enabled && URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
|
636 |
+
<motion.div
|
637 |
+
initial={{ opacity: 0, height: 0 }}
|
638 |
+
animate={{ opacity: 1, height: 'auto' }}
|
639 |
+
exit={{ opacity: 0, height: 0 }}
|
640 |
+
className="mt-4"
|
641 |
+
>
|
642 |
+
<div className="flex flex-col gap-2">
|
643 |
+
<label className="text-sm text-bolt-elements-textSecondary">API Endpoint</label>
|
644 |
+
{editingProvider === provider.name ? (
|
645 |
+
<input
|
646 |
+
type="text"
|
647 |
+
defaultValue={provider.settings.baseUrl}
|
648 |
+
placeholder={`Enter ${provider.name} base URL`}
|
649 |
+
className={classNames(
|
650 |
+
'w-full px-3 py-2 rounded-lg text-sm',
|
651 |
+
'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
|
652 |
+
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
653 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
|
654 |
+
'transition-all duration-200',
|
655 |
+
)}
|
656 |
+
onKeyDown={(e) => {
|
657 |
+
if (e.key === 'Enter') {
|
658 |
+
handleUpdateBaseUrl(provider, e.currentTarget.value);
|
659 |
+
} else if (e.key === 'Escape') {
|
660 |
+
setEditingProvider(null);
|
661 |
+
}
|
662 |
+
}}
|
663 |
+
onBlur={(e) => handleUpdateBaseUrl(provider, e.target.value)}
|
664 |
+
autoFocus
|
665 |
+
/>
|
666 |
+
) : (
|
667 |
+
<div
|
668 |
+
onClick={() => setEditingProvider(provider.name)}
|
669 |
+
className={classNames(
|
670 |
+
'w-full px-3 py-2 rounded-lg text-sm cursor-pointer',
|
671 |
+
'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
|
672 |
+
'hover:border-purple-500/30 hover:bg-bolt-elements-background-depth-4',
|
673 |
+
'transition-all duration-200',
|
674 |
+
)}
|
675 |
+
>
|
676 |
+
<div className="flex items-center gap-2 text-bolt-elements-textSecondary">
|
677 |
+
<div className="i-ph:link text-sm" />
|
678 |
+
<span>{provider.settings.baseUrl || 'Click to set base URL'}</span>
|
679 |
+
</div>
|
680 |
+
</div>
|
681 |
+
)}
|
682 |
+
</div>
|
683 |
+
</motion.div>
|
684 |
+
)}
|
685 |
+
</AnimatePresence>
|
686 |
+
</motion.div>
|
687 |
+
))}
|
688 |
+
</div>
|
689 |
</div>
|
690 |
</motion.div>
|
691 |
+
</div>
|
692 |
+
);
|
693 |
+
}
|
694 |
|
695 |
+
// Helper component for model status badge
|
696 |
+
function ModelStatusBadge({ status }: { status?: string }) {
|
697 |
+
if (!status || status === 'idle') {
|
698 |
+
return null;
|
699 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
700 |
|
701 |
+
const statusConfig = {
|
702 |
+
updating: { bg: 'bg-yellow-500/10', text: 'text-yellow-500', label: 'Updating' },
|
703 |
+
updated: { bg: 'bg-green-500/10', text: 'text-green-500', label: 'Updated' },
|
704 |
+
error: { bg: 'bg-red-500/10', text: 'text-red-500', label: 'Error' },
|
705 |
+
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
706 |
|
707 |
+
const config = statusConfig[status as keyof typeof statusConfig];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
708 |
|
709 |
+
if (!config) {
|
710 |
+
return null;
|
711 |
+
}
|
712 |
+
|
713 |
+
return (
|
714 |
+
<span className={classNames('px-2 py-0.5 rounded-full text-xs font-medium', config.bg, config.text)}>
|
715 |
+
{config.label}
|
716 |
+
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
717 |
);
|
718 |
}
|
|
|
|
@@ -0,0 +1,597 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
+
|
7 |
+
interface OllamaModelInstallerProps {
|
8 |
+
onModelInstalled: () => void;
|
9 |
+
}
|
10 |
+
|
11 |
+
interface InstallProgress {
|
12 |
+
status: string;
|
13 |
+
progress: number;
|
14 |
+
downloadedSize?: string;
|
15 |
+
totalSize?: string;
|
16 |
+
speed?: string;
|
17 |
+
}
|
18 |
+
|
19 |
+
interface ModelInfo {
|
20 |
+
name: string;
|
21 |
+
desc: string;
|
22 |
+
size: string;
|
23 |
+
tags: string[];
|
24 |
+
installedVersion?: string;
|
25 |
+
latestVersion?: string;
|
26 |
+
needsUpdate?: boolean;
|
27 |
+
status?: 'idle' | 'installing' | 'updating' | 'updated' | 'error';
|
28 |
+
details?: {
|
29 |
+
family: string;
|
30 |
+
parameter_size: string;
|
31 |
+
quantization_level: string;
|
32 |
+
};
|
33 |
+
}
|
34 |
+
|
35 |
+
const POPULAR_MODELS: ModelInfo[] = [
|
36 |
+
{
|
37 |
+
name: 'deepseek-coder:6.7b',
|
38 |
+
desc: "DeepSeek's code generation model",
|
39 |
+
size: '4.1GB',
|
40 |
+
tags: ['coding', 'popular'],
|
41 |
+
},
|
42 |
+
{
|
43 |
+
name: 'llama2:7b',
|
44 |
+
desc: "Meta's Llama 2 (7B parameters)",
|
45 |
+
size: '3.8GB',
|
46 |
+
tags: ['general', 'popular'],
|
47 |
+
},
|
48 |
+
{
|
49 |
+
name: 'mistral:7b',
|
50 |
+
desc: "Mistral's 7B model",
|
51 |
+
size: '4.1GB',
|
52 |
+
tags: ['general', 'popular'],
|
53 |
+
},
|
54 |
+
{
|
55 |
+
name: 'gemma:7b',
|
56 |
+
desc: "Google's Gemma model",
|
57 |
+
size: '4.0GB',
|
58 |
+
tags: ['general', 'new'],
|
59 |
+
},
|
60 |
+
{
|
61 |
+
name: 'codellama:7b',
|
62 |
+
desc: "Meta's Code Llama model",
|
63 |
+
size: '4.1GB',
|
64 |
+
tags: ['coding', 'popular'],
|
65 |
+
},
|
66 |
+
{
|
67 |
+
name: 'neural-chat:7b',
|
68 |
+
desc: "Intel's Neural Chat model",
|
69 |
+
size: '4.1GB',
|
70 |
+
tags: ['chat', 'popular'],
|
71 |
+
},
|
72 |
+
{
|
73 |
+
name: 'phi:latest',
|
74 |
+
desc: "Microsoft's Phi-2 model",
|
75 |
+
size: '2.7GB',
|
76 |
+
tags: ['small', 'fast'],
|
77 |
+
},
|
78 |
+
{
|
79 |
+
name: 'qwen:7b',
|
80 |
+
desc: "Alibaba's Qwen model",
|
81 |
+
size: '4.1GB',
|
82 |
+
tags: ['general'],
|
83 |
+
},
|
84 |
+
{
|
85 |
+
name: 'solar:10.7b',
|
86 |
+
desc: "Upstage's Solar model",
|
87 |
+
size: '6.1GB',
|
88 |
+
tags: ['large', 'powerful'],
|
89 |
+
},
|
90 |
+
{
|
91 |
+
name: 'openchat:7b',
|
92 |
+
desc: 'Open-source chat model',
|
93 |
+
size: '4.1GB',
|
94 |
+
tags: ['chat', 'popular'],
|
95 |
+
},
|
96 |
+
{
|
97 |
+
name: 'dolphin-phi:2.7b',
|
98 |
+
desc: 'Lightweight chat model',
|
99 |
+
size: '1.6GB',
|
100 |
+
tags: ['small', 'fast'],
|
101 |
+
},
|
102 |
+
{
|
103 |
+
name: 'stable-code:3b',
|
104 |
+
desc: 'Lightweight coding model',
|
105 |
+
size: '1.8GB',
|
106 |
+
tags: ['coding', 'small'],
|
107 |
+
},
|
108 |
+
];
|
109 |
+
|
110 |
+
function formatBytes(bytes: number): string {
|
111 |
+
if (bytes === 0) {
|
112 |
+
return '0 B';
|
113 |
+
}
|
114 |
+
|
115 |
+
const k = 1024;
|
116 |
+
const sizes = ['B', 'KB', 'MB', 'GB'];
|
117 |
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
118 |
+
|
119 |
+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
120 |
+
}
|
121 |
+
|
122 |
+
function formatSpeed(bytesPerSecond: number): string {
|
123 |
+
return `${formatBytes(bytesPerSecond)}/s`;
|
124 |
+
}
|
125 |
+
|
126 |
+
// Add Ollama Icon SVG component
|
127 |
+
function OllamaIcon({ className }: { className?: string }) {
|
128 |
+
return (
|
129 |
+
<svg viewBox="0 0 1024 1024" className={className} fill="currentColor">
|
130 |
+
<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" />
|
131 |
+
</svg>
|
132 |
+
);
|
133 |
+
}
|
134 |
+
|
135 |
+
export default function OllamaModelInstaller({ onModelInstalled }: OllamaModelInstallerProps) {
|
136 |
+
const [modelString, setModelString] = useState('');
|
137 |
+
const [searchQuery, setSearchQuery] = useState('');
|
138 |
+
const [isInstalling, setIsInstalling] = useState(false);
|
139 |
+
const [isChecking, setIsChecking] = useState(false);
|
140 |
+
const [installProgress, setInstallProgress] = useState<InstallProgress | null>(null);
|
141 |
+
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
142 |
+
const [models, setModels] = useState<ModelInfo[]>(POPULAR_MODELS);
|
143 |
+
const { toast } = useToast();
|
144 |
+
|
145 |
+
// Function to check installed models and their versions
|
146 |
+
const checkInstalledModels = async () => {
|
147 |
+
try {
|
148 |
+
const response = await fetch('http://127.0.0.1:11434/api/tags', {
|
149 |
+
method: 'GET',
|
150 |
+
});
|
151 |
+
|
152 |
+
if (!response.ok) {
|
153 |
+
throw new Error('Failed to fetch installed models');
|
154 |
+
}
|
155 |
+
|
156 |
+
const data = (await response.json()) as { models: Array<{ name: string; digest: string; latest: string }> };
|
157 |
+
const installedModels = data.models || [];
|
158 |
+
|
159 |
+
// Update models with installed versions
|
160 |
+
setModels((prevModels) =>
|
161 |
+
prevModels.map((model) => {
|
162 |
+
const installed = installedModels.find((m) => m.name.toLowerCase() === model.name.toLowerCase());
|
163 |
+
|
164 |
+
if (installed) {
|
165 |
+
return {
|
166 |
+
...model,
|
167 |
+
installedVersion: installed.digest.substring(0, 8),
|
168 |
+
needsUpdate: installed.digest !== installed.latest,
|
169 |
+
latestVersion: installed.latest?.substring(0, 8),
|
170 |
+
};
|
171 |
+
}
|
172 |
+
|
173 |
+
return model;
|
174 |
+
}),
|
175 |
+
);
|
176 |
+
} catch (error) {
|
177 |
+
console.error('Error checking installed models:', error);
|
178 |
+
}
|
179 |
+
};
|
180 |
+
|
181 |
+
// Check installed models on mount and after installation
|
182 |
+
useEffect(() => {
|
183 |
+
checkInstalledModels();
|
184 |
+
}, []);
|
185 |
+
|
186 |
+
const handleCheckUpdates = async () => {
|
187 |
+
setIsChecking(true);
|
188 |
+
|
189 |
+
try {
|
190 |
+
await checkInstalledModels();
|
191 |
+
toast('Model versions checked');
|
192 |
+
} catch (err) {
|
193 |
+
console.error('Failed to check model versions:', err);
|
194 |
+
toast('Failed to check model versions');
|
195 |
+
} finally {
|
196 |
+
setIsChecking(false);
|
197 |
+
}
|
198 |
+
};
|
199 |
+
|
200 |
+
const filteredModels = models.filter((model) => {
|
201 |
+
const matchesSearch =
|
202 |
+
searchQuery === '' ||
|
203 |
+
model.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
204 |
+
model.desc.toLowerCase().includes(searchQuery.toLowerCase());
|
205 |
+
const matchesTags = selectedTags.length === 0 || selectedTags.some((tag) => model.tags.includes(tag));
|
206 |
+
|
207 |
+
return matchesSearch && matchesTags;
|
208 |
+
});
|
209 |
+
|
210 |
+
const handleInstallModel = async (modelToInstall: string) => {
|
211 |
+
if (!modelToInstall) {
|
212 |
+
return;
|
213 |
+
}
|
214 |
+
|
215 |
+
try {
|
216 |
+
setIsInstalling(true);
|
217 |
+
setInstallProgress({
|
218 |
+
status: 'Starting download...',
|
219 |
+
progress: 0,
|
220 |
+
downloadedSize: '0 B',
|
221 |
+
totalSize: 'Calculating...',
|
222 |
+
speed: '0 B/s',
|
223 |
+
});
|
224 |
+
setModelString('');
|
225 |
+
setSearchQuery('');
|
226 |
+
|
227 |
+
const response = await fetch('http://127.0.0.1:11434/api/pull', {
|
228 |
+
method: 'POST',
|
229 |
+
headers: {
|
230 |
+
'Content-Type': 'application/json',
|
231 |
+
},
|
232 |
+
body: JSON.stringify({ name: modelToInstall }),
|
233 |
+
});
|
234 |
+
|
235 |
+
if (!response.ok) {
|
236 |
+
throw new Error(`HTTP error! status: ${response.status}`);
|
237 |
+
}
|
238 |
+
|
239 |
+
const reader = response.body?.getReader();
|
240 |
+
|
241 |
+
if (!reader) {
|
242 |
+
throw new Error('Failed to get response reader');
|
243 |
+
}
|
244 |
+
|
245 |
+
let lastTime = Date.now();
|
246 |
+
let lastBytes = 0;
|
247 |
+
|
248 |
+
while (true) {
|
249 |
+
const { done, value } = await reader.read();
|
250 |
+
|
251 |
+
if (done) {
|
252 |
+
break;
|
253 |
+
}
|
254 |
+
|
255 |
+
const text = new TextDecoder().decode(value);
|
256 |
+
const lines = text.split('\n').filter(Boolean);
|
257 |
+
|
258 |
+
for (const line of lines) {
|
259 |
+
try {
|
260 |
+
const data = JSON.parse(line);
|
261 |
+
|
262 |
+
if ('status' in data) {
|
263 |
+
const currentTime = Date.now();
|
264 |
+
const timeDiff = (currentTime - lastTime) / 1000; // Convert to seconds
|
265 |
+
const bytesDiff = (data.completed || 0) - lastBytes;
|
266 |
+
const speed = bytesDiff / timeDiff;
|
267 |
+
|
268 |
+
setInstallProgress({
|
269 |
+
status: data.status,
|
270 |
+
progress: data.completed && data.total ? (data.completed / data.total) * 100 : 0,
|
271 |
+
downloadedSize: formatBytes(data.completed || 0),
|
272 |
+
totalSize: data.total ? formatBytes(data.total) : 'Calculating...',
|
273 |
+
speed: formatSpeed(speed),
|
274 |
+
});
|
275 |
+
|
276 |
+
lastTime = currentTime;
|
277 |
+
lastBytes = data.completed || 0;
|
278 |
+
}
|
279 |
+
} catch (err) {
|
280 |
+
console.error('Error parsing progress:', err);
|
281 |
+
}
|
282 |
+
}
|
283 |
+
}
|
284 |
+
|
285 |
+
toast('Successfully installed ' + modelToInstall + '. The model list will refresh automatically.');
|
286 |
+
|
287 |
+
// Ensure we call onModelInstalled after successful installation
|
288 |
+
setTimeout(() => {
|
289 |
+
onModelInstalled();
|
290 |
+
}, 1000);
|
291 |
+
} catch (err) {
|
292 |
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
293 |
+
console.error(`Error installing ${modelToInstall}:`, errorMessage);
|
294 |
+
toast(`Failed to install ${modelToInstall}. ${errorMessage}`);
|
295 |
+
} finally {
|
296 |
+
setIsInstalling(false);
|
297 |
+
setInstallProgress(null);
|
298 |
+
}
|
299 |
+
};
|
300 |
+
|
301 |
+
const handleUpdateModel = async (modelToUpdate: string) => {
|
302 |
+
try {
|
303 |
+
setModels((prev) => prev.map((m) => (m.name === modelToUpdate ? { ...m, status: 'updating' } : m)));
|
304 |
+
|
305 |
+
const response = await fetch('http://127.0.0.1:11434/api/pull', {
|
306 |
+
method: 'POST',
|
307 |
+
headers: {
|
308 |
+
'Content-Type': 'application/json',
|
309 |
+
},
|
310 |
+
body: JSON.stringify({ name: modelToUpdate }),
|
311 |
+
});
|
312 |
+
|
313 |
+
if (!response.ok) {
|
314 |
+
throw new Error(`HTTP error! status: ${response.status}`);
|
315 |
+
}
|
316 |
+
|
317 |
+
const reader = response.body?.getReader();
|
318 |
+
|
319 |
+
if (!reader) {
|
320 |
+
throw new Error('Failed to get response reader');
|
321 |
+
}
|
322 |
+
|
323 |
+
let lastTime = Date.now();
|
324 |
+
let lastBytes = 0;
|
325 |
+
|
326 |
+
while (true) {
|
327 |
+
const { done, value } = await reader.read();
|
328 |
+
|
329 |
+
if (done) {
|
330 |
+
break;
|
331 |
+
}
|
332 |
+
|
333 |
+
const text = new TextDecoder().decode(value);
|
334 |
+
const lines = text.split('\n').filter(Boolean);
|
335 |
+
|
336 |
+
for (const line of lines) {
|
337 |
+
try {
|
338 |
+
const data = JSON.parse(line);
|
339 |
+
|
340 |
+
if ('status' in data) {
|
341 |
+
const currentTime = Date.now();
|
342 |
+
const timeDiff = (currentTime - lastTime) / 1000;
|
343 |
+
const bytesDiff = (data.completed || 0) - lastBytes;
|
344 |
+
const speed = bytesDiff / timeDiff;
|
345 |
+
|
346 |
+
setInstallProgress({
|
347 |
+
status: data.status,
|
348 |
+
progress: data.completed && data.total ? (data.completed / data.total) * 100 : 0,
|
349 |
+
downloadedSize: formatBytes(data.completed || 0),
|
350 |
+
totalSize: data.total ? formatBytes(data.total) : 'Calculating...',
|
351 |
+
speed: formatSpeed(speed),
|
352 |
+
});
|
353 |
+
|
354 |
+
lastTime = currentTime;
|
355 |
+
lastBytes = data.completed || 0;
|
356 |
+
}
|
357 |
+
} catch (err) {
|
358 |
+
console.error('Error parsing progress:', err);
|
359 |
+
}
|
360 |
+
}
|
361 |
+
}
|
362 |
+
|
363 |
+
toast('Successfully updated ' + modelToUpdate);
|
364 |
+
|
365 |
+
// Refresh model list after update
|
366 |
+
await checkInstalledModels();
|
367 |
+
} catch (err) {
|
368 |
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
369 |
+
console.error(`Error updating ${modelToUpdate}:`, errorMessage);
|
370 |
+
toast(`Failed to update ${modelToUpdate}. ${errorMessage}`);
|
371 |
+
setModels((prev) => prev.map((m) => (m.name === modelToUpdate ? { ...m, status: 'error' } : m)));
|
372 |
+
} finally {
|
373 |
+
setInstallProgress(null);
|
374 |
+
}
|
375 |
+
};
|
376 |
+
|
377 |
+
const allTags = Array.from(new Set(POPULAR_MODELS.flatMap((model) => model.tags)));
|
378 |
+
|
379 |
+
return (
|
380 |
+
<div className="space-y-6">
|
381 |
+
<div className="flex items-center justify-between pt-6">
|
382 |
+
<div className="flex items-center gap-3">
|
383 |
+
<OllamaIcon className="w-8 h-8 text-purple-500" />
|
384 |
+
<div>
|
385 |
+
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary">Ollama Models</h3>
|
386 |
+
<p className="text-sm text-bolt-elements-textSecondary mt-1">Install and manage your Ollama models</p>
|
387 |
+
</div>
|
388 |
+
</div>
|
389 |
+
<motion.button
|
390 |
+
onClick={handleCheckUpdates}
|
391 |
+
disabled={isChecking}
|
392 |
+
className={classNames(
|
393 |
+
'px-4 py-2 rounded-lg',
|
394 |
+
'bg-purple-500/10 text-purple-500',
|
395 |
+
'hover:bg-purple-500/20',
|
396 |
+
'transition-all duration-200',
|
397 |
+
'flex items-center gap-2',
|
398 |
+
)}
|
399 |
+
whileHover={{ scale: 1.02 }}
|
400 |
+
whileTap={{ scale: 0.98 }}
|
401 |
+
>
|
402 |
+
{isChecking ? (
|
403 |
+
<div className="i-ph:spinner-gap-bold animate-spin" />
|
404 |
+
) : (
|
405 |
+
<div className="i-ph:arrows-clockwise" />
|
406 |
+
)}
|
407 |
+
Check Updates
|
408 |
+
</motion.button>
|
409 |
+
</div>
|
410 |
+
|
411 |
+
<div className="flex gap-4">
|
412 |
+
<div className="flex-1">
|
413 |
+
<div className="space-y-1">
|
414 |
+
<input
|
415 |
+
type="text"
|
416 |
+
className={classNames(
|
417 |
+
'w-full px-4 py-3 rounded-xl',
|
418 |
+
'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor',
|
419 |
+
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
420 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
|
421 |
+
'transition-all duration-200',
|
422 |
+
)}
|
423 |
+
placeholder="Search models or enter custom model name..."
|
424 |
+
value={searchQuery || modelString}
|
425 |
+
onChange={(e) => {
|
426 |
+
const value = e.target.value;
|
427 |
+
setSearchQuery(value);
|
428 |
+
setModelString(value);
|
429 |
+
}}
|
430 |
+
disabled={isInstalling}
|
431 |
+
/>
|
432 |
+
<p className="text-xs text-bolt-elements-textTertiary px-1">
|
433 |
+
Browse models at{' '}
|
434 |
+
<a
|
435 |
+
href="https://ollama.com/library"
|
436 |
+
target="_blank"
|
437 |
+
rel="noopener noreferrer"
|
438 |
+
className="text-purple-500 hover:underline inline-flex items-center gap-0.5"
|
439 |
+
>
|
440 |
+
ollama.com/library
|
441 |
+
<div className="i-ph:arrow-square-out text-[10px]" />
|
442 |
+
</a>{' '}
|
443 |
+
and copy model names to install
|
444 |
+
</p>
|
445 |
+
</div>
|
446 |
+
</div>
|
447 |
+
<motion.button
|
448 |
+
onClick={() => handleInstallModel(modelString)}
|
449 |
+
disabled={!modelString || isInstalling}
|
450 |
+
className={classNames(
|
451 |
+
'rounded-xl px-6 py-3',
|
452 |
+
'bg-purple-500 text-white',
|
453 |
+
'hover:bg-purple-600',
|
454 |
+
'transition-all duration-200',
|
455 |
+
{ 'opacity-50 cursor-not-allowed': !modelString || isInstalling },
|
456 |
+
)}
|
457 |
+
whileHover={{ scale: 1.02 }}
|
458 |
+
whileTap={{ scale: 0.98 }}
|
459 |
+
>
|
460 |
+
{isInstalling ? (
|
461 |
+
<div className="flex items-center gap-2">
|
462 |
+
<div className="i-ph:spinner-gap-bold animate-spin" />
|
463 |
+
<span>Installing...</span>
|
464 |
+
</div>
|
465 |
+
) : (
|
466 |
+
<div className="flex items-center gap-2">
|
467 |
+
<OllamaIcon className="w-4 h-4" />
|
468 |
+
<span>Install Model</span>
|
469 |
+
</div>
|
470 |
+
)}
|
471 |
+
</motion.button>
|
472 |
+
</div>
|
473 |
+
|
474 |
+
<div className="flex flex-wrap gap-2">
|
475 |
+
{allTags.map((tag) => (
|
476 |
+
<button
|
477 |
+
key={tag}
|
478 |
+
onClick={() => {
|
479 |
+
setSelectedTags((prev) => (prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]));
|
480 |
+
}}
|
481 |
+
className={classNames(
|
482 |
+
'px-3 py-1 rounded-full text-xs font-medium transition-all duration-200',
|
483 |
+
selectedTags.includes(tag)
|
484 |
+
? 'bg-purple-500 text-white'
|
485 |
+
: 'bg-bolt-elements-background-depth-3 text-bolt-elements-textSecondary hover:bg-bolt-elements-background-depth-4',
|
486 |
+
)}
|
487 |
+
>
|
488 |
+
{tag}
|
489 |
+
</button>
|
490 |
+
))}
|
491 |
+
</div>
|
492 |
+
|
493 |
+
<div className="grid grid-cols-1 gap-2">
|
494 |
+
{filteredModels.map((model) => (
|
495 |
+
<motion.div
|
496 |
+
key={model.name}
|
497 |
+
className={classNames(
|
498 |
+
'flex items-start gap-2 p-3 rounded-lg',
|
499 |
+
'bg-bolt-elements-background-depth-3',
|
500 |
+
'hover:bg-bolt-elements-background-depth-4',
|
501 |
+
'transition-all duration-200',
|
502 |
+
'relative group',
|
503 |
+
)}
|
504 |
+
>
|
505 |
+
<OllamaIcon className="w-5 h-5 text-purple-500 mt-0.5 flex-shrink-0" />
|
506 |
+
<div className="flex-1 space-y-1.5">
|
507 |
+
<div className="flex items-start justify-between">
|
508 |
+
<div>
|
509 |
+
<p className="text-bolt-elements-textPrimary font-mono text-sm">{model.name}</p>
|
510 |
+
<p className="text-xs text-bolt-elements-textSecondary mt-0.5">{model.desc}</p>
|
511 |
+
</div>
|
512 |
+
<div className="text-right">
|
513 |
+
<span className="text-xs text-bolt-elements-textTertiary">{model.size}</span>
|
514 |
+
{model.installedVersion && (
|
515 |
+
<div className="mt-0.5 flex flex-col items-end gap-0.5">
|
516 |
+
<span className="text-xs text-bolt-elements-textTertiary">v{model.installedVersion}</span>
|
517 |
+
{model.needsUpdate && model.latestVersion && (
|
518 |
+
<span className="text-xs text-purple-500">v{model.latestVersion} available</span>
|
519 |
+
)}
|
520 |
+
</div>
|
521 |
+
)}
|
522 |
+
</div>
|
523 |
+
</div>
|
524 |
+
<div className="flex items-center justify-between">
|
525 |
+
<div className="flex flex-wrap gap-1">
|
526 |
+
{model.tags.map((tag) => (
|
527 |
+
<span
|
528 |
+
key={tag}
|
529 |
+
className="px-1.5 py-0.5 rounded-full text-[10px] bg-bolt-elements-background-depth-4 text-bolt-elements-textTertiary"
|
530 |
+
>
|
531 |
+
{tag}
|
532 |
+
</span>
|
533 |
+
))}
|
534 |
+
</div>
|
535 |
+
<div className="flex gap-2">
|
536 |
+
{model.installedVersion ? (
|
537 |
+
model.needsUpdate ? (
|
538 |
+
<motion.button
|
539 |
+
onClick={() => handleUpdateModel(model.name)}
|
540 |
+
className={classNames(
|
541 |
+
'px-2 py-0.5 rounded-lg text-xs',
|
542 |
+
'bg-purple-500 text-white',
|
543 |
+
'hover:bg-purple-600',
|
544 |
+
'transition-all duration-200',
|
545 |
+
'flex items-center gap-1',
|
546 |
+
)}
|
547 |
+
whileHover={{ scale: 1.02 }}
|
548 |
+
whileTap={{ scale: 0.98 }}
|
549 |
+
>
|
550 |
+
<div className="i-ph:arrows-clockwise text-xs" />
|
551 |
+
Update
|
552 |
+
</motion.button>
|
553 |
+
) : (
|
554 |
+
<span className="px-2 py-0.5 rounded-lg text-xs text-green-500 bg-green-500/10">Up to date</span>
|
555 |
+
)
|
556 |
+
) : (
|
557 |
+
<motion.button
|
558 |
+
onClick={() => handleInstallModel(model.name)}
|
559 |
+
className={classNames(
|
560 |
+
'px-2 py-0.5 rounded-lg text-xs',
|
561 |
+
'bg-purple-500 text-white',
|
562 |
+
'hover:bg-purple-600',
|
563 |
+
'transition-all duration-200',
|
564 |
+
'flex items-center gap-1',
|
565 |
+
)}
|
566 |
+
whileHover={{ scale: 1.02 }}
|
567 |
+
whileTap={{ scale: 0.98 }}
|
568 |
+
>
|
569 |
+
<div className="i-ph:download text-xs" />
|
570 |
+
Install
|
571 |
+
</motion.button>
|
572 |
+
)}
|
573 |
+
</div>
|
574 |
+
</div>
|
575 |
+
</div>
|
576 |
+
</motion.div>
|
577 |
+
))}
|
578 |
+
</div>
|
579 |
+
|
580 |
+
{installProgress && (
|
581 |
+
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="space-y-2">
|
582 |
+
<div className="flex justify-between text-sm">
|
583 |
+
<span className="text-bolt-elements-textSecondary">{installProgress.status}</span>
|
584 |
+
<div className="flex items-center gap-4">
|
585 |
+
<span className="text-bolt-elements-textTertiary">
|
586 |
+
{installProgress.downloadedSize} / {installProgress.totalSize}
|
587 |
+
</span>
|
588 |
+
<span className="text-bolt-elements-textTertiary">{installProgress.speed}</span>
|
589 |
+
<span className="text-bolt-elements-textSecondary">{Math.round(installProgress.progress)}%</span>
|
590 |
+
</div>
|
591 |
+
</div>
|
592 |
+
<Progress value={installProgress.progress} className="h-1" />
|
593 |
+
</motion.div>
|
594 |
+
)}
|
595 |
+
</div>
|
596 |
+
);
|
597 |
+
}
|
File without changes
|
File without changes
|
File without changes
|
@@ -1,5 +1,5 @@
|
|
1 |
-
import { BaseProviderChecker } from '~/components
|
2 |
-
import type { StatusCheckResult } from '~/components
|
3 |
|
4 |
export class AmazonBedrockStatusChecker extends BaseProviderChecker {
|
5 |
async checkStatus(): Promise<StatusCheckResult> {
|
|
|
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> {
|
@@ -1,5 +1,5 @@
|
|
1 |
-
import { BaseProviderChecker } from '~/components
|
2 |
-
import type { StatusCheckResult } from '~/components
|
3 |
|
4 |
export class AnthropicStatusChecker extends BaseProviderChecker {
|
5 |
async checkStatus(): Promise<StatusCheckResult> {
|
|
|
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> {
|
@@ -1,5 +1,5 @@
|
|
1 |
-
import { BaseProviderChecker } from '~/components
|
2 |
-
import type { StatusCheckResult } from '~/components
|
3 |
|
4 |
export class CohereStatusChecker extends BaseProviderChecker {
|
5 |
async checkStatus(): Promise<StatusCheckResult> {
|
|
|
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> {
|
@@ -1,5 +1,5 @@
|
|
1 |
-
import { BaseProviderChecker } from '~/components
|
2 |
-
import type { StatusCheckResult } from '~/components
|
3 |
|
4 |
export class DeepseekStatusChecker extends BaseProviderChecker {
|
5 |
async checkStatus(): Promise<StatusCheckResult> {
|
|
|
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> {
|
@@ -1,5 +1,5 @@
|
|
1 |
-
import { BaseProviderChecker } from '~/components
|
2 |
-
import type { StatusCheckResult } from '~/components
|
3 |
|
4 |
export class GoogleStatusChecker extends BaseProviderChecker {
|
5 |
async checkStatus(): Promise<StatusCheckResult> {
|
|
|
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> {
|
@@ -1,5 +1,5 @@
|
|
1 |
-
import { BaseProviderChecker } from '~/components
|
2 |
-
import type { StatusCheckResult } from '~/components
|
3 |
|
4 |
export class GroqStatusChecker extends BaseProviderChecker {
|
5 |
async checkStatus(): Promise<StatusCheckResult> {
|
|
|
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> {
|
@@ -1,5 +1,5 @@
|
|
1 |
-
import { BaseProviderChecker } from '~/components
|
2 |
-
import type { StatusCheckResult } from '~/components
|
3 |
|
4 |
export class HuggingFaceStatusChecker extends BaseProviderChecker {
|
5 |
async checkStatus(): Promise<StatusCheckResult> {
|
|
|
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> {
|
@@ -1,5 +1,5 @@
|
|
1 |
-
import { BaseProviderChecker } from '~/components
|
2 |
-
import type { StatusCheckResult } from '~/components
|
3 |
|
4 |
export class HyperbolicStatusChecker extends BaseProviderChecker {
|
5 |
async checkStatus(): Promise<StatusCheckResult> {
|
|
|
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> {
|
@@ -1,5 +1,5 @@
|
|
1 |
-
import { BaseProviderChecker } from '~/components
|
2 |
-
import type { StatusCheckResult } from '~/components
|
3 |
|
4 |
export class MistralStatusChecker extends BaseProviderChecker {
|
5 |
async checkStatus(): Promise<StatusCheckResult> {
|
|
|
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> {
|
@@ -1,5 +1,5 @@
|
|
1 |
-
import { BaseProviderChecker } from '~/components
|
2 |
-
import type { StatusCheckResult } from '~/components
|
3 |
|
4 |
export class OpenAIStatusChecker extends BaseProviderChecker {
|
5 |
async checkStatus(): Promise<StatusCheckResult> {
|
|
|
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> {
|
@@ -1,5 +1,5 @@
|
|
1 |
-
import { BaseProviderChecker } from '~/components
|
2 |
-
import type { StatusCheckResult } from '~/components
|
3 |
|
4 |
export class OpenRouterStatusChecker extends BaseProviderChecker {
|
5 |
async checkStatus(): Promise<StatusCheckResult> {
|
|
|
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> {
|
@@ -1,5 +1,5 @@
|
|
1 |
-
import { BaseProviderChecker } from '~/components
|
2 |
-
import type { StatusCheckResult } from '~/components
|
3 |
|
4 |
export class PerplexityStatusChecker extends BaseProviderChecker {
|
5 |
async checkStatus(): Promise<StatusCheckResult> {
|
|
|
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> {
|
@@ -1,5 +1,5 @@
|
|
1 |
-
import { BaseProviderChecker } from '~/components
|
2 |
-
import type { StatusCheckResult } from '~/components
|
3 |
|
4 |
export class TogetherStatusChecker extends BaseProviderChecker {
|
5 |
async checkStatus(): Promise<StatusCheckResult> {
|
|
|
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> {
|
@@ -1,5 +1,5 @@
|
|
1 |
-
import { BaseProviderChecker } from '~/components
|
2 |
-
import type { StatusCheckResult } from '~/components
|
3 |
|
4 |
export class XAIStatusChecker extends BaseProviderChecker {
|
5 |
async checkStatus(): Promise<StatusCheckResult> {
|
|
|
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> {
|
File without changes
|
File without changes
|
@@ -4,7 +4,7 @@ import { toast } from 'react-toastify';
|
|
4 |
import { classNames } from '~/utils/classNames';
|
5 |
import { Switch } from '~/components/ui/Switch';
|
6 |
import { themeStore, kTheme } from '~/lib/stores/theme';
|
7 |
-
import type { UserProfile } from '~/components
|
8 |
import { useStore } from '@nanostores/react';
|
9 |
import { shortcutsStore } from '~/lib/stores/settings';
|
10 |
|
|
|
4 |
import { classNames } from '~/utils/classNames';
|
5 |
import { Switch } from '~/components/ui/Switch';
|
6 |
import { themeStore, kTheme } from '~/lib/stores/theme';
|
7 |
+
import type { UserProfile } from '~/components/@settings/core/types';
|
8 |
import { useStore } from '@nanostores/react';
|
9 |
import { shortcutsStore } from '~/lib/stores/settings';
|
10 |
|
@@ -1,4 +1,5 @@
|
|
1 |
-
import
|
|
|
2 |
import { classNames } from '~/utils/classNames';
|
3 |
import { Line } from 'react-chartjs-2';
|
4 |
import {
|
@@ -12,6 +13,9 @@ import {
|
|
12 |
Legend,
|
13 |
} from 'chart.js';
|
14 |
import { toast } from 'react-toastify'; // Import toast
|
|
|
|
|
|
|
15 |
|
16 |
// Register ChartJS components
|
17 |
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend);
|
@@ -74,12 +78,6 @@ interface SystemMetrics {
|
|
74 |
lcp: number;
|
75 |
};
|
76 |
};
|
77 |
-
storage: {
|
78 |
-
total: number;
|
79 |
-
used: number;
|
80 |
-
free: number;
|
81 |
-
type: string;
|
82 |
-
};
|
83 |
health: {
|
84 |
score: number;
|
85 |
issues: string[];
|
@@ -134,37 +132,46 @@ declare global {
|
|
134 |
}
|
135 |
}
|
136 |
|
137 |
-
|
138 |
-
const BATTERY_THRESHOLD = 20; // Enable energy saver when battery below 20%
|
139 |
const UPDATE_INTERVALS = {
|
140 |
normal: {
|
141 |
-
metrics: 1000, //
|
|
|
142 |
},
|
143 |
energySaver: {
|
144 |
-
metrics: 5000, //
|
|
|
145 |
},
|
146 |
};
|
147 |
|
148 |
-
//
|
149 |
-
const
|
150 |
-
|
151 |
-
|
152 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
153 |
};
|
154 |
|
155 |
-
|
156 |
-
|
157 |
-
|
158 |
-
fps: { warning: 30, critical: 15 },
|
159 |
-
loadTime: { warning: 3000, critical: 5000 },
|
160 |
};
|
161 |
|
|
|
162 |
const POWER_PROFILES: PowerProfile[] = [
|
163 |
{
|
164 |
name: 'Performance',
|
165 |
-
description: 'Maximum performance
|
166 |
settings: {
|
167 |
-
updateInterval:
|
168 |
enableAnimations: true,
|
169 |
backgroundProcessing: true,
|
170 |
networkThrottling: false,
|
@@ -172,7 +179,7 @@ const POWER_PROFILES: PowerProfile[] = [
|
|
172 |
},
|
173 |
{
|
174 |
name: 'Balanced',
|
175 |
-
description: '
|
176 |
settings: {
|
177 |
updateInterval: 2000,
|
178 |
enableAnimations: true,
|
@@ -181,10 +188,10 @@ const POWER_PROFILES: PowerProfile[] = [
|
|
181 |
},
|
182 |
},
|
183 |
{
|
184 |
-
name: '
|
185 |
-
description: 'Maximum
|
186 |
settings: {
|
187 |
-
updateInterval:
|
188 |
enableAnimations: false,
|
189 |
backgroundProcessing: false,
|
190 |
networkThrottling: true,
|
@@ -192,50 +199,271 @@ const POWER_PROFILES: PowerProfile[] = [
|
|
192 |
},
|
193 |
];
|
194 |
|
195 |
-
|
196 |
-
|
197 |
-
|
198 |
-
|
199 |
-
|
200 |
-
|
201 |
-
|
202 |
-
|
203 |
-
|
204 |
-
|
205 |
-
|
206 |
-
|
|
|
|
|
207 |
},
|
208 |
-
|
209 |
-
|
210 |
-
|
211 |
-
|
212 |
-
|
213 |
-
|
214 |
-
|
215 |
-
|
216 |
-
|
217 |
-
|
218 |
-
|
219 |
-
|
220 |
-
|
221 |
-
|
222 |
-
|
223 |
-
|
224 |
-
|
225 |
-
|
226 |
-
|
227 |
-
|
228 |
-
|
229 |
-
|
230 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
231 |
updatesReduced: 0,
|
232 |
timeInSaverMode: 0,
|
233 |
estimatedEnergySaved: 0,
|
234 |
-
});
|
235 |
-
|
236 |
-
const saverModeStartTime = useRef<number | null>(null);
|
237 |
-
const [selectedProfile, setSelectedProfile] = useState<PowerProfile>(POWER_PROFILES[1]); // Default to Balanced
|
238 |
const [alerts, setAlerts] = useState<PerformanceAlert[]>([]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
239 |
|
240 |
// Handle energy saver mode changes
|
241 |
const handleEnergySaverChange = (checked: boolean) => {
|
@@ -296,48 +524,6 @@ export default function TaskManagerTab() {
|
|
296 |
return () => clearInterval(interval);
|
297 |
}, [updateEnergySavings]);
|
298 |
|
299 |
-
// Get detailed performance metrics
|
300 |
-
const getPerformanceMetrics = async (): Promise<Partial<SystemMetrics['performance']>> => {
|
301 |
-
try {
|
302 |
-
// Get FPS
|
303 |
-
const fps = await measureFrameRate();
|
304 |
-
|
305 |
-
// Get page load metrics
|
306 |
-
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
|
307 |
-
const pageLoad = navigation.loadEventEnd - navigation.startTime;
|
308 |
-
const domReady = navigation.domContentLoadedEventEnd - navigation.startTime;
|
309 |
-
|
310 |
-
// Get resource metrics
|
311 |
-
const resources = performance.getEntriesByType('resource');
|
312 |
-
const resourceMetrics = {
|
313 |
-
total: resources.length,
|
314 |
-
size: resources.reduce((total, r) => total + (r as any).transferSize || 0, 0),
|
315 |
-
loadTime: Math.max(...resources.map((r) => r.duration)),
|
316 |
-
};
|
317 |
-
|
318 |
-
// Get Web Vitals
|
319 |
-
const ttfb = navigation.responseStart - navigation.requestStart;
|
320 |
-
const paintEntries = performance.getEntriesByType('paint');
|
321 |
-
const fcp = paintEntries.find((entry) => entry.name === 'first-contentful-paint')?.startTime || 0;
|
322 |
-
const lcpEntry = await getLargestContentfulPaint();
|
323 |
-
|
324 |
-
return {
|
325 |
-
fps,
|
326 |
-
pageLoad,
|
327 |
-
domReady,
|
328 |
-
resources: resourceMetrics,
|
329 |
-
timing: {
|
330 |
-
ttfb,
|
331 |
-
fcp,
|
332 |
-
lcp: lcpEntry?.startTime || 0,
|
333 |
-
},
|
334 |
-
};
|
335 |
-
} catch (error) {
|
336 |
-
console.error('Failed to get performance metrics:', error);
|
337 |
-
return {};
|
338 |
-
}
|
339 |
-
};
|
340 |
-
|
341 |
// Measure frame rate
|
342 |
const measureFrameRate = async (): Promise<number> => {
|
343 |
return new Promise((resolve) => {
|
@@ -486,12 +672,6 @@ export default function TaskManagerTab() {
|
|
486 |
battery: batteryInfo,
|
487 |
network: networkInfo,
|
488 |
performance: performanceMetrics as SystemMetrics['performance'],
|
489 |
-
storage: {
|
490 |
-
total: 0,
|
491 |
-
used: 0,
|
492 |
-
free: 0,
|
493 |
-
type: 'unknown',
|
494 |
-
},
|
495 |
health: { score: 0, issues: [], suggestions: [] },
|
496 |
};
|
497 |
|
@@ -597,23 +777,6 @@ export default function TaskManagerTab() {
|
|
597 |
};
|
598 |
}, [energySaverMode]);
|
599 |
|
600 |
-
// Initial update effect
|
601 |
-
useEffect((): (() => void) => {
|
602 |
-
// Initial update
|
603 |
-
updateMetrics();
|
604 |
-
|
605 |
-
// Set up intervals for live updates
|
606 |
-
const metricsInterval = setInterval(
|
607 |
-
updateMetrics,
|
608 |
-
energySaverMode ? UPDATE_INTERVALS.energySaver.metrics : UPDATE_INTERVALS.normal.metrics,
|
609 |
-
);
|
610 |
-
|
611 |
-
// Cleanup on unmount
|
612 |
-
return () => {
|
613 |
-
clearInterval(metricsInterval);
|
614 |
-
};
|
615 |
-
}, [energySaverMode]); // Re-create intervals when energy saver mode changes
|
616 |
-
|
617 |
const getUsageColor = (usage: number): string => {
|
618 |
if (usage > 80) {
|
619 |
return 'text-red-500';
|
@@ -761,6 +924,7 @@ export default function TaskManagerTab() {
|
|
761 |
onChange={(e) => handleAutoEnergySaverChange(e.target.checked)}
|
762 |
className="form-checkbox h-4 w-4 text-purple-600 rounded border-gray-300 dark:border-gray-700"
|
763 |
/>
|
|
|
764 |
<label htmlFor="autoEnergySaver" className="text-sm text-bolt-elements-textSecondary">
|
765 |
Auto Energy Saver
|
766 |
</label>
|
@@ -774,6 +938,7 @@ export default function TaskManagerTab() {
|
|
774 |
disabled={autoEnergySaver}
|
775 |
className="form-checkbox h-4 w-4 text-purple-600 rounded border-gray-300 dark:border-gray-700 disabled:opacity-50"
|
776 |
/>
|
|
|
777 |
<label
|
778 |
htmlFor="energySaver"
|
779 |
className={classNames('text-sm text-bolt-elements-textSecondary', { 'opacity-50': autoEnergySaver })}
|
@@ -782,24 +947,43 @@ export default function TaskManagerTab() {
|
|
782 |
{energySaverMode && <span className="ml-2 text-xs text-bolt-elements-textSecondary">Active</span>}
|
783 |
</label>
|
784 |
</div>
|
785 |
-
<
|
786 |
-
|
787 |
-
|
788 |
-
|
789 |
-
|
790 |
-
|
791 |
-
|
792 |
-
|
793 |
-
|
794 |
-
|
795 |
-
|
796 |
-
|
797 |
-
|
798 |
-
|
799 |
-
|
800 |
-
|
801 |
-
|
802 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
803 |
</div>
|
804 |
</div>
|
805 |
<div className="text-sm text-bolt-elements-textSecondary">{selectedProfile.description}</div>
|
@@ -981,30 +1165,6 @@ export default function TaskManagerTab() {
|
|
981 |
</div>
|
982 |
)}
|
983 |
|
984 |
-
{/* Storage Section */}
|
985 |
-
<div className="flex flex-col gap-2 rounded-lg bg-[#F8F8F8] dark:bg-[#141414] p-4">
|
986 |
-
<div className="flex items-center justify-between">
|
987 |
-
<span className="text-sm text-bolt-elements-textSecondary">Storage</span>
|
988 |
-
<span className="text-sm font-medium text-bolt-elements-textPrimary">
|
989 |
-
{formatBytes(metrics.storage.used)} / {formatBytes(metrics.storage.total)}
|
990 |
-
</span>
|
991 |
-
</div>
|
992 |
-
<div className="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
993 |
-
<div
|
994 |
-
className={classNames('h-full transition-all duration-300', {
|
995 |
-
'bg-green-500': metrics.storage.used / metrics.storage.total < 0.7,
|
996 |
-
'bg-yellow-500':
|
997 |
-
metrics.storage.used / metrics.storage.total >= 0.7 &&
|
998 |
-
metrics.storage.used / metrics.storage.total < 0.9,
|
999 |
-
'bg-red-500': metrics.storage.used / metrics.storage.total >= 0.9,
|
1000 |
-
})}
|
1001 |
-
style={{ width: `${(metrics.storage.used / metrics.storage.total) * 100}%` }}
|
1002 |
-
/>
|
1003 |
-
</div>
|
1004 |
-
<div className="text-xs text-bolt-elements-textSecondary mt-2">Free: {formatBytes(metrics.storage.free)}</div>
|
1005 |
-
<div className="text-xs text-bolt-elements-textSecondary">Type: {metrics.storage.type}</div>
|
1006 |
-
</div>
|
1007 |
-
|
1008 |
{/* Performance Alerts */}
|
1009 |
{alerts.length > 0 && (
|
1010 |
<div className="flex flex-col gap-2 rounded-lg bg-[#F8F8F8] dark:bg-[#141414] p-4">
|
@@ -1071,7 +1231,9 @@ export default function TaskManagerTab() {
|
|
1071 |
</div>
|
1072 |
</div>
|
1073 |
);
|
1074 |
-
}
|
|
|
|
|
1075 |
|
1076 |
// Helper function to format bytes
|
1077 |
const formatBytes = (bytes: number): string => {
|
|
|
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 {
|
|
|
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);
|
|
|
78 |
lcp: number;
|
79 |
};
|
80 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
81 |
health: {
|
82 |
score: number;
|
83 |
issues: string[];
|
|
|
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,
|
|
|
179 |
},
|
180 |
{
|
181 |
name: 'Balanced',
|
182 |
+
description: 'Optimal balance between performance and energy efficiency',
|
183 |
settings: {
|
184 |
updateInterval: 2000,
|
185 |
enableAnimations: true,
|
|
|
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,
|
|
|
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) => {
|
|
|
524 |
return () => clearInterval(interval);
|
525 |
}, [updateEnergySavings]);
|
526 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
527 |
// Measure frame rate
|
528 |
const measureFrameRate = async (): Promise<number> => {
|
529 |
return new Promise((resolve) => {
|
|
|
672 |
battery: batteryInfo,
|
673 |
network: networkInfo,
|
674 |
performance: performanceMetrics as SystemMetrics['performance'],
|
|
|
|
|
|
|
|
|
|
|
|
|
675 |
health: { score: 0, issues: [], suggestions: [] },
|
676 |
};
|
677 |
|
|
|
777 |
};
|
778 |
}, [energySaverMode]);
|
779 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
780 |
const getUsageColor = (usage: number): string => {
|
781 |
if (usage > 80) {
|
782 |
return 'text-red-500';
|
|
|
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>
|
|
|
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 })}
|
|
|
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>
|
|
|
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">
|
|
|
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 => {
|
@@ -35,6 +35,10 @@ interface UpdateInfo {
|
|
35 |
downloadProgress?: number;
|
36 |
installProgress?: number;
|
37 |
estimatedTimeRemaining?: number;
|
|
|
|
|
|
|
|
|
38 |
}
|
39 |
|
40 |
interface UpdateSettings {
|
@@ -46,11 +50,8 @@ interface UpdateSettings {
|
|
46 |
interface UpdateResponse {
|
47 |
success: boolean;
|
48 |
error?: string;
|
49 |
-
|
50 |
-
|
51 |
-
total: number;
|
52 |
-
stage: 'download' | 'install' | 'complete';
|
53 |
-
};
|
54 |
}
|
55 |
|
56 |
const categorizeChangelog = (messages: string[]) => {
|
@@ -190,62 +191,29 @@ const UpdateTab = () => {
|
|
190 |
localStorage.setItem('update_settings', JSON.stringify(updateSettings));
|
191 |
}, [updateSettings]);
|
192 |
|
193 |
-
const handleUpdateProgress = async (response: Response): Promise<void> => {
|
194 |
-
const reader = response.body?.getReader();
|
195 |
-
|
196 |
-
if (!reader) {
|
197 |
-
return;
|
198 |
-
}
|
199 |
-
|
200 |
-
const contentLength = +(response.headers.get('Content-Length') ?? 0);
|
201 |
-
let receivedLength = 0;
|
202 |
-
|
203 |
-
while (true) {
|
204 |
-
const { done, value } = await reader.read();
|
205 |
-
|
206 |
-
if (done) {
|
207 |
-
break;
|
208 |
-
}
|
209 |
-
|
210 |
-
receivedLength += value.length;
|
211 |
-
|
212 |
-
const progress = (receivedLength / contentLength) * 100;
|
213 |
-
|
214 |
-
setUpdateInfo((prev) => (prev ? { ...prev, downloadProgress: progress } : prev));
|
215 |
-
}
|
216 |
-
};
|
217 |
-
|
218 |
const checkForUpdates = async () => {
|
219 |
console.log('Starting update check...');
|
220 |
setIsChecking(true);
|
221 |
setError(null);
|
222 |
setLastChecked(new Date());
|
223 |
|
224 |
-
// Add a minimum delay of 2 seconds to show the spinning animation
|
225 |
-
const startTime = Date.now();
|
226 |
-
|
227 |
try {
|
228 |
console.log('Fetching update info...');
|
229 |
|
230 |
-
const githubToken = localStorage.getItem('github_connection');
|
231 |
-
const headers: HeadersInit = {};
|
232 |
-
|
233 |
-
if (githubToken) {
|
234 |
-
const { token } = JSON.parse(githubToken);
|
235 |
-
headers.Authorization = `Bearer ${token}`;
|
236 |
-
}
|
237 |
-
|
238 |
const branchToCheck = isLatestBranch ? 'main' : 'stable';
|
239 |
-
const info = await GITHUB_URLS.commitJson(branchToCheck
|
240 |
|
241 |
-
|
242 |
-
const elapsedTime = Date.now() - startTime;
|
243 |
|
244 |
-
if (
|
245 |
-
|
246 |
-
|
|
|
|
|
|
|
247 |
|
248 |
-
|
|
|
249 |
|
250 |
if (info.hasUpdate) {
|
251 |
const existingLogs = Object.values(logStore.logs.get());
|
@@ -267,18 +235,23 @@ const UpdateTab = () => {
|
|
267 |
});
|
268 |
|
269 |
if (updateSettings.autoUpdate && !hasUserRespondedToUpdate) {
|
270 |
-
setUpdateChangelog(
|
|
|
|
|
|
|
|
|
|
|
271 |
setShowUpdateDialog(true);
|
272 |
}
|
273 |
}
|
274 |
}
|
275 |
} catch (err) {
|
276 |
-
console.error('Detailed update check error:', err);
|
277 |
-
setError('Failed to check for updates. Please try again later.');
|
278 |
console.error('Update check failed:', err);
|
|
|
|
|
|
|
279 |
setUpdateFailed(true);
|
280 |
} finally {
|
281 |
-
console.log('Update check completed');
|
282 |
setIsChecking(false);
|
283 |
}
|
284 |
};
|
@@ -292,49 +265,45 @@ const UpdateTab = () => {
|
|
292 |
|
293 |
const attemptUpdate = async (): Promise<void> => {
|
294 |
try {
|
295 |
-
const
|
296 |
-
|
297 |
-
|
298 |
-
|
299 |
-
|
300 |
-
|
301 |
-
|
302 |
-
|
303 |
-
|
304 |
-
branch: isLatestBranch ? 'main' : 'stable',
|
305 |
-
settings: updateSettings,
|
306 |
-
}),
|
307 |
-
});
|
308 |
-
|
309 |
-
if (!response.ok) {
|
310 |
-
throw new Error('Failed to initiate update');
|
311 |
-
}
|
312 |
|
313 |
-
|
|
|
|
|
|
|
314 |
|
315 |
-
|
316 |
|
317 |
-
|
318 |
-
|
319 |
-
|
320 |
-
|
321 |
-
|
322 |
-
toast.success('Update completed successfully!');
|
323 |
-
setUpdateFailed(false);
|
324 |
|
325 |
-
|
326 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
327 |
|
328 |
-
|
329 |
}
|
330 |
|
331 |
-
|
332 |
-
logStore.logInfo('Manual update required', {
|
333 |
-
type: 'update',
|
334 |
-
message: 'Please download and install the latest version from the GitHub releases page.',
|
335 |
-
});
|
336 |
-
|
337 |
-
return;
|
338 |
} catch (err) {
|
339 |
currentRetry++;
|
340 |
|
@@ -349,13 +318,11 @@ const UpdateTab = () => {
|
|
349 |
return;
|
350 |
}
|
351 |
|
352 |
-
setError('Failed to
|
353 |
console.error('Update failed:', err);
|
354 |
logStore.logSystem('Update failed: ' + errorMessage);
|
355 |
toast.error('Update failed: ' + errorMessage);
|
356 |
setUpdateFailed(true);
|
357 |
-
|
358 |
-
return;
|
359 |
}
|
360 |
};
|
361 |
|
@@ -518,7 +485,19 @@ const UpdateTab = () => {
|
|
518 |
<div className="p-4 rounded-lg bg-red-500/10 border border-red-500/20 text-red-600 dark:text-red-400">
|
519 |
<div className="flex items-center gap-2">
|
520 |
<div className="i-ph:warning-circle" />
|
521 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
522 |
</div>
|
523 |
</div>
|
524 |
)}
|
@@ -803,7 +782,7 @@ const UpdateTab = () => {
|
|
803 |
</DialogDescription>
|
804 |
|
805 |
<div className="mt-3">
|
806 |
-
<h3 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">
|
807 |
<div
|
808 |
className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-lg p-3 max-h-[300px] overflow-y-auto"
|
809 |
style={{
|
@@ -814,7 +793,18 @@ const UpdateTab = () => {
|
|
814 |
<div className="text-sm text-bolt-elements-textSecondary space-y-1.5">
|
815 |
{updateChangelog.map((log, index) => (
|
816 |
<div key={index} className="break-words leading-relaxed">
|
817 |
-
{log
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
818 |
</div>
|
819 |
))}
|
820 |
</div>
|
|
|
35 |
downloadProgress?: number;
|
36 |
installProgress?: number;
|
37 |
estimatedTimeRemaining?: number;
|
38 |
+
error?: {
|
39 |
+
type: string;
|
40 |
+
message: string;
|
41 |
+
};
|
42 |
}
|
43 |
|
44 |
interface UpdateSettings {
|
|
|
50 |
interface UpdateResponse {
|
51 |
success: boolean;
|
52 |
error?: string;
|
53 |
+
message?: string;
|
54 |
+
instructions?: string[];
|
|
|
|
|
|
|
55 |
}
|
56 |
|
57 |
const categorizeChangelog = (messages: string[]) => {
|
|
|
191 |
localStorage.setItem('update_settings', JSON.stringify(updateSettings));
|
192 |
}, [updateSettings]);
|
193 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
194 |
const checkForUpdates = async () => {
|
195 |
console.log('Starting update check...');
|
196 |
setIsChecking(true);
|
197 |
setError(null);
|
198 |
setLastChecked(new Date());
|
199 |
|
|
|
|
|
|
|
200 |
try {
|
201 |
console.log('Fetching update info...');
|
202 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
203 |
const branchToCheck = isLatestBranch ? 'main' : 'stable';
|
204 |
+
const info = await GITHUB_URLS.commitJson(branchToCheck);
|
205 |
|
206 |
+
setUpdateInfo(info);
|
|
|
207 |
|
208 |
+
if (info.error) {
|
209 |
+
setError(info.error.message);
|
210 |
+
logStore.logWarning('Update Check Failed', {
|
211 |
+
type: 'update',
|
212 |
+
message: info.error.message,
|
213 |
+
});
|
214 |
|
215 |
+
return;
|
216 |
+
}
|
217 |
|
218 |
if (info.hasUpdate) {
|
219 |
const existingLogs = Object.values(logStore.logs.get());
|
|
|
235 |
});
|
236 |
|
237 |
if (updateSettings.autoUpdate && !hasUserRespondedToUpdate) {
|
238 |
+
setUpdateChangelog([
|
239 |
+
'New version available.',
|
240 |
+
`Compare changes: https://github.com/stackblitz-labs/bolt.diy/compare/${info.currentVersion}...${info.latestVersion}`,
|
241 |
+
'',
|
242 |
+
'Click "Update Now" to start the update process.',
|
243 |
+
]);
|
244 |
setShowUpdateDialog(true);
|
245 |
}
|
246 |
}
|
247 |
}
|
248 |
} catch (err) {
|
|
|
|
|
249 |
console.error('Update check failed:', err);
|
250 |
+
|
251 |
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
252 |
+
setError(`Failed to check for updates: ${errorMessage}`);
|
253 |
setUpdateFailed(true);
|
254 |
} finally {
|
|
|
255 |
setIsChecking(false);
|
256 |
}
|
257 |
};
|
|
|
265 |
|
266 |
const attemptUpdate = async (): Promise<void> => {
|
267 |
try {
|
268 |
+
const response = await fetch('/api/update', {
|
269 |
+
method: 'POST',
|
270 |
+
headers: {
|
271 |
+
'Content-Type': 'application/json',
|
272 |
+
},
|
273 |
+
body: JSON.stringify({
|
274 |
+
branch: isLatestBranch ? 'main' : 'stable',
|
275 |
+
}),
|
276 |
+
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
277 |
|
278 |
+
if (!response.ok) {
|
279 |
+
const errorData = (await response.json()) as { error: string };
|
280 |
+
throw new Error(errorData.error || 'Failed to initiate update');
|
281 |
+
}
|
282 |
|
283 |
+
const result = (await response.json()) as UpdateResponse;
|
284 |
|
285 |
+
if (result.success) {
|
286 |
+
logStore.logSuccess('Update instructions ready', {
|
287 |
+
type: 'update',
|
288 |
+
message: result.message || 'Update instructions ready',
|
289 |
+
});
|
|
|
|
|
290 |
|
291 |
+
// Show manual update instructions
|
292 |
+
setShowManualInstructions(true);
|
293 |
+
setUpdateChangelog(
|
294 |
+
result.instructions || [
|
295 |
+
'Failed to get update instructions. Please update manually:',
|
296 |
+
'1. git pull origin main',
|
297 |
+
'2. pnpm install',
|
298 |
+
'3. pnpm build',
|
299 |
+
'4. Restart the application',
|
300 |
+
],
|
301 |
+
);
|
302 |
|
303 |
+
return;
|
304 |
}
|
305 |
|
306 |
+
throw new Error(result.error || 'Update failed');
|
|
|
|
|
|
|
|
|
|
|
|
|
307 |
} catch (err) {
|
308 |
currentRetry++;
|
309 |
|
|
|
318 |
return;
|
319 |
}
|
320 |
|
321 |
+
setError('Failed to get update instructions. Please update manually.');
|
322 |
console.error('Update failed:', err);
|
323 |
logStore.logSystem('Update failed: ' + errorMessage);
|
324 |
toast.error('Update failed: ' + errorMessage);
|
325 |
setUpdateFailed(true);
|
|
|
|
|
326 |
}
|
327 |
};
|
328 |
|
|
|
485 |
<div className="p-4 rounded-lg bg-red-500/10 border border-red-500/20 text-red-600 dark:text-red-400">
|
486 |
<div className="flex items-center gap-2">
|
487 |
<div className="i-ph:warning-circle" />
|
488 |
+
<div className="flex flex-col">
|
489 |
+
<span className="font-medium">{error}</span>
|
490 |
+
{error.includes('rate limit') && (
|
491 |
+
<span className="text-sm mt-1">
|
492 |
+
Try adding a GitHub token in the connections tab to increase the rate limit.
|
493 |
+
</span>
|
494 |
+
)}
|
495 |
+
{error.includes('authentication') && (
|
496 |
+
<span className="text-sm mt-1">
|
497 |
+
Please check your GitHub token configuration in the connections tab.
|
498 |
+
</span>
|
499 |
+
)}
|
500 |
+
</div>
|
501 |
</div>
|
502 |
</div>
|
503 |
)}
|
|
|
782 |
</DialogDescription>
|
783 |
|
784 |
<div className="mt-3">
|
785 |
+
<h3 className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Update Information:</h3>
|
786 |
<div
|
787 |
className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-lg p-3 max-h-[300px] overflow-y-auto"
|
788 |
style={{
|
|
|
793 |
<div className="text-sm text-bolt-elements-textSecondary space-y-1.5">
|
794 |
{updateChangelog.map((log, index) => (
|
795 |
<div key={index} className="break-words leading-relaxed">
|
796 |
+
{log.startsWith('Compare changes:') ? (
|
797 |
+
<a
|
798 |
+
href={log.split(': ')[1]}
|
799 |
+
target="_blank"
|
800 |
+
rel="noopener noreferrer"
|
801 |
+
className="text-purple-500 hover:text-purple-600 dark:text-purple-400 dark:hover:text-purple-300"
|
802 |
+
>
|
803 |
+
View changes on GitHub
|
804 |
+
</a>
|
805 |
+
) : (
|
806 |
+
log
|
807 |
+
)}
|
808 |
</div>
|
809 |
))}
|
810 |
</div>
|
@@ -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 |
+
};
|
@@ -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 |
+
};
|
@@ -23,6 +23,7 @@ import type { ProviderInfo } from '~/types/model';
|
|
23 |
import { useSearchParams } from '@remix-run/react';
|
24 |
import { createSampler } from '~/utils/sampler';
|
25 |
import { getTemplates, selectStarterTemplate } from '~/utils/selectStarterTemplate';
|
|
|
26 |
|
27 |
const toastAnimation = cssTransition({
|
28 |
enter: 'animated fadeInRight',
|
@@ -114,8 +115,8 @@ export const ChatImpl = memo(
|
|
114 |
|
115 |
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
116 |
const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
|
117 |
-
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
|
118 |
-
const [imageDataList, setImageDataList] = useState<string[]>([]);
|
119 |
const [searchParams, setSearchParams] = useSearchParams();
|
120 |
const [fakeLoading, setFakeLoading] = useState(false);
|
121 |
const files = useStore(workbenchStore.files);
|
@@ -161,6 +162,11 @@ export const ChatImpl = memo(
|
|
161 |
sendExtraMessageFields: true,
|
162 |
onError: (e) => {
|
163 |
logger.error('Request failed\n\n', e, error);
|
|
|
|
|
|
|
|
|
|
|
164 |
toast.error(
|
165 |
'There was an error processing your request: ' + (e.message ? e.message : 'No details were returned'),
|
166 |
);
|
@@ -171,8 +177,14 @@ export const ChatImpl = memo(
|
|
171 |
|
172 |
if (usage) {
|
173 |
console.log('Token usage:', usage);
|
174 |
-
|
175 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
176 |
}
|
177 |
|
178 |
logger.debug('Finished streaming');
|
@@ -231,6 +243,13 @@ export const ChatImpl = memo(
|
|
231 |
stop();
|
232 |
chatStore.setKey('aborted', true);
|
233 |
workbenchStore.abortAllActions();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
234 |
};
|
235 |
|
236 |
useEffect(() => {
|
@@ -262,9 +281,9 @@ export const ChatImpl = memo(
|
|
262 |
};
|
263 |
|
264 |
const sendMessage = async (_event: React.UIEvent, messageInput?: string) => {
|
265 |
-
const
|
266 |
|
267 |
-
if (!
|
268 |
return;
|
269 |
}
|
270 |
|
@@ -280,7 +299,7 @@ export const ChatImpl = memo(
|
|
280 |
|
281 |
if (autoSelectTemplate) {
|
282 |
const { template, title } = await selectStarterTemplate({
|
283 |
-
message:
|
284 |
model,
|
285 |
provider,
|
286 |
});
|
@@ -302,7 +321,7 @@ export const ChatImpl = memo(
|
|
302 |
{
|
303 |
id: `${new Date().getTime()}`,
|
304 |
role: 'user',
|
305 |
-
content:
|
306 |
},
|
307 |
{
|
308 |
id: `${new Date().getTime()}`,
|
@@ -332,7 +351,7 @@ export const ChatImpl = memo(
|
|
332 |
content: [
|
333 |
{
|
334 |
type: 'text',
|
335 |
-
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${
|
336 |
},
|
337 |
...imageDataList.map((imageData) => ({
|
338 |
type: 'image',
|
@@ -356,31 +375,20 @@ export const ChatImpl = memo(
|
|
356 |
chatStore.setKey('aborted', false);
|
357 |
|
358 |
if (fileModifications !== undefined) {
|
359 |
-
/**
|
360 |
-
* If we have file modifications we append a new user message manually since we have to prefix
|
361 |
-
* the user input with the file modifications and we don't want the new user input to appear
|
362 |
-
* in the prompt. Using `append` is almost the same as `handleSubmit` except that we have to
|
363 |
-
* manually reset the input and we'd have to manually pass in file attachments. However, those
|
364 |
-
* aren't relevant here.
|
365 |
-
*/
|
366 |
append({
|
367 |
role: 'user',
|
368 |
content: [
|
369 |
{
|
370 |
type: 'text',
|
371 |
-
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${
|
372 |
},
|
373 |
...imageDataList.map((imageData) => ({
|
374 |
type: 'image',
|
375 |
image: imageData,
|
376 |
})),
|
377 |
-
] as any,
|
378 |
});
|
379 |
|
380 |
-
/**
|
381 |
-
* After sending a new message we reset all modifications since the model
|
382 |
-
* should now be aware of all the changes.
|
383 |
-
*/
|
384 |
workbenchStore.resetAllFileModifications();
|
385 |
} else {
|
386 |
append({
|
@@ -388,20 +396,19 @@ export const ChatImpl = memo(
|
|
388 |
content: [
|
389 |
{
|
390 |
type: 'text',
|
391 |
-
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${
|
392 |
},
|
393 |
...imageDataList.map((imageData) => ({
|
394 |
type: 'image',
|
395 |
image: imageData,
|
396 |
})),
|
397 |
-
] as any,
|
398 |
});
|
399 |
}
|
400 |
|
401 |
setInput('');
|
402 |
Cookies.remove(PROMPT_COOKIE_KEY);
|
403 |
|
404 |
-
// Add file cleanup here
|
405 |
setUploadedFiles([]);
|
406 |
setImageDataList([]);
|
407 |
|
|
|
23 |
import { useSearchParams } from '@remix-run/react';
|
24 |
import { createSampler } from '~/utils/sampler';
|
25 |
import { getTemplates, selectStarterTemplate } from '~/utils/selectStarterTemplate';
|
26 |
+
import { logStore } from '~/lib/stores/logs';
|
27 |
|
28 |
const toastAnimation = cssTransition({
|
29 |
enter: 'animated fadeInRight',
|
|
|
115 |
|
116 |
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
117 |
const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
|
118 |
+
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
|
119 |
+
const [imageDataList, setImageDataList] = useState<string[]>([]);
|
120 |
const [searchParams, setSearchParams] = useSearchParams();
|
121 |
const [fakeLoading, setFakeLoading] = useState(false);
|
122 |
const files = useStore(workbenchStore.files);
|
|
|
162 |
sendExtraMessageFields: true,
|
163 |
onError: (e) => {
|
164 |
logger.error('Request failed\n\n', e, error);
|
165 |
+
logStore.logError('Chat request failed', e, {
|
166 |
+
component: 'Chat',
|
167 |
+
action: 'request',
|
168 |
+
error: e.message,
|
169 |
+
});
|
170 |
toast.error(
|
171 |
'There was an error processing your request: ' + (e.message ? e.message : 'No details were returned'),
|
172 |
);
|
|
|
177 |
|
178 |
if (usage) {
|
179 |
console.log('Token usage:', usage);
|
180 |
+
logStore.logProvider('Chat response completed', {
|
181 |
+
component: 'Chat',
|
182 |
+
action: 'response',
|
183 |
+
model,
|
184 |
+
provider: provider.name,
|
185 |
+
usage,
|
186 |
+
messageLength: message.content.length,
|
187 |
+
});
|
188 |
}
|
189 |
|
190 |
logger.debug('Finished streaming');
|
|
|
243 |
stop();
|
244 |
chatStore.setKey('aborted', true);
|
245 |
workbenchStore.abortAllActions();
|
246 |
+
|
247 |
+
logStore.logProvider('Chat response aborted', {
|
248 |
+
component: 'Chat',
|
249 |
+
action: 'abort',
|
250 |
+
model,
|
251 |
+
provider: provider.name,
|
252 |
+
});
|
253 |
};
|
254 |
|
255 |
useEffect(() => {
|
|
|
281 |
};
|
282 |
|
283 |
const sendMessage = async (_event: React.UIEvent, messageInput?: string) => {
|
284 |
+
const messageContent = messageInput || input;
|
285 |
|
286 |
+
if (!messageContent?.trim()) {
|
287 |
return;
|
288 |
}
|
289 |
|
|
|
299 |
|
300 |
if (autoSelectTemplate) {
|
301 |
const { template, title } = await selectStarterTemplate({
|
302 |
+
message: messageContent,
|
303 |
model,
|
304 |
provider,
|
305 |
});
|
|
|
321 |
{
|
322 |
id: `${new Date().getTime()}`,
|
323 |
role: 'user',
|
324 |
+
content: messageContent,
|
325 |
},
|
326 |
{
|
327 |
id: `${new Date().getTime()}`,
|
|
|
351 |
content: [
|
352 |
{
|
353 |
type: 'text',
|
354 |
+
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${messageContent}`,
|
355 |
},
|
356 |
...imageDataList.map((imageData) => ({
|
357 |
type: 'image',
|
|
|
375 |
chatStore.setKey('aborted', false);
|
376 |
|
377 |
if (fileModifications !== undefined) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
378 |
append({
|
379 |
role: 'user',
|
380 |
content: [
|
381 |
{
|
382 |
type: 'text',
|
383 |
+
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${messageContent}`,
|
384 |
},
|
385 |
...imageDataList.map((imageData) => ({
|
386 |
type: 'image',
|
387 |
image: imageData,
|
388 |
})),
|
389 |
+
] as any,
|
390 |
});
|
391 |
|
|
|
|
|
|
|
|
|
392 |
workbenchStore.resetAllFileModifications();
|
393 |
} else {
|
394 |
append({
|
|
|
396 |
content: [
|
397 |
{
|
398 |
type: 'text',
|
399 |
+
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${messageContent}`,
|
400 |
},
|
401 |
...imageDataList.map((imageData) => ({
|
402 |
type: 'image',
|
403 |
image: imageData,
|
404 |
})),
|
405 |
+
] as any,
|
406 |
});
|
407 |
}
|
408 |
|
409 |
setInput('');
|
410 |
Cookies.remove(PROMPT_COOKIE_KEY);
|
411 |
|
|
|
412 |
setUploadedFiles([]);
|
413 |
setImageDataList([]);
|
414 |
|
@@ -6,7 +6,7 @@ import { generateId } from '~/utils/fileUtils';
|
|
6 |
import { useState } from 'react';
|
7 |
import { toast } from 'react-toastify';
|
8 |
import { LoadingOverlay } from '~/components/ui/LoadingOverlay';
|
9 |
-
import { RepositorySelectionDialog } from '~/components
|
10 |
import { classNames } from '~/utils/classNames';
|
11 |
import { Button } from '~/components/ui/Button';
|
12 |
import type { IChatMetadata } from '~/lib/persistence/db';
|
|
|
6 |
import { useState } from 'react';
|
7 |
import { toast } from 'react-toastify';
|
8 |
import { LoadingOverlay } from '~/components/ui/LoadingOverlay';
|
9 |
+
import { RepositorySelectionDialog } from '~/components/@settings/tabs/connections/components/RepositorySelectionDialog';
|
10 |
import { classNames } from '~/utils/classNames';
|
11 |
import { Button } from '~/components/ui/Button';
|
12 |
import type { IChatMetadata } from '~/lib/persistence/db';
|