V1 : Release of the new Settings Dashboard
Browse files# π Release v1.0.0
## What's Changed π
### π¨ UI/UX Improvements
- **Dark Mode Support**
- Implemented comprehensive dark theme across all components
- Enhanced contrast and readability in dark mode
- Added smooth theme transitions
- Optimized dialog overlays and backdrops
### π οΈ Settings Panel
- **Data Management**
- Added chat history export/import functionality
- Implemented settings backup and restore
- Added secure data deletion with confirmations
- Added profile customization options
- **Provider Management**
- Added comprehensive provider configuration
- Implemented URL-configurable providers
- Added local model support (Ollama, LMStudio)
- Added provider health checks
- Added provider status indicators
- **Ollama Integration**
- Added Ollama Model Manager with real-time updates
- Implemented model version tracking
- Added bulk update capability
- Added progress tracking for model updates
- Displays model details (parameter size, quantization)
- **GitHub Integration**
- Added GitHub connection management
- Implemented secure token storage
- Added connection state persistence
- Real-time connection status updates
- Proper error handling and user feedback
### π Event Logging
- **System Monitoring**
- Added real-time event logging system
- Implemented log filtering by type (info, warning, error, debug)
- Added log export functionality
- Added auto-scroll and search capabilities
- Enhanced log visualization with color coding
### π« Animations & Interactions
- Added smooth page transitions
- Implemented loading states with spinners
- Added micro-interactions for better feedback
- Enhanced button hover and active states
- Added motion effects for UI elements
### π Security Features
- Secure token storage
- Added confirmation dialogs for destructive actions
- Implemented data validation
- Added file size and type validation
- Secure connection management
### βΏοΈ Accessibility
- Improved keyboard navigation
- Enhanced screen reader support
- Added ARIA labels and descriptions
- Implemented focus management
- Added proper dialog accessibility
### π― Developer Experience
- Added comprehensive debug information
- Implemented system status monitoring
- Added version control integration
- Enhanced error handling and reporting
- Added detailed logging system
---
## π§ Technical Details
- **Frontend Stack**
- React 18 with TypeScript
- Framer Motion for animations
- TailwindCSS for styling
- Radix UI for accessible components
- **State Management**
- Local storage for persistence
- React hooks for state
- Custom stores for global state
- **API Integration**
- GitHub API integration
- Ollama API integration
- Provider API management
- Error boundary implementation
## π Notes
- Initial release focusing on core functionality and user experience
- Enhanced dark mode support across all components
- Improved accessibility and keyboard navigation
- Added comprehensive logging and debugging tools
- Implemented robust error handling and user feedback
- .windsurf/config.json +39 -0
- .windsurf/rules.json +103 -0
- app/components/settings/Settings.module.scss +0 -63
- app/components/settings/SettingsWindow.tsx +273 -101
- app/components/settings/connections/ConnectionsTab.tsx +171 -114
- app/components/settings/data/DataTab.tsx +352 -318
- app/components/settings/debug/DebugTab.tsx +168 -80
- app/components/settings/event-logs/EventLogsTab.tsx +257 -77
- app/components/settings/features/FeaturesTab.tsx +264 -71
- app/components/settings/profile/ProfileTab.tsx +399 -0
- app/components/settings/providers/OllamaModelUpdater.tsx +295 -0
- app/components/settings/providers/ProvidersTab.tsx +370 -110
- app/components/settings/settings.styles.ts +37 -0
- app/components/settings/settings.types.ts +53 -0
- app/components/ui/Dialog.tsx +72 -62
- app/components/ui/Separator.tsx +22 -0
- app/lib/stores/logs.ts +5 -0
- package.json +7 -2
- pnpm-lock.yaml +168 -3
- uno.config.ts +52 -16
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"enabled": true,
|
3 |
+
"rulesPath": ".windsurf/rules.json",
|
4 |
+
"integration": {
|
5 |
+
"ide": {
|
6 |
+
"cursor": true,
|
7 |
+
"vscode": true
|
8 |
+
},
|
9 |
+
"autoApply": true,
|
10 |
+
"notifications": true,
|
11 |
+
"autoFix": {
|
12 |
+
"enabled": true,
|
13 |
+
"onSave": true,
|
14 |
+
"formatOnSave": true,
|
15 |
+
"suggestImports": true,
|
16 |
+
"suggestComponents": true
|
17 |
+
},
|
18 |
+
"suggestions": {
|
19 |
+
"inline": true,
|
20 |
+
"quickFix": true,
|
21 |
+
"codeActions": true,
|
22 |
+
"snippets": true
|
23 |
+
}
|
24 |
+
},
|
25 |
+
"features": {
|
26 |
+
"codeCompletion": true,
|
27 |
+
"linting": true,
|
28 |
+
"formatting": true,
|
29 |
+
"importValidation": true,
|
30 |
+
"dependencyChecks": true,
|
31 |
+
"uiStandardization": true
|
32 |
+
},
|
33 |
+
"hooks": {
|
34 |
+
"preCommit": true,
|
35 |
+
"prePush": true,
|
36 |
+
"onFileCreate": true,
|
37 |
+
"onImportAdd": true
|
38 |
+
}
|
39 |
+
}
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"version": "1.0",
|
3 |
+
"rules": {
|
4 |
+
"fileTypes": {
|
5 |
+
"typescript": ["ts", "tsx"],
|
6 |
+
"javascript": ["js", "jsx", "mjs", "cjs"],
|
7 |
+
"json": ["json"],
|
8 |
+
"markdown": ["md"],
|
9 |
+
"css": ["css"],
|
10 |
+
"dockerfile": ["Dockerfile"]
|
11 |
+
},
|
12 |
+
"formatting": {
|
13 |
+
"typescript": {
|
14 |
+
"indentSize": 2,
|
15 |
+
"useTabs": false,
|
16 |
+
"maxLineLength": 100,
|
17 |
+
"semicolons": true,
|
18 |
+
"quotes": "single",
|
19 |
+
"trailingComma": "es5"
|
20 |
+
},
|
21 |
+
"javascript": {
|
22 |
+
"indentSize": 2,
|
23 |
+
"useTabs": false,
|
24 |
+
"maxLineLength": 100,
|
25 |
+
"semicolons": true,
|
26 |
+
"quotes": "single",
|
27 |
+
"trailingComma": "es5"
|
28 |
+
}
|
29 |
+
},
|
30 |
+
"linting": {
|
31 |
+
"typescript": {
|
32 |
+
"noUnusedVariables": true,
|
33 |
+
"noImplicitAny": true,
|
34 |
+
"strictNullChecks": true,
|
35 |
+
"noConsole": "warn"
|
36 |
+
}
|
37 |
+
},
|
38 |
+
"dependencies": {
|
39 |
+
"nodeVersion": ">=18.18.0",
|
40 |
+
"packageManager": "pnpm",
|
41 |
+
"requiredFiles": ["package.json", "tsconfig.json", ".env.example"]
|
42 |
+
},
|
43 |
+
"git": {
|
44 |
+
"ignoredPaths": ["node_modules", "build", ".env", ".env.local"],
|
45 |
+
"protectedBranches": ["main", "master"]
|
46 |
+
},
|
47 |
+
"testing": {
|
48 |
+
"framework": "vitest",
|
49 |
+
"coverage": {
|
50 |
+
"statements": 70,
|
51 |
+
"branches": 70,
|
52 |
+
"functions": 70,
|
53 |
+
"lines": 70
|
54 |
+
}
|
55 |
+
},
|
56 |
+
"security": {
|
57 |
+
"secrets": {
|
58 |
+
"patterns": ["API_KEY", "SECRET", "PASSWORD", "TOKEN"],
|
59 |
+
"locations": [".env", ".env.local"]
|
60 |
+
}
|
61 |
+
},
|
62 |
+
"commands": {
|
63 |
+
"dev": "pnpm dev",
|
64 |
+
"build": "pnpm build",
|
65 |
+
"test": "pnpm test",
|
66 |
+
"lint": "pnpm lint",
|
67 |
+
"typecheck": "pnpm typecheck"
|
68 |
+
},
|
69 |
+
"codeQuality": {
|
70 |
+
"imports": {
|
71 |
+
"validateImports": true,
|
72 |
+
"checkPackageAvailability": true,
|
73 |
+
"requireExactVersions": true,
|
74 |
+
"preventUnusedImports": true
|
75 |
+
},
|
76 |
+
"fileManagement": {
|
77 |
+
"preventUnnecessaryFiles": true,
|
78 |
+
"requireFileJustification": true,
|
79 |
+
"checkExistingImplementations": true
|
80 |
+
},
|
81 |
+
"dependencies": {
|
82 |
+
"autoInstallMissing": false,
|
83 |
+
"validateVersionCompatibility": true,
|
84 |
+
"checkPackageJson": true
|
85 |
+
}
|
86 |
+
},
|
87 |
+
"uiStandards": {
|
88 |
+
"styling": {
|
89 |
+
"framework": "tailwind",
|
90 |
+
"preferredIconSets": ["@iconify-json/ph", "@iconify-json/svg-spinners"],
|
91 |
+
"colorScheme": {
|
92 |
+
"useSystemPreference": true,
|
93 |
+
"supportDarkMode": true
|
94 |
+
},
|
95 |
+
"components": {
|
96 |
+
"preferModern": true,
|
97 |
+
"accessibility": true,
|
98 |
+
"responsive": true
|
99 |
+
}
|
100 |
+
}
|
101 |
+
}
|
102 |
+
}
|
103 |
+
}
|
@@ -1,63 +0,0 @@
|
|
1 |
-
.settings-tabs {
|
2 |
-
button {
|
3 |
-
width: 100%;
|
4 |
-
display: flex;
|
5 |
-
align-items: center;
|
6 |
-
gap: 0.5rem;
|
7 |
-
padding: 0.75rem 1rem;
|
8 |
-
border-radius: 0.5rem;
|
9 |
-
text-align: left;
|
10 |
-
font-size: 0.875rem;
|
11 |
-
transition: all 0.2s;
|
12 |
-
margin-bottom: 0.5rem;
|
13 |
-
|
14 |
-
&.active {
|
15 |
-
background: var(--bolt-elements-button-primary-background);
|
16 |
-
color: var(--bolt-elements-textPrimary);
|
17 |
-
}
|
18 |
-
|
19 |
-
&:not(.active) {
|
20 |
-
background: var(--bolt-elements-bg-depth-3);
|
21 |
-
color: var(--bolt-elements-textPrimary);
|
22 |
-
|
23 |
-
&:hover {
|
24 |
-
background: var(--bolt-elements-button-primary-backgroundHover);
|
25 |
-
}
|
26 |
-
}
|
27 |
-
}
|
28 |
-
}
|
29 |
-
|
30 |
-
.settings-button {
|
31 |
-
background-color: var(--bolt-elements-button-primary-background);
|
32 |
-
color: var(--bolt-elements-textPrimary);
|
33 |
-
border-radius: 0.5rem;
|
34 |
-
padding: 0.5rem 1rem;
|
35 |
-
transition: background-color 0.2s;
|
36 |
-
|
37 |
-
&:hover {
|
38 |
-
background-color: var(--bolt-elements-button-primary-backgroundHover);
|
39 |
-
}
|
40 |
-
}
|
41 |
-
|
42 |
-
.settings-danger-area {
|
43 |
-
background-color: transparent;
|
44 |
-
color: var(--bolt-elements-textPrimary);
|
45 |
-
border-radius: 0.5rem;
|
46 |
-
padding: 1rem;
|
47 |
-
margin-bottom: 1rem;
|
48 |
-
border-style: solid;
|
49 |
-
border-color: var(--bolt-elements-button-danger-backgroundHover);
|
50 |
-
border-width: thin;
|
51 |
-
|
52 |
-
button {
|
53 |
-
background-color: var(--bolt-elements-button-danger-background);
|
54 |
-
color: var(--bolt-elements-button-danger-text);
|
55 |
-
border-radius: 0.5rem;
|
56 |
-
padding: 0.5rem 1rem;
|
57 |
-
transition: background-color 0.2s;
|
58 |
-
|
59 |
-
&:hover {
|
60 |
-
background-color: var(--bolt-elements-button-danger-backgroundHover);
|
61 |
-
}
|
62 |
-
}
|
63 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,10 +1,11 @@
|
|
1 |
import * as RadixDialog from '@radix-ui/react-dialog';
|
2 |
-
import { motion } from 'framer-motion';
|
3 |
-
import { useState
|
4 |
import { classNames } from '~/utils/classNames';
|
5 |
-
import { DialogTitle
|
6 |
-
import {
|
7 |
-
import
|
|
|
8 |
import ProvidersTab from './providers/ProvidersTab';
|
9 |
import { useSettings } from '~/lib/hooks/useSettings';
|
10 |
import FeaturesTab from './features/FeaturesTab';
|
@@ -18,110 +19,281 @@ interface SettingsProps {
|
|
18 |
onClose: () => void;
|
19 |
}
|
20 |
|
21 |
-
type TabType = 'data' | 'providers' | 'features' | 'debug' | 'event-logs' | 'connection';
|
22 |
-
|
23 |
export const SettingsWindow = ({ open, onClose }: SettingsProps) => {
|
24 |
const { debug, eventLogs } = useSettings();
|
25 |
-
const [
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
{
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
:
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
:
|
52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
53 |
|
54 |
return (
|
55 |
<RadixDialog.Root open={open}>
|
56 |
<RadixDialog.Portal>
|
57 |
-
<
|
58 |
-
<
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
<
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
-
<div className={tab.icon} />
|
91 |
-
{tab.label}
|
92 |
-
</button>
|
93 |
-
))}
|
94 |
-
<div className="mt-auto flex flex-col gap-2">
|
95 |
-
<a
|
96 |
-
href="https://github.com/stackblitz-labs/bolt.diy"
|
97 |
-
target="_blank"
|
98 |
-
rel="noopener noreferrer"
|
99 |
-
className={classNames(styles['settings-button'], 'flex items-center gap-2')}
|
100 |
>
|
101 |
-
<div className="
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
109 |
>
|
110 |
-
<div className="
|
111 |
-
|
112 |
-
|
113 |
-
|
114 |
-
|
115 |
-
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
125 |
</RadixDialog.Portal>
|
126 |
</RadixDialog.Root>
|
127 |
);
|
|
|
1 |
import * as RadixDialog from '@radix-ui/react-dialog';
|
2 |
+
import { motion, AnimatePresence } from 'framer-motion';
|
3 |
+
import { useState } from 'react';
|
4 |
import { classNames } from '~/utils/classNames';
|
5 |
+
import { DialogTitle } from '~/components/ui/Dialog';
|
6 |
+
import type { SettingCategory, TabType } from './settings.types';
|
7 |
+
import { categoryLabels, categoryIcons } from './settings.types';
|
8 |
+
import ProfileTab from './profile/ProfileTab';
|
9 |
import ProvidersTab from './providers/ProvidersTab';
|
10 |
import { useSettings } from '~/lib/hooks/useSettings';
|
11 |
import FeaturesTab from './features/FeaturesTab';
|
|
|
19 |
onClose: () => void;
|
20 |
}
|
21 |
|
|
|
|
|
22 |
export const SettingsWindow = ({ open, onClose }: SettingsProps) => {
|
23 |
const { debug, eventLogs } = useSettings();
|
24 |
+
const [searchQuery, setSearchQuery] = useState('');
|
25 |
+
const [activeTab, setActiveTab] = useState<TabType | null>(null);
|
26 |
+
|
27 |
+
const settingItems = [
|
28 |
+
{
|
29 |
+
id: 'profile' as const,
|
30 |
+
label: 'Profile Settings',
|
31 |
+
icon: 'i-ph:user-circle',
|
32 |
+
category: 'profile' as const,
|
33 |
+
description: 'Manage your personal information and preferences',
|
34 |
+
component: () => <ProfileTab />,
|
35 |
+
keywords: ['profile', 'account', 'avatar', 'email', 'name', 'theme', 'notifications'],
|
36 |
+
},
|
37 |
+
|
38 |
+
{
|
39 |
+
id: 'data' as const,
|
40 |
+
label: 'Data Management',
|
41 |
+
icon: 'i-ph:database',
|
42 |
+
category: 'file_sharing' as const,
|
43 |
+
description: 'Manage your chat history and application data',
|
44 |
+
component: () => <DataTab />,
|
45 |
+
keywords: ['data', 'export', 'import', 'backup', 'delete'],
|
46 |
+
},
|
47 |
+
|
48 |
+
{
|
49 |
+
id: 'providers' as const,
|
50 |
+
label: 'Providers',
|
51 |
+
icon: 'i-ph:key',
|
52 |
+
category: 'file_sharing' as const,
|
53 |
+
description: 'Configure AI providers and API keys',
|
54 |
+
component: () => <ProvidersTab />,
|
55 |
+
keywords: ['api', 'keys', 'providers', 'configuration'],
|
56 |
+
},
|
57 |
+
|
58 |
+
{
|
59 |
+
id: 'connection' as const,
|
60 |
+
label: 'Connection',
|
61 |
+
icon: 'i-ph:link',
|
62 |
+
category: 'connectivity' as const,
|
63 |
+
description: 'Manage network and connection settings',
|
64 |
+
component: () => <ConnectionsTab />,
|
65 |
+
keywords: ['network', 'connection', 'proxy', 'ssl'],
|
66 |
+
},
|
67 |
+
|
68 |
+
{
|
69 |
+
id: 'features' as const,
|
70 |
+
label: 'Features',
|
71 |
+
icon: 'i-ph:star',
|
72 |
+
category: 'system' as const,
|
73 |
+
description: 'Configure application features and preferences',
|
74 |
+
component: () => <FeaturesTab />,
|
75 |
+
keywords: ['features', 'settings', 'options'],
|
76 |
+
},
|
77 |
+
] as const;
|
78 |
+
|
79 |
+
const debugItems = debug
|
80 |
+
? [
|
81 |
+
{
|
82 |
+
id: 'debug' as const,
|
83 |
+
label: 'Debug',
|
84 |
+
icon: 'i-ph:bug',
|
85 |
+
category: 'system' as const,
|
86 |
+
description: 'Advanced debugging tools and options',
|
87 |
+
component: () => <DebugTab />,
|
88 |
+
keywords: ['debug', 'logs', 'developer'],
|
89 |
+
},
|
90 |
+
]
|
91 |
+
: [];
|
92 |
+
|
93 |
+
const eventLogItems = eventLogs
|
94 |
+
? [
|
95 |
+
{
|
96 |
+
id: 'event-logs' as const,
|
97 |
+
label: 'Event Logs',
|
98 |
+
icon: 'i-ph:list-bullets',
|
99 |
+
category: 'system' as const,
|
100 |
+
description: 'View system events and application logs',
|
101 |
+
component: () => <EventLogsTab />,
|
102 |
+
keywords: ['logs', 'events', 'history'],
|
103 |
+
},
|
104 |
+
]
|
105 |
+
: [];
|
106 |
+
|
107 |
+
const allSettingItems = [...settingItems, ...debugItems, ...eventLogItems];
|
108 |
+
|
109 |
+
const filteredItems = allSettingItems.filter(
|
110 |
+
(item) =>
|
111 |
+
item.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
112 |
+
item.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
113 |
+
item.keywords?.some((keyword) => keyword.toLowerCase().includes(searchQuery.toLowerCase())),
|
114 |
+
);
|
115 |
+
|
116 |
+
const groupedItems = filteredItems.reduce(
|
117 |
+
(acc, item) => {
|
118 |
+
if (!acc[item.category]) {
|
119 |
+
acc[item.category] = allSettingItems.filter((i) => i.category === item.category);
|
120 |
+
}
|
121 |
+
|
122 |
+
return acc;
|
123 |
+
},
|
124 |
+
{} as Record<SettingCategory, typeof allSettingItems>,
|
125 |
+
);
|
126 |
+
|
127 |
+
const handleBackToDashboard = () => {
|
128 |
+
setActiveTab(null);
|
129 |
+
onClose();
|
130 |
+
};
|
131 |
+
|
132 |
+
const activeTabItem = allSettingItems.find((item) => item.id === activeTab);
|
133 |
|
134 |
return (
|
135 |
<RadixDialog.Root open={open}>
|
136 |
<RadixDialog.Portal>
|
137 |
+
<div className="fixed inset-0 flex items-center justify-center z-[9999]">
|
138 |
+
<RadixDialog.Overlay asChild>
|
139 |
+
<motion.div
|
140 |
+
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
141 |
+
initial={{ opacity: 0 }}
|
142 |
+
animate={{ opacity: 1 }}
|
143 |
+
exit={{ opacity: 0 }}
|
144 |
+
transition={{ duration: 0.2 }}
|
145 |
+
/>
|
146 |
+
</RadixDialog.Overlay>
|
147 |
+
<RadixDialog.Content aria-describedby={undefined} asChild>
|
148 |
+
<motion.div
|
149 |
+
className={classNames(
|
150 |
+
'relative',
|
151 |
+
'w-[1000px] max-h-[90vh] min-h-[700px]',
|
152 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
153 |
+
'rounded-2xl overflow-hidden shadow-2xl',
|
154 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
155 |
+
'overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent',
|
156 |
+
)}
|
157 |
+
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
158 |
+
animate={{ opacity: 1, scale: 1, y: 0 }}
|
159 |
+
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
160 |
+
transition={{ duration: 0.2 }}
|
161 |
+
>
|
162 |
+
<AnimatePresence mode="wait">
|
163 |
+
{activeTab ? (
|
164 |
+
<motion.div
|
165 |
+
className="flex flex-col h-full"
|
166 |
+
initial={{ opacity: 0, y: 20 }}
|
167 |
+
animate={{ opacity: 1, y: 0 }}
|
168 |
+
exit={{ opacity: 0, y: -20 }}
|
169 |
+
transition={{ duration: 0.2 }}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
170 |
>
|
171 |
+
<div className="flex items-center justify-between p-6 border-b border-[#E5E5E5] dark:border-[#1A1A1A] sticky top-0 bg-[#FAFAFA] dark:bg-[#0A0A0A] z-10">
|
172 |
+
<div className="flex items-center">
|
173 |
+
<button
|
174 |
+
onClick={() => setActiveTab(null)}
|
175 |
+
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm bg-[#F5F5F5] dark:bg-[#1A1A1A] text-[#666666] dark:text-[#999999] hover:text-[#333333] dark:hover:text-white"
|
176 |
+
>
|
177 |
+
<div className="i-ph:arrow-left w-4 h-4" />
|
178 |
+
Back to Settings
|
179 |
+
</button>
|
180 |
+
|
181 |
+
<div className="text-bolt-elements-textTertiary mx-6 select-none">|</div>
|
182 |
+
|
183 |
+
{activeTabItem && (
|
184 |
+
<div className="flex items-center gap-4">
|
185 |
+
<div className={classNames(activeTabItem.icon, 'w-6 h-6 text-purple-500')} />
|
186 |
+
<div>
|
187 |
+
<h2 className="text-lg font-medium text-bolt-elements-textPrimary">
|
188 |
+
{activeTabItem.label}
|
189 |
+
</h2>
|
190 |
+
<p className="text-sm text-bolt-elements-textSecondary">{activeTabItem.description}</p>
|
191 |
+
</div>
|
192 |
+
</div>
|
193 |
+
)}
|
194 |
+
</div>
|
195 |
+
|
196 |
+
<button
|
197 |
+
onClick={handleBackToDashboard}
|
198 |
+
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm bg-[#F5F5F5] dark:bg-[#1A1A1A] text-[#666666] dark:text-[#999999] hover:text-[#333333] dark:hover:text-white"
|
199 |
+
>
|
200 |
+
<div className="i-ph:house w-4 h-4" />
|
201 |
+
Back to Bolt DIY
|
202 |
+
</button>
|
203 |
+
</div>
|
204 |
+
<div className="flex-1 p-6 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent">
|
205 |
+
{allSettingItems.find((item) => item.id === activeTab)?.component()}
|
206 |
+
</div>
|
207 |
+
</motion.div>
|
208 |
+
) : (
|
209 |
+
<motion.div
|
210 |
+
className="flex flex-col h-full"
|
211 |
+
initial={{ opacity: 0, y: 20 }}
|
212 |
+
animate={{ opacity: 1, y: 0 }}
|
213 |
+
exit={{ opacity: 0, y: -20 }}
|
214 |
+
transition={{ duration: 0.2 }}
|
215 |
>
|
216 |
+
<div className="flex items-center justify-between p-6 border-b border-[#E5E5E5] dark:border-[#1A1A1A] sticky top-0 bg-[#FAFAFA] dark:bg-[#0A0A0A] z-10">
|
217 |
+
<div className="flex items-center gap-3">
|
218 |
+
<div className="i-ph:lightning-fill w-5 h-5 text-purple-500" />
|
219 |
+
<DialogTitle className="text-lg font-medium text-bolt-elements-textPrimary">
|
220 |
+
Bolt Control Panel
|
221 |
+
</DialogTitle>
|
222 |
+
</div>
|
223 |
+
<div className="flex items-center gap-4">
|
224 |
+
<div className="relative w-[320px]">
|
225 |
+
<input
|
226 |
+
type="text"
|
227 |
+
placeholder="Search settings..."
|
228 |
+
value={searchQuery}
|
229 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
230 |
+
className={classNames(
|
231 |
+
'w-full h-10 pl-10 pr-4 rounded-lg text-sm',
|
232 |
+
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
|
233 |
+
'border border-[#E5E5E5] dark:border-[#333333]',
|
234 |
+
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
235 |
+
'focus:outline-none focus:ring-1 focus:ring-purple-500 transition-all',
|
236 |
+
)}
|
237 |
+
/>
|
238 |
+
<div className="absolute left-3.5 top-1/2 -translate-y-1/2">
|
239 |
+
<div className="i-ph:magnifying-glass w-4 h-4 text-bolt-elements-textTertiary" />
|
240 |
+
</div>
|
241 |
+
</div>
|
242 |
+
<button
|
243 |
+
onClick={handleBackToDashboard}
|
244 |
+
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm bg-[#F5F5F5] dark:bg-[#1A1A1A] text-[#666666] dark:text-[#999999] hover:text-[#333333] dark:hover:text-white"
|
245 |
+
>
|
246 |
+
<div className="i-ph:house w-4 h-4" />
|
247 |
+
Back to Bolt DIY
|
248 |
+
</button>
|
249 |
+
</div>
|
250 |
+
</div>
|
251 |
+
|
252 |
+
<div className="flex-1 p-6 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent">
|
253 |
+
<div className="space-y-8">
|
254 |
+
{(Object.keys(groupedItems) as SettingCategory[]).map((category) => (
|
255 |
+
<div key={category} className="space-y-4">
|
256 |
+
<div className="flex items-center gap-3">
|
257 |
+
<div className={classNames(categoryIcons[category], 'w-5 h-5 text-purple-500')} />
|
258 |
+
<h2 className="text-base font-medium text-bolt-elements-textPrimary">
|
259 |
+
{categoryLabels[category]}
|
260 |
+
</h2>
|
261 |
+
</div>
|
262 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
263 |
+
{groupedItems[category].map((item) => (
|
264 |
+
<button
|
265 |
+
key={item.id}
|
266 |
+
onClick={() => setActiveTab(item.id)}
|
267 |
+
className={classNames(
|
268 |
+
'flex flex-col gap-2 p-4 rounded-lg text-left',
|
269 |
+
'bg-white dark:bg-[#0A0A0A]',
|
270 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
271 |
+
'hover:bg-[#F8F8F8] dark:hover:bg-[#1A1A1A]',
|
272 |
+
'transition-all duration-200',
|
273 |
+
)}
|
274 |
+
>
|
275 |
+
<div className="flex items-center gap-3">
|
276 |
+
<div className={classNames(item.icon, 'w-5 h-5 text-purple-500')} />
|
277 |
+
<span className="text-sm font-medium text-bolt-elements-textPrimary">
|
278 |
+
{item.label}
|
279 |
+
</span>
|
280 |
+
</div>
|
281 |
+
{item.description && (
|
282 |
+
<p className="text-sm text-bolt-elements-textSecondary">{item.description}</p>
|
283 |
+
)}
|
284 |
+
</button>
|
285 |
+
))}
|
286 |
+
</div>
|
287 |
+
</div>
|
288 |
+
))}
|
289 |
+
</div>
|
290 |
+
</div>
|
291 |
+
</motion.div>
|
292 |
+
)}
|
293 |
+
</AnimatePresence>
|
294 |
+
</motion.div>
|
295 |
+
</RadixDialog.Content>
|
296 |
+
</div>
|
297 |
</RadixDialog.Portal>
|
298 |
</RadixDialog.Root>
|
299 |
);
|
@@ -1,150 +1,207 @@
|
|
1 |
import React, { useState, useEffect } from 'react';
|
2 |
-
import { toast } from 'react-toastify';
|
3 |
-
import Cookies from 'js-cookie';
|
4 |
import { logStore } from '~/lib/stores/logs';
|
|
|
|
|
|
|
5 |
|
6 |
interface GitHubUserResponse {
|
7 |
login: string;
|
8 |
-
|
9 |
-
|
10 |
}
|
11 |
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
const [isVerifying, setIsVerifying] = useState(false);
|
17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
18 |
useEffect(() => {
|
19 |
-
|
20 |
-
|
21 |
-
|
|
|
22 |
}
|
23 |
-
}, []);
|
24 |
|
25 |
-
|
26 |
-
|
27 |
|
|
|
28 |
try {
|
|
|
|
|
29 |
const response = await fetch('https://api.github.com/user', {
|
30 |
headers: {
|
31 |
-
Authorization: `Bearer ${
|
32 |
},
|
33 |
});
|
34 |
|
35 |
-
if (response.ok) {
|
36 |
-
|
37 |
-
|
38 |
-
if (data.login === githubUsername) {
|
39 |
-
setIsConnected(true);
|
40 |
-
return true;
|
41 |
-
}
|
42 |
}
|
43 |
|
44 |
-
|
|
|
45 |
|
46 |
-
|
|
|
|
|
|
|
47 |
} catch (error) {
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
return false;
|
52 |
} finally {
|
53 |
-
|
54 |
}
|
55 |
};
|
56 |
|
57 |
-
const
|
58 |
-
|
59 |
-
|
60 |
-
return;
|
61 |
-
}
|
62 |
-
|
63 |
-
setIsVerifying(true);
|
64 |
-
|
65 |
-
const isValid = await verifyGitHubCredentials();
|
66 |
-
|
67 |
-
if (isValid) {
|
68 |
-
Cookies.set('githubUsername', githubUsername);
|
69 |
-
Cookies.set('githubToken', githubToken);
|
70 |
-
logStore.logSystem('GitHub connection settings updated', {
|
71 |
-
username: githubUsername,
|
72 |
-
hasToken: !!githubToken,
|
73 |
-
});
|
74 |
-
toast.success('GitHub credentials verified and saved successfully!');
|
75 |
-
Cookies.set('git:github.com', JSON.stringify({ username: githubToken, password: 'x-oauth-basic' }));
|
76 |
-
setIsConnected(true);
|
77 |
-
} else {
|
78 |
-
toast.error('Invalid GitHub credentials. Please check your username and token.');
|
79 |
-
}
|
80 |
};
|
81 |
|
82 |
const handleDisconnect = () => {
|
83 |
-
|
84 |
-
|
85 |
-
|
86 |
-
setGithubUsername('');
|
87 |
-
setGithubToken('');
|
88 |
-
setIsConnected(false);
|
89 |
-
logStore.logSystem('GitHub connection removed');
|
90 |
-
toast.success('GitHub connection removed successfully!');
|
91 |
};
|
92 |
|
93 |
-
|
94 |
-
|
95 |
-
<
|
96 |
-
|
97 |
-
|
98 |
-
<
|
99 |
-
<input
|
100 |
-
type="text"
|
101 |
-
value={githubUsername}
|
102 |
-
onChange={(e) => setGithubUsername(e.target.value)}
|
103 |
-
disabled={isVerifying}
|
104 |
-
className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor disabled:opacity-50"
|
105 |
-
/>
|
106 |
-
</div>
|
107 |
-
<div className="flex-1">
|
108 |
-
<label className="block text-sm text-bolt-elements-textSecondary mb-1">Personal Access Token:</label>
|
109 |
-
<input
|
110 |
-
type="password"
|
111 |
-
value={githubToken}
|
112 |
-
onChange={(e) => setGithubToken(e.target.value)}
|
113 |
-
disabled={isVerifying}
|
114 |
-
className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor disabled:opacity-50"
|
115 |
-
/>
|
116 |
</div>
|
117 |
</div>
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
|
127 |
-
|
128 |
-
|
129 |
-
|
130 |
-
|
131 |
-
|
132 |
-
|
133 |
-
|
134 |
-
|
135 |
-
|
136 |
-
|
137 |
-
|
138 |
-
|
139 |
-
|
140 |
-
|
141 |
-
|
142 |
-
|
143 |
-
|
144 |
-
|
145 |
-
|
146 |
-
|
147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
148 |
</div>
|
149 |
</div>
|
150 |
);
|
|
|
1 |
import React, { useState, useEffect } from 'react';
|
|
|
|
|
2 |
import { logStore } from '~/lib/stores/logs';
|
3 |
+
import { classNames } from '~/utils/classNames';
|
4 |
+
import { motion } from 'framer-motion';
|
5 |
+
import { toast } from 'react-toastify';
|
6 |
|
7 |
interface GitHubUserResponse {
|
8 |
login: string;
|
9 |
+
avatar_url: string;
|
10 |
+
html_url: string;
|
11 |
}
|
12 |
|
13 |
+
interface GitHubConnection {
|
14 |
+
user: GitHubUserResponse | null;
|
15 |
+
token: string;
|
16 |
+
}
|
|
|
17 |
|
18 |
+
export default function ConnectionsTab() {
|
19 |
+
const [connection, setConnection] = useState<GitHubConnection>({
|
20 |
+
user: null,
|
21 |
+
token: '',
|
22 |
+
});
|
23 |
+
const [isLoading, setIsLoading] = useState(true);
|
24 |
+
const [isConnecting, setIsConnecting] = useState(false);
|
25 |
+
|
26 |
+
// Load saved connection on mount
|
27 |
useEffect(() => {
|
28 |
+
const savedConnection = localStorage.getItem('github_connection');
|
29 |
+
|
30 |
+
if (savedConnection) {
|
31 |
+
setConnection(JSON.parse(savedConnection));
|
32 |
}
|
|
|
33 |
|
34 |
+
setIsLoading(false);
|
35 |
+
}, []);
|
36 |
|
37 |
+
const fetchGithubUser = async (token: string) => {
|
38 |
try {
|
39 |
+
setIsConnecting(true);
|
40 |
+
|
41 |
const response = await fetch('https://api.github.com/user', {
|
42 |
headers: {
|
43 |
+
Authorization: `Bearer ${token}`,
|
44 |
},
|
45 |
});
|
46 |
|
47 |
+
if (!response.ok) {
|
48 |
+
throw new Error('Invalid token or unauthorized');
|
|
|
|
|
|
|
|
|
|
|
49 |
}
|
50 |
|
51 |
+
const data = (await response.json()) as GitHubUserResponse;
|
52 |
+
const newConnection = { user: data, token };
|
53 |
|
54 |
+
// Save connection
|
55 |
+
localStorage.setItem('github_connection', JSON.stringify(newConnection));
|
56 |
+
setConnection(newConnection);
|
57 |
+
toast.success('Successfully connected to GitHub');
|
58 |
} catch (error) {
|
59 |
+
logStore.logError('Failed to authenticate with GitHub', { error });
|
60 |
+
toast.error('Failed to connect to GitHub');
|
61 |
+
setConnection({ user: null, token: '' });
|
|
|
62 |
} finally {
|
63 |
+
setIsConnecting(false);
|
64 |
}
|
65 |
};
|
66 |
|
67 |
+
const handleConnect = async (event: React.FormEvent) => {
|
68 |
+
event.preventDefault();
|
69 |
+
await fetchGithubUser(connection.token);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
70 |
};
|
71 |
|
72 |
const handleDisconnect = () => {
|
73 |
+
localStorage.removeItem('github_connection');
|
74 |
+
setConnection({ user: null, token: '' });
|
75 |
+
toast.success('Disconnected from GitHub');
|
|
|
|
|
|
|
|
|
|
|
76 |
};
|
77 |
|
78 |
+
if (isLoading) {
|
79 |
+
return (
|
80 |
+
<div className="flex items-center justify-center p-4">
|
81 |
+
<div className="flex items-center gap-2">
|
82 |
+
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
83 |
+
<span className="text-bolt-elements-textSecondary">Loading...</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
84 |
</div>
|
85 |
</div>
|
86 |
+
);
|
87 |
+
}
|
88 |
+
|
89 |
+
return (
|
90 |
+
<div className="space-y-4">
|
91 |
+
{/* Header */}
|
92 |
+
<motion.div
|
93 |
+
className="flex items-center gap-2 mb-2"
|
94 |
+
initial={{ opacity: 0, y: 20 }}
|
95 |
+
animate={{ opacity: 1, y: 0 }}
|
96 |
+
transition={{ delay: 0.1 }}
|
97 |
+
>
|
98 |
+
<div className="i-ph:plugs-connected w-5 h-5 text-purple-500" />
|
99 |
+
<h2 className="text-lg font-medium text-bolt-elements-textPrimary">Connection Settings</h2>
|
100 |
+
</motion.div>
|
101 |
+
<p className="text-sm text-bolt-elements-textSecondary mb-6">
|
102 |
+
Manage your external service connections and integrations
|
103 |
+
</p>
|
104 |
+
|
105 |
+
<div className="grid grid-cols-1 gap-4">
|
106 |
+
{/* GitHub Connection */}
|
107 |
+
<motion.div
|
108 |
+
className="bg-[#FFFFFF] dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
109 |
+
initial={{ opacity: 0, y: 20 }}
|
110 |
+
animate={{ opacity: 1, y: 0 }}
|
111 |
+
transition={{ delay: 0.2 }}
|
112 |
+
>
|
113 |
+
<div className="p-6 space-y-6">
|
114 |
+
<div className="flex items-center gap-2">
|
115 |
+
<div className="i-ph:github-logo w-5 h-5 text-bolt-elements-textPrimary" />
|
116 |
+
<h3 className="text-base font-medium text-bolt-elements-textPrimary">GitHub Connection</h3>
|
117 |
+
</div>
|
118 |
+
|
119 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
120 |
+
<div>
|
121 |
+
<label className="block text-sm text-bolt-elements-textSecondary mb-2">GitHub Username</label>
|
122 |
+
<input
|
123 |
+
type="text"
|
124 |
+
value={connection.user?.login || ''}
|
125 |
+
disabled={true}
|
126 |
+
placeholder="Not connected"
|
127 |
+
className={classNames(
|
128 |
+
'w-full px-3 py-2 rounded-lg text-sm',
|
129 |
+
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
|
130 |
+
'border border-[#E5E5E5] dark:border-[#333333]',
|
131 |
+
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
132 |
+
'focus:outline-none focus:ring-1 focus:ring-purple-500',
|
133 |
+
'disabled:opacity-50',
|
134 |
+
)}
|
135 |
+
/>
|
136 |
+
</div>
|
137 |
+
|
138 |
+
<div>
|
139 |
+
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Personal Access Token</label>
|
140 |
+
<input
|
141 |
+
type="password"
|
142 |
+
value={connection.token}
|
143 |
+
onChange={(e) => setConnection((prev) => ({ ...prev, token: e.target.value }))}
|
144 |
+
disabled={isConnecting || !!connection.user}
|
145 |
+
placeholder="Enter your GitHub token"
|
146 |
+
className={classNames(
|
147 |
+
'w-full px-3 py-2 rounded-lg text-sm',
|
148 |
+
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
|
149 |
+
'border border-[#E5E5E5] dark:border-[#333333]',
|
150 |
+
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
151 |
+
'focus:outline-none focus:ring-1 focus:ring-purple-500',
|
152 |
+
'disabled:opacity-50',
|
153 |
+
)}
|
154 |
+
/>
|
155 |
+
</div>
|
156 |
+
</div>
|
157 |
+
|
158 |
+
<div className="flex items-center gap-3">
|
159 |
+
{!connection.user ? (
|
160 |
+
<button
|
161 |
+
onClick={handleConnect}
|
162 |
+
disabled={isConnecting || !connection.token}
|
163 |
+
className={classNames(
|
164 |
+
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
165 |
+
'bg-purple-500 text-white',
|
166 |
+
'hover:bg-purple-600',
|
167 |
+
'disabled:opacity-50 disabled:cursor-not-allowed',
|
168 |
+
)}
|
169 |
+
>
|
170 |
+
{isConnecting ? (
|
171 |
+
<>
|
172 |
+
<div className="i-ph:spinner-gap animate-spin" />
|
173 |
+
Connecting...
|
174 |
+
</>
|
175 |
+
) : (
|
176 |
+
<>
|
177 |
+
<div className="i-ph:plug-charging w-4 h-4" />
|
178 |
+
Connect
|
179 |
+
</>
|
180 |
+
)}
|
181 |
+
</button>
|
182 |
+
) : (
|
183 |
+
<button
|
184 |
+
onClick={handleDisconnect}
|
185 |
+
className={classNames(
|
186 |
+
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
187 |
+
'bg-red-500 text-white',
|
188 |
+
'hover:bg-red-600',
|
189 |
+
)}
|
190 |
+
>
|
191 |
+
<div className="i-ph:plug-x w-4 h-4" />
|
192 |
+
Disconnect
|
193 |
+
</button>
|
194 |
+
)}
|
195 |
+
|
196 |
+
{connection.user && (
|
197 |
+
<span className="text-sm text-green-500 flex items-center gap-1">
|
198 |
+
<div className="i-ph:check-circle w-4 h-4" />
|
199 |
+
Connected to GitHub
|
200 |
+
</span>
|
201 |
+
)}
|
202 |
+
</div>
|
203 |
+
</div>
|
204 |
+
</motion.div>
|
205 |
</div>
|
206 |
</div>
|
207 |
);
|
@@ -1,388 +1,422 @@
|
|
1 |
-
import
|
2 |
-
import {
|
3 |
-
import Cookies from 'js-cookie';
|
4 |
import { toast } from 'react-toastify';
|
5 |
-
import {
|
6 |
-
import {
|
7 |
-
import { classNames } from '~/utils/classNames';
|
8 |
-
import type { Message } from 'ai';
|
9 |
-
|
10 |
-
// List of supported providers that can have API keys
|
11 |
-
const API_KEY_PROVIDERS = [
|
12 |
-
'Anthropic',
|
13 |
-
'OpenAI',
|
14 |
-
'Google',
|
15 |
-
'Groq',
|
16 |
-
'HuggingFace',
|
17 |
-
'OpenRouter',
|
18 |
-
'Deepseek',
|
19 |
-
'Mistral',
|
20 |
-
'OpenAILike',
|
21 |
-
'Together',
|
22 |
-
'xAI',
|
23 |
-
'Perplexity',
|
24 |
-
'Cohere',
|
25 |
-
'AzureOpenAI',
|
26 |
-
'AmazonBedrock',
|
27 |
-
] as const;
|
28 |
-
|
29 |
-
interface ApiKeys {
|
30 |
-
[key: string]: string;
|
31 |
-
}
|
32 |
|
33 |
export default function DataTab() {
|
34 |
-
const
|
|
|
|
|
35 |
const [isDeleting, setIsDeleting] = useState(false);
|
36 |
-
|
37 |
-
const
|
38 |
-
|
39 |
-
|
40 |
-
const link = document.createElement('a');
|
41 |
-
link.href = url;
|
42 |
-
link.download = filename;
|
43 |
-
document.body.appendChild(link);
|
44 |
-
link.click();
|
45 |
-
document.body.removeChild(link);
|
46 |
-
URL.revokeObjectURL(url);
|
47 |
-
};
|
48 |
|
49 |
const handleExportAllChats = async () => {
|
50 |
-
if (!db) {
|
51 |
-
const error = new Error('Database is not available');
|
52 |
-
logStore.logError('Failed to export chats - DB unavailable', error);
|
53 |
-
toast.error('Database is not available');
|
54 |
-
|
55 |
-
return;
|
56 |
-
}
|
57 |
-
|
58 |
try {
|
|
|
|
|
|
|
|
|
|
|
59 |
const allChats = await getAll(db);
|
60 |
const exportData = {
|
61 |
chats: allChats,
|
62 |
exportDate: new Date().toISOString(),
|
63 |
};
|
64 |
|
65 |
-
|
66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
67 |
toast.success('Chats exported successfully');
|
68 |
} catch (error) {
|
69 |
-
|
70 |
toast.error('Failed to export chats');
|
71 |
-
console.error(error);
|
72 |
}
|
73 |
};
|
74 |
|
75 |
-
const
|
76 |
-
const confirmDelete = window.confirm('Are you sure you want to delete all chats? This action cannot be undone.');
|
77 |
-
|
78 |
-
if (!confirmDelete) {
|
79 |
-
return;
|
80 |
-
}
|
81 |
-
|
82 |
-
if (!db) {
|
83 |
-
const error = new Error('Database is not available');
|
84 |
-
logStore.logError('Failed to delete chats - DB unavailable', error);
|
85 |
-
toast.error('Database is not available');
|
86 |
-
|
87 |
-
return;
|
88 |
-
}
|
89 |
-
|
90 |
try {
|
91 |
-
|
|
|
|
|
|
|
|
|
92 |
|
93 |
-
const
|
94 |
-
|
95 |
-
|
96 |
-
|
97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
98 |
} catch (error) {
|
99 |
-
|
100 |
-
toast.error('Failed to
|
101 |
-
console.error(error);
|
102 |
-
} finally {
|
103 |
-
setIsDeleting(false);
|
104 |
}
|
105 |
};
|
106 |
|
107 |
-
const
|
108 |
-
const settings = {
|
109 |
-
providers: Cookies.get('providers'),
|
110 |
-
isDebugEnabled: Cookies.get('isDebugEnabled'),
|
111 |
-
isEventLogsEnabled: Cookies.get('isEventLogsEnabled'),
|
112 |
-
isLocalModelsEnabled: Cookies.get('isLocalModelsEnabled'),
|
113 |
-
promptId: Cookies.get('promptId'),
|
114 |
-
isLatestBranch: Cookies.get('isLatestBranch'),
|
115 |
-
commitHash: Cookies.get('commitHash'),
|
116 |
-
eventLogs: Cookies.get('eventLogs'),
|
117 |
-
selectedModel: Cookies.get('selectedModel'),
|
118 |
-
selectedProvider: Cookies.get('selectedProvider'),
|
119 |
-
githubUsername: Cookies.get('githubUsername'),
|
120 |
-
githubToken: Cookies.get('githubToken'),
|
121 |
-
bolt_theme: localStorage.getItem('bolt_theme'),
|
122 |
-
};
|
123 |
-
|
124 |
-
downloadAsJson(settings, 'bolt-settings.json');
|
125 |
-
toast.success('Settings exported successfully');
|
126 |
-
};
|
127 |
-
|
128 |
-
const handleImportSettings = (event: React.ChangeEvent<HTMLInputElement>) => {
|
129 |
const file = event.target.files?.[0];
|
130 |
|
131 |
if (!file) {
|
132 |
return;
|
133 |
}
|
134 |
|
135 |
-
|
136 |
-
|
137 |
-
|
138 |
-
try {
|
139 |
-
const settings = JSON.parse(e.target?.result as string);
|
140 |
-
|
141 |
-
Object.entries(settings).forEach(([key, value]) => {
|
142 |
-
if (key === 'bolt_theme') {
|
143 |
-
if (value) {
|
144 |
-
localStorage.setItem(key, value as string);
|
145 |
-
}
|
146 |
-
} else if (value) {
|
147 |
-
Cookies.set(key, value as string);
|
148 |
-
}
|
149 |
-
});
|
150 |
-
|
151 |
-
toast.success('Settings imported successfully. Please refresh the page for changes to take effect.');
|
152 |
-
} catch (error) {
|
153 |
-
toast.error('Failed to import settings. Make sure the file is a valid JSON file.');
|
154 |
-
console.error('Failed to import settings:', error);
|
155 |
-
}
|
156 |
-
};
|
157 |
-
reader.readAsText(file);
|
158 |
-
event.target.value = '';
|
159 |
-
};
|
160 |
|
161 |
-
|
162 |
-
|
163 |
-
|
164 |
-
template[`${provider}_API_KEY`] = '';
|
165 |
-
});
|
166 |
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
template.TOGETHER_API_BASE_URL = '';
|
171 |
|
172 |
-
|
173 |
-
|
|
|
|
|
|
|
|
|
174 |
};
|
175 |
|
176 |
-
const
|
177 |
const file = event.target.files?.[0];
|
178 |
|
179 |
if (!file) {
|
180 |
return;
|
181 |
}
|
182 |
|
183 |
-
|
184 |
-
|
185 |
-
reader.onload = (e) => {
|
186 |
-
try {
|
187 |
-
const apiKeys = JSON.parse(e.target?.result as string);
|
188 |
-
let importedCount = 0;
|
189 |
-
const consolidatedKeys: Record<string, string> = {};
|
190 |
|
191 |
-
|
192 |
-
|
193 |
-
|
194 |
-
if (apiKeys[keyName]) {
|
195 |
-
consolidatedKeys[provider] = apiKeys[keyName];
|
196 |
-
importedCount++;
|
197 |
-
}
|
198 |
-
});
|
199 |
-
|
200 |
-
if (importedCount > 0) {
|
201 |
-
// Store all API keys in a single cookie as JSON
|
202 |
-
Cookies.set('apiKeys', JSON.stringify(consolidatedKeys));
|
203 |
-
|
204 |
-
// Also set individual cookies for backward compatibility
|
205 |
-
Object.entries(consolidatedKeys).forEach(([provider, key]) => {
|
206 |
-
Cookies.set(`${provider}_API_KEY`, key);
|
207 |
-
});
|
208 |
-
|
209 |
-
toast.success(`Successfully imported ${importedCount} API keys/URLs. Refreshing page to apply changes...`);
|
210 |
|
211 |
-
|
212 |
-
|
213 |
-
|
214 |
-
}
|
215 |
-
} else {
|
216 |
-
toast.warn('No valid API keys found in the file');
|
217 |
}
|
218 |
|
219 |
-
|
220 |
-
|
221 |
-
(baseUrl) => {
|
222 |
-
if (apiKeys[baseUrl]) {
|
223 |
-
Cookies.set(baseUrl, apiKeys[baseUrl]);
|
224 |
-
}
|
225 |
-
},
|
226 |
-
);
|
227 |
-
} catch (error) {
|
228 |
-
toast.error('Failed to import API keys. Make sure the file is a valid JSON file.');
|
229 |
-
console.error('Failed to import API keys:', error);
|
230 |
-
}
|
231 |
-
};
|
232 |
-
reader.readAsText(file);
|
233 |
-
event.target.value = '';
|
234 |
-
};
|
235 |
|
236 |
-
|
237 |
-
|
238 |
-
|
239 |
-
|
240 |
-
|
241 |
-
|
242 |
-
urlId?: string;
|
243 |
-
}> => {
|
244 |
-
// Handle Bolt standard format (single chat)
|
245 |
-
if (data.messages && Array.isArray(data.messages)) {
|
246 |
-
const chatId = crypto.randomUUID();
|
247 |
-
return [
|
248 |
-
{
|
249 |
-
id: chatId,
|
250 |
-
messages: data.messages,
|
251 |
-
description: data.description || 'Imported Chat',
|
252 |
-
urlId: chatId,
|
253 |
-
},
|
254 |
-
];
|
255 |
-
}
|
256 |
|
257 |
-
|
258 |
-
|
259 |
-
|
260 |
-
id: chat.id || crypto.randomUUID(),
|
261 |
-
messages: chat.messages,
|
262 |
-
description: chat.description || 'Imported Chat',
|
263 |
-
urlId: chat.urlId,
|
264 |
-
}));
|
265 |
}
|
266 |
-
|
267 |
-
console.error('No matching format found for:', data);
|
268 |
-
throw new Error('Unsupported chat format');
|
269 |
};
|
270 |
|
271 |
-
const
|
272 |
-
|
273 |
-
input.type = 'file';
|
274 |
-
input.accept = '.json';
|
275 |
-
|
276 |
-
input.onchange = async (e) => {
|
277 |
-
const file = (e.target as HTMLInputElement).files?.[0];
|
278 |
|
279 |
-
|
280 |
-
|
281 |
-
|
282 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
283 |
|
284 |
-
|
285 |
-
|
286 |
-
|
287 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
288 |
|
289 |
-
|
290 |
-
|
291 |
-
}
|
292 |
|
293 |
-
|
294 |
-
|
295 |
-
|
296 |
-
|
297 |
-
|
298 |
-
|
299 |
-
|
300 |
-
|
301 |
-
|
302 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
303 |
|
304 |
-
|
305 |
-
|
306 |
-
};
|
307 |
|
308 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
309 |
};
|
310 |
|
311 |
return (
|
312 |
-
<div className="
|
313 |
-
<
|
314 |
-
|
315 |
-
|
316 |
-
|
317 |
-
|
318 |
-
|
319 |
-
<
|
320 |
-
<
|
321 |
-
<button
|
322 |
-
onClick={handleExportAllChats}
|
323 |
-
className="px-4 py-2 bg-bolt-elements-button-primary-background hover:bg-bolt-elements-button-primary-backgroundHover text-bolt-elements-textPrimary rounded-lg transition-colors"
|
324 |
-
>
|
325 |
-
Export All Chats
|
326 |
-
</button>
|
327 |
-
<button
|
328 |
-
onClick={handleImportChats}
|
329 |
-
className="px-4 py-2 bg-bolt-elements-button-primary-background hover:bg-bolt-elements-button-primary-backgroundHover text-bolt-elements-textPrimary rounded-lg transition-colors"
|
330 |
-
>
|
331 |
-
Import Chats
|
332 |
-
</button>
|
333 |
-
<button
|
334 |
-
onClick={handleDeleteAllChats}
|
335 |
-
disabled={isDeleting}
|
336 |
-
className={classNames(
|
337 |
-
'px-4 py-2 bg-bolt-elements-button-danger-background hover:bg-bolt-elements-button-danger-backgroundHover text-bolt-elements-button-danger-text rounded-lg transition-colors',
|
338 |
-
isDeleting ? 'opacity-50 cursor-not-allowed' : '',
|
339 |
-
)}
|
340 |
-
>
|
341 |
-
{isDeleting ? 'Deleting...' : 'Delete All Chats'}
|
342 |
-
</button>
|
343 |
-
</div>
|
344 |
</div>
|
345 |
-
|
346 |
-
|
347 |
-
|
348 |
-
|
349 |
-
|
350 |
-
|
351 |
-
|
352 |
-
<button
|
353 |
-
onClick={handleExportSettings}
|
354 |
-
className="px-4 py-2 bg-bolt-elements-button-primary-background hover:bg-bolt-elements-button-primary-backgroundHover text-bolt-elements-textPrimary rounded-lg transition-colors"
|
355 |
-
>
|
356 |
-
Export Settings
|
357 |
</button>
|
358 |
-
|
359 |
-
|
360 |
-
|
361 |
-
|
362 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
363 |
</div>
|
364 |
-
|
365 |
-
|
366 |
-
|
367 |
-
|
368 |
-
|
369 |
-
|
370 |
-
|
371 |
-
|
372 |
-
|
373 |
-
|
374 |
-
|
375 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
376 |
</button>
|
377 |
-
|
378 |
-
|
379 |
-
|
380 |
-
|
381 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
382 |
</div>
|
383 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
384 |
</div>
|
385 |
-
</div>
|
386 |
</div>
|
387 |
);
|
388 |
}
|
|
|
1 |
+
import { useState, useRef } from 'react';
|
2 |
+
import { motion } from 'framer-motion';
|
|
|
3 |
import { toast } from 'react-toastify';
|
4 |
+
import { DialogRoot, DialogClose, Dialog, DialogTitle } from '~/components/ui/Dialog';
|
5 |
+
import { db, getAll } from '~/lib/persistence';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
6 |
|
7 |
export default function DataTab() {
|
8 |
+
const [isDownloadingTemplate, setIsDownloadingTemplate] = useState(false);
|
9 |
+
const [isImportingKeys, setIsImportingKeys] = useState(false);
|
10 |
+
const [isResetting, setIsResetting] = useState(false);
|
11 |
const [isDeleting, setIsDeleting] = useState(false);
|
12 |
+
const [showResetInlineConfirm, setShowResetInlineConfirm] = useState(false);
|
13 |
+
const [showDeleteInlineConfirm, setShowDeleteInlineConfirm] = useState(false);
|
14 |
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
15 |
+
const apiKeyFileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
16 |
|
17 |
const handleExportAllChats = async () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
18 |
try {
|
19 |
+
if (!db) {
|
20 |
+
throw new Error('Database not initialized');
|
21 |
+
}
|
22 |
+
|
23 |
+
// Get all chats from IndexedDB
|
24 |
const allChats = await getAll(db);
|
25 |
const exportData = {
|
26 |
chats: allChats,
|
27 |
exportDate: new Date().toISOString(),
|
28 |
};
|
29 |
|
30 |
+
// Download as JSON
|
31 |
+
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
32 |
+
const url = URL.createObjectURL(blob);
|
33 |
+
const a = document.createElement('a');
|
34 |
+
a.href = url;
|
35 |
+
a.download = `bolt-chats-${new Date().toISOString()}.json`;
|
36 |
+
document.body.appendChild(a);
|
37 |
+
a.click();
|
38 |
+
document.body.removeChild(a);
|
39 |
+
URL.revokeObjectURL(url);
|
40 |
+
|
41 |
toast.success('Chats exported successfully');
|
42 |
} catch (error) {
|
43 |
+
console.error('Export error:', error);
|
44 |
toast.error('Failed to export chats');
|
|
|
45 |
}
|
46 |
};
|
47 |
|
48 |
+
const handleExportSettings = () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
49 |
try {
|
50 |
+
const settings = {
|
51 |
+
userProfile: localStorage.getItem('bolt_user_profile'),
|
52 |
+
settings: localStorage.getItem('bolt_settings'),
|
53 |
+
exportDate: new Date().toISOString(),
|
54 |
+
};
|
55 |
|
56 |
+
const blob = new Blob([JSON.stringify(settings, null, 2)], { type: 'application/json' });
|
57 |
+
const url = URL.createObjectURL(blob);
|
58 |
+
const a = document.createElement('a');
|
59 |
+
a.href = url;
|
60 |
+
a.download = `bolt-settings-${new Date().toISOString()}.json`;
|
61 |
+
document.body.appendChild(a);
|
62 |
+
a.click();
|
63 |
+
document.body.removeChild(a);
|
64 |
+
URL.revokeObjectURL(url);
|
65 |
+
|
66 |
+
toast.success('Settings exported successfully');
|
67 |
} catch (error) {
|
68 |
+
console.error('Export error:', error);
|
69 |
+
toast.error('Failed to export settings');
|
|
|
|
|
|
|
70 |
}
|
71 |
};
|
72 |
|
73 |
+
const handleImportSettings = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
74 |
const file = event.target.files?.[0];
|
75 |
|
76 |
if (!file) {
|
77 |
return;
|
78 |
}
|
79 |
|
80 |
+
try {
|
81 |
+
const content = await file.text();
|
82 |
+
const settings = JSON.parse(content);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
83 |
|
84 |
+
if (settings.userProfile) {
|
85 |
+
localStorage.setItem('bolt_user_profile', settings.userProfile);
|
86 |
+
}
|
|
|
|
|
87 |
|
88 |
+
if (settings.settings) {
|
89 |
+
localStorage.setItem('bolt_settings', settings.settings);
|
90 |
+
}
|
|
|
91 |
|
92 |
+
window.location.reload(); // Reload to apply settings
|
93 |
+
toast.success('Settings imported successfully');
|
94 |
+
} catch (error) {
|
95 |
+
console.error('Import error:', error);
|
96 |
+
toast.error('Failed to import settings');
|
97 |
+
}
|
98 |
};
|
99 |
|
100 |
+
const handleImportAPIKeys = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
101 |
const file = event.target.files?.[0];
|
102 |
|
103 |
if (!file) {
|
104 |
return;
|
105 |
}
|
106 |
|
107 |
+
setIsImportingKeys(true);
|
|
|
|
|
|
|
|
|
|
|
|
|
108 |
|
109 |
+
try {
|
110 |
+
const content = await file.text();
|
111 |
+
const keys = JSON.parse(content);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
112 |
|
113 |
+
// Validate and save each key
|
114 |
+
Object.entries(keys).forEach(([key, value]) => {
|
115 |
+
if (typeof value !== 'string') {
|
116 |
+
throw new Error(`Invalid value for key: ${key}`);
|
|
|
|
|
117 |
}
|
118 |
|
119 |
+
localStorage.setItem(`bolt_${key.toLowerCase()}`, value);
|
120 |
+
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
121 |
|
122 |
+
toast.success('API keys imported successfully');
|
123 |
+
} catch (error) {
|
124 |
+
console.error('Error importing API keys:', error);
|
125 |
+
toast.error('Failed to import API keys');
|
126 |
+
} finally {
|
127 |
+
setIsImportingKeys(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
128 |
|
129 |
+
if (apiKeyFileInputRef.current) {
|
130 |
+
apiKeyFileInputRef.current.value = '';
|
131 |
+
}
|
|
|
|
|
|
|
|
|
|
|
132 |
}
|
|
|
|
|
|
|
133 |
};
|
134 |
|
135 |
+
const handleDownloadTemplate = () => {
|
136 |
+
setIsDownloadingTemplate(true);
|
|
|
|
|
|
|
|
|
|
|
137 |
|
138 |
+
try {
|
139 |
+
const template = {
|
140 |
+
Anthropic_API_KEY: '',
|
141 |
+
OpenAI_API_KEY: '',
|
142 |
+
Google_API_KEY: '',
|
143 |
+
Groq_API_KEY: '',
|
144 |
+
HuggingFace_API_KEY: '',
|
145 |
+
OpenRouter_API_KEY: '',
|
146 |
+
Deepseek_API_KEY: '',
|
147 |
+
Mistral_API_KEY: '',
|
148 |
+
OpenAILike_API_KEY: '',
|
149 |
+
Together_API_KEY: '',
|
150 |
+
xAI_API_KEY: '',
|
151 |
+
Perplexity_API_KEY: '',
|
152 |
+
Cohere_API_KEY: '',
|
153 |
+
AzureOpenAI_API_KEY: '',
|
154 |
+
OPENAI_LIKE_API_BASE_URL: '',
|
155 |
+
LMSTUDIO_API_BASE_URL: '',
|
156 |
+
OLLAMA_API_BASE_URL: '',
|
157 |
+
TOGETHER_API_BASE_URL: '',
|
158 |
+
};
|
159 |
|
160 |
+
const blob = new Blob([JSON.stringify(template, null, 2)], { type: 'application/json' });
|
161 |
+
const url = URL.createObjectURL(blob);
|
162 |
+
const a = document.createElement('a');
|
163 |
+
a.href = url;
|
164 |
+
a.download = 'bolt-api-keys-template.json';
|
165 |
+
document.body.appendChild(a);
|
166 |
+
a.click();
|
167 |
+
document.body.removeChild(a);
|
168 |
+
URL.revokeObjectURL(url);
|
169 |
+
|
170 |
+
toast.success('Template downloaded successfully');
|
171 |
+
} catch (error) {
|
172 |
+
console.error('Error downloading template:', error);
|
173 |
+
toast.error('Failed to download template');
|
174 |
+
} finally {
|
175 |
+
setIsDownloadingTemplate(false);
|
176 |
+
}
|
177 |
+
};
|
178 |
|
179 |
+
const handleResetSettings = async () => {
|
180 |
+
setIsResetting(true);
|
|
|
181 |
|
182 |
+
try {
|
183 |
+
// Clear all stored settings
|
184 |
+
localStorage.removeItem('bolt_user_profile');
|
185 |
+
localStorage.removeItem('bolt_settings');
|
186 |
+
localStorage.removeItem('bolt_chat_history');
|
187 |
+
|
188 |
+
// Reload the page to apply reset
|
189 |
+
window.location.reload();
|
190 |
+
toast.success('Settings reset successfully');
|
191 |
+
} catch (error) {
|
192 |
+
console.error('Reset error:', error);
|
193 |
+
toast.error('Failed to reset settings');
|
194 |
+
} finally {
|
195 |
+
setIsResetting(false);
|
196 |
+
}
|
197 |
+
};
|
198 |
|
199 |
+
const handleDeleteAllChats = async () => {
|
200 |
+
setIsDeleting(true);
|
|
|
201 |
|
202 |
+
try {
|
203 |
+
// Clear chat history
|
204 |
+
localStorage.removeItem('bolt_chat_history');
|
205 |
+
toast.success('Chat history deleted successfully');
|
206 |
+
} catch (error) {
|
207 |
+
console.error('Delete error:', error);
|
208 |
+
toast.error('Failed to delete chat history');
|
209 |
+
} finally {
|
210 |
+
setIsDeleting(false);
|
211 |
+
}
|
212 |
};
|
213 |
|
214 |
return (
|
215 |
+
<div className="space-y-6">
|
216 |
+
<input ref={fileInputRef} type="file" accept=".json" onChange={handleImportSettings} className="hidden" />
|
217 |
+
{/* Reset Settings Dialog */}
|
218 |
+
<DialogRoot open={showResetInlineConfirm} onOpenChange={setShowResetInlineConfirm}>
|
219 |
+
<Dialog showCloseButton={false}>
|
220 |
+
<div className="p-6">
|
221 |
+
<div className="flex items-center gap-3">
|
222 |
+
<div className="i-ph:warning-circle-fill w-5 h-5 text-yellow-500" />
|
223 |
+
<DialogTitle>Reset All Settings?</DialogTitle>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
224 |
</div>
|
225 |
+
<p className="text-sm text-bolt-elements-textSecondary mt-2">
|
226 |
+
This will reset all your settings to their default values. This action cannot be undone.
|
227 |
+
</p>
|
228 |
+
<div className="flex justify-end items-center gap-3 mt-6">
|
229 |
+
<DialogClose asChild>
|
230 |
+
<button className="px-4 py-2 rounded-lg text-sm bg-[#F5F5F5] dark:bg-[#1A1A1A] text-[#666666] dark:text-[#999999] hover:text-[#333333] dark:hover:text-white">
|
231 |
+
Cancel
|
|
|
|
|
|
|
|
|
|
|
232 |
</button>
|
233 |
+
</DialogClose>
|
234 |
+
<motion.button
|
235 |
+
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm bg-white dark:bg-[#1A1A1A] text-yellow-600 dark:text-yellow-500 hover:bg-yellow-50 dark:hover:bg-yellow-500/10 border border-transparent hover:border-yellow-500/10 dark:hover:border-yellow-500/20"
|
236 |
+
onClick={handleResetSettings}
|
237 |
+
disabled={isResetting}
|
238 |
+
whileHover={{ scale: 1.02 }}
|
239 |
+
whileTap={{ scale: 0.98 }}
|
240 |
+
>
|
241 |
+
{isResetting ? (
|
242 |
+
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
243 |
+
) : (
|
244 |
+
<div className="i-ph:arrow-counter-clockwise w-4 h-4" />
|
245 |
+
)}
|
246 |
+
Reset Settings
|
247 |
+
</motion.button>
|
248 |
</div>
|
249 |
+
</div>
|
250 |
+
</Dialog>
|
251 |
+
</DialogRoot>
|
252 |
+
|
253 |
+
{/* Delete Confirmation Dialog */}
|
254 |
+
<DialogRoot open={showDeleteInlineConfirm} onOpenChange={setShowDeleteInlineConfirm}>
|
255 |
+
<Dialog showCloseButton={false}>
|
256 |
+
<div className="p-6">
|
257 |
+
<div className="flex items-center gap-3">
|
258 |
+
<div className="i-ph:warning-circle-fill w-5 h-5 text-red-500" />
|
259 |
+
<DialogTitle>Delete All Chats?</DialogTitle>
|
260 |
+
</div>
|
261 |
+
<p className="text-sm text-bolt-elements-textSecondary mt-2">
|
262 |
+
This will permanently delete all your chat history. This action cannot be undone.
|
263 |
+
</p>
|
264 |
+
<div className="flex justify-end items-center gap-3 mt-6">
|
265 |
+
<DialogClose asChild>
|
266 |
+
<button className="px-4 py-2 rounded-lg text-sm bg-[#F5F5F5] dark:bg-[#1A1A1A] text-[#666666] dark:text-[#999999] hover:text-[#333333] dark:hover:text-white">
|
267 |
+
Cancel
|
268 |
</button>
|
269 |
+
</DialogClose>
|
270 |
+
<motion.button
|
271 |
+
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm bg-white dark:bg-[#1A1A1A] text-red-500 dark:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 border border-transparent hover:border-red-500/10 dark:hover:border-red-500/20"
|
272 |
+
onClick={handleDeleteAllChats}
|
273 |
+
disabled={isDeleting}
|
274 |
+
whileHover={{ scale: 1.02 }}
|
275 |
+
whileTap={{ scale: 0.98 }}
|
276 |
+
>
|
277 |
+
{isDeleting ? (
|
278 |
+
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
279 |
+
) : (
|
280 |
+
<div className="i-ph:trash w-4 h-4" />
|
281 |
+
)}
|
282 |
+
Delete All
|
283 |
+
</motion.button>
|
284 |
</div>
|
285 |
</div>
|
286 |
+
</Dialog>
|
287 |
+
</DialogRoot>
|
288 |
+
|
289 |
+
{/* Chat History Section */}
|
290 |
+
<motion.div
|
291 |
+
className="bg-white dark:bg-[#0A0A0A] rounded-lg p-6 border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
292 |
+
initial={{ opacity: 0, y: 20 }}
|
293 |
+
animate={{ opacity: 1, y: 0 }}
|
294 |
+
transition={{ delay: 0.1 }}
|
295 |
+
>
|
296 |
+
<div className="flex items-center gap-2 mb-2">
|
297 |
+
<div className="i-ph:chat-circle-duotone w-5 h-5 text-purple-500" />
|
298 |
+
<h3 className="text-lg font-medium">Chat History</h3>
|
299 |
+
</div>
|
300 |
+
<p className="text-sm text-bolt-elements-textSecondary mb-4">Export or delete all your chat history.</p>
|
301 |
+
<div className="flex gap-4">
|
302 |
+
<motion.button
|
303 |
+
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600"
|
304 |
+
whileHover={{ scale: 1.02 }}
|
305 |
+
whileTap={{ scale: 0.98 }}
|
306 |
+
onClick={handleExportAllChats}
|
307 |
+
>
|
308 |
+
<div className="i-ph:download-simple w-4 h-4" />
|
309 |
+
Export All Chats
|
310 |
+
</motion.button>
|
311 |
+
<motion.button
|
312 |
+
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-red-50 text-red-500 text-sm hover:bg-red-100 dark:bg-red-500/10 dark:hover:bg-red-500/20"
|
313 |
+
whileHover={{ scale: 1.02 }}
|
314 |
+
whileTap={{ scale: 0.98 }}
|
315 |
+
onClick={() => setShowDeleteInlineConfirm(true)}
|
316 |
+
>
|
317 |
+
<div className="i-ph:trash w-4 h-4" />
|
318 |
+
Delete All Chats
|
319 |
+
</motion.button>
|
320 |
+
</div>
|
321 |
+
</motion.div>
|
322 |
+
|
323 |
+
{/* Settings Backup Section */}
|
324 |
+
<motion.div
|
325 |
+
className="bg-white dark:bg-[#0A0A0A] rounded-lg p-6 border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
326 |
+
initial={{ opacity: 0, y: 20 }}
|
327 |
+
animate={{ opacity: 1, y: 0 }}
|
328 |
+
transition={{ delay: 0.2 }}
|
329 |
+
>
|
330 |
+
<div className="flex items-center gap-2 mb-2">
|
331 |
+
<div className="i-ph:gear-duotone w-5 h-5 text-purple-500" />
|
332 |
+
<h3 className="text-lg font-medium">Settings Backup</h3>
|
333 |
+
</div>
|
334 |
+
<p className="text-sm text-bolt-elements-textSecondary mb-4">
|
335 |
+
Export your settings to a JSON file or import settings from a previously exported file.
|
336 |
+
</p>
|
337 |
+
<div className="flex gap-4">
|
338 |
+
<motion.button
|
339 |
+
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600"
|
340 |
+
whileHover={{ scale: 1.02 }}
|
341 |
+
whileTap={{ scale: 0.98 }}
|
342 |
+
onClick={handleExportSettings}
|
343 |
+
>
|
344 |
+
<div className="i-ph:download-simple w-4 h-4" />
|
345 |
+
Export Settings
|
346 |
+
</motion.button>
|
347 |
+
<motion.button
|
348 |
+
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600"
|
349 |
+
whileHover={{ scale: 1.02 }}
|
350 |
+
whileTap={{ scale: 0.98 }}
|
351 |
+
onClick={() => fileInputRef.current?.click()}
|
352 |
+
>
|
353 |
+
<div className="i-ph:upload-simple w-4 h-4" />
|
354 |
+
Import Settings
|
355 |
+
</motion.button>
|
356 |
+
<motion.button
|
357 |
+
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-yellow-50 text-yellow-600 text-sm hover:bg-yellow-100 dark:bg-yellow-500/10 dark:hover:bg-yellow-500/20"
|
358 |
+
whileHover={{ scale: 1.02 }}
|
359 |
+
whileTap={{ scale: 0.98 }}
|
360 |
+
onClick={() => setShowResetInlineConfirm(true)}
|
361 |
+
>
|
362 |
+
<div className="i-ph:arrow-counter-clockwise w-4 h-4" />
|
363 |
+
Reset Settings
|
364 |
+
</motion.button>
|
365 |
+
</div>
|
366 |
+
</motion.div>
|
367 |
+
|
368 |
+
{/* API Keys Management Section */}
|
369 |
+
<motion.div
|
370 |
+
className="bg-white dark:bg-[#0A0A0A] rounded-lg p-6 border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
371 |
+
initial={{ opacity: 0, y: 20 }}
|
372 |
+
animate={{ opacity: 1, y: 0 }}
|
373 |
+
transition={{ delay: 0.3 }}
|
374 |
+
>
|
375 |
+
<div className="flex items-center gap-2 mb-2">
|
376 |
+
<div className="i-ph:key-duotone w-5 h-5 text-purple-500" />
|
377 |
+
<h3 className="text-lg font-medium">API Keys Management</h3>
|
378 |
+
</div>
|
379 |
+
<p className="text-sm text-bolt-elements-textSecondary mb-4">
|
380 |
+
Import API keys from a JSON file or download a template to fill in your keys.
|
381 |
+
</p>
|
382 |
+
<div className="flex gap-4">
|
383 |
+
<input
|
384 |
+
ref={apiKeyFileInputRef}
|
385 |
+
type="file"
|
386 |
+
accept=".json"
|
387 |
+
onChange={handleImportAPIKeys}
|
388 |
+
className="hidden"
|
389 |
+
/>
|
390 |
+
<motion.button
|
391 |
+
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600"
|
392 |
+
whileHover={{ scale: 1.02 }}
|
393 |
+
whileTap={{ scale: 0.98 }}
|
394 |
+
onClick={handleDownloadTemplate}
|
395 |
+
disabled={isDownloadingTemplate}
|
396 |
+
>
|
397 |
+
{isDownloadingTemplate ? (
|
398 |
+
<div className="i-ph:spinner-gap-bold animate-spin" />
|
399 |
+
) : (
|
400 |
+
<div className="i-ph:download-simple w-4 h-4" />
|
401 |
+
)}
|
402 |
+
Download Template
|
403 |
+
</motion.button>
|
404 |
+
<motion.button
|
405 |
+
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600"
|
406 |
+
whileHover={{ scale: 1.02 }}
|
407 |
+
whileTap={{ scale: 0.98 }}
|
408 |
+
onClick={() => apiKeyFileInputRef.current?.click()}
|
409 |
+
disabled={isImportingKeys}
|
410 |
+
>
|
411 |
+
{isImportingKeys ? (
|
412 |
+
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
413 |
+
) : (
|
414 |
+
<div className="i-ph:upload-simple w-4 h-4" />
|
415 |
+
)}
|
416 |
+
Import API Keys
|
417 |
+
</motion.button>
|
418 |
</div>
|
419 |
+
</motion.div>
|
420 |
</div>
|
421 |
);
|
422 |
}
|
@@ -2,6 +2,9 @@ import React, { useCallback, useEffect, useState } from 'react';
|
|
2 |
import { useSettings } from '~/lib/hooks/useSettings';
|
3 |
import { toast } from 'react-toastify';
|
4 |
import { providerBaseUrlEnvKeys } from '~/utils/constants';
|
|
|
|
|
|
|
5 |
|
6 |
interface ProviderStatus {
|
7 |
name: string;
|
@@ -438,107 +441,182 @@ export default function DebugTab() {
|
|
438 |
}, [activeProviders, systemInfo, isLatestBranch]);
|
439 |
|
440 |
return (
|
441 |
-
<div className="
|
442 |
<div className="flex items-center justify-between">
|
443 |
-
<
|
|
|
|
|
|
|
444 |
<div className="flex gap-2">
|
445 |
-
<button
|
446 |
onClick={handleCopyToClipboard}
|
447 |
-
className=
|
|
|
|
|
448 |
>
|
|
|
449 |
Copy Debug Info
|
450 |
-
</button>
|
451 |
-
<button
|
452 |
onClick={handleCheckForUpdate}
|
453 |
disabled={isCheckingUpdate}
|
454 |
-
className={
|
455 |
-
|
456 |
-
|
457 |
>
|
458 |
-
{isCheckingUpdate ?
|
459 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
460 |
</div>
|
461 |
</div>
|
462 |
|
463 |
{updateMessage && (
|
464 |
-
<div
|
465 |
-
className={
|
466 |
-
|
467 |
-
|
|
|
|
|
|
|
|
|
468 |
>
|
469 |
-
<
|
470 |
-
|
471 |
-
|
472 |
-
|
473 |
-
|
474 |
-
|
475 |
-
|
476 |
-
|
477 |
-
|
478 |
-
|
479 |
-
|
480 |
-
|
481 |
-
|
482 |
-
|
483 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
484 |
</div>
|
485 |
-
|
486 |
-
</div>
|
487 |
)}
|
488 |
|
489 |
<section className="space-y-4">
|
490 |
-
<div>
|
491 |
-
<
|
492 |
-
|
|
|
|
|
|
|
493 |
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
494 |
<div>
|
495 |
-
<
|
|
|
|
|
|
|
496 |
<p className="text-sm font-medium text-bolt-elements-textPrimary">{systemInfo.os}</p>
|
497 |
</div>
|
498 |
<div>
|
499 |
-
<
|
|
|
|
|
|
|
500 |
<p className="text-sm font-medium text-bolt-elements-textPrimary">{systemInfo.deviceType}</p>
|
501 |
</div>
|
502 |
<div>
|
503 |
-
<
|
|
|
|
|
|
|
504 |
<p className="text-sm font-medium text-bolt-elements-textPrimary">{systemInfo.browser}</p>
|
505 |
</div>
|
506 |
<div>
|
507 |
-
<
|
|
|
|
|
|
|
508 |
<p className="text-sm font-medium text-bolt-elements-textPrimary">
|
509 |
{systemInfo.screen} ({systemInfo.colorDepth}) @{systemInfo.pixelRatio}x
|
510 |
</p>
|
511 |
</div>
|
512 |
<div>
|
513 |
-
<
|
514 |
-
|
|
|
|
|
|
|
515 |
<span
|
516 |
-
className={
|
517 |
/>
|
518 |
-
<span
|
|
|
|
|
519 |
{systemInfo.online ? 'Online' : 'Offline'}
|
520 |
</span>
|
521 |
-
</
|
522 |
-
</div>
|
523 |
-
<div>
|
524 |
-
<p className="text-xs text-bolt-elements-textSecondary">Screen Resolution</p>
|
525 |
-
<p className="text-sm font-medium text-bolt-elements-textPrimary">{systemInfo.screen}</p>
|
526 |
</div>
|
527 |
<div>
|
528 |
-
<
|
|
|
|
|
|
|
529 |
<p className="text-sm font-medium text-bolt-elements-textPrimary">{systemInfo.language}</p>
|
530 |
</div>
|
531 |
<div>
|
532 |
-
<
|
|
|
|
|
|
|
533 |
<p className="text-sm font-medium text-bolt-elements-textPrimary">{systemInfo.timezone}</p>
|
534 |
</div>
|
535 |
<div>
|
536 |
-
<
|
|
|
|
|
|
|
537 |
<p className="text-sm font-medium text-bolt-elements-textPrimary">{systemInfo.cores}</p>
|
538 |
</div>
|
539 |
</div>
|
540 |
-
<div className="mt-3 pt-3 border-t border-bolt-elements-
|
541 |
-
<
|
|
|
|
|
|
|
542 |
<p className="text-sm font-medium text-bolt-elements-textPrimary font-mono">
|
543 |
{connitJson.commit.slice(0, 7)}
|
544 |
<span className="ml-2 text-xs text-bolt-elements-textSecondary">
|
@@ -546,22 +624,31 @@ export default function DebugTab() {
|
|
546 |
</span>
|
547 |
</p>
|
548 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
549 |
</div>
|
550 |
-
|
551 |
-
|
552 |
-
<div>
|
553 |
-
<h4 className="text-md font-medium text-bolt-elements-textPrimary mb-2">Local LLM Status</h4>
|
554 |
-
<div className="bg-bolt-elements-surface rounded-lg">
|
555 |
-
<div className="grid grid-cols-1 divide-y">
|
556 |
{activeProviders.map((provider) => (
|
557 |
-
<div key={provider.name} className="p-
|
558 |
<div className="flex items-center justify-between">
|
559 |
<div className="flex items-center gap-3">
|
560 |
<div className="flex-shrink-0">
|
561 |
<div
|
562 |
-
className={
|
563 |
-
|
564 |
-
|
|
|
565 |
/>
|
566 |
</div>
|
567 |
<div>
|
@@ -575,17 +662,21 @@ export default function DebugTab() {
|
|
575 |
</div>
|
576 |
<div className="flex items-center gap-2">
|
577 |
<span
|
578 |
-
className={
|
579 |
-
|
580 |
-
|
|
|
|
|
|
|
581 |
>
|
582 |
{provider.enabled ? 'Enabled' : 'Disabled'}
|
583 |
</span>
|
584 |
{provider.enabled && (
|
585 |
<span
|
586 |
-
className={
|
587 |
-
|
588 |
-
|
|
|
589 |
>
|
590 |
{provider.isRunning ? 'Running' : 'Not Running'}
|
591 |
</span>
|
@@ -593,31 +684,28 @@ export default function DebugTab() {
|
|
593 |
</div>
|
594 |
</div>
|
595 |
|
596 |
-
<div className="pl-5
|
597 |
-
{/* Status Details */}
|
598 |
<div className="flex flex-wrap gap-2">
|
599 |
-
<span className="text-bolt-elements-textSecondary">
|
600 |
Last checked: {new Date(provider.lastChecked).toLocaleTimeString()}
|
601 |
</span>
|
602 |
{provider.responseTime && (
|
603 |
-
<span className="text-bolt-elements-textSecondary">
|
604 |
Response time: {Math.round(provider.responseTime)}ms
|
605 |
</span>
|
606 |
)}
|
607 |
</div>
|
608 |
|
609 |
-
{/* Error Message */}
|
610 |
{provider.error && (
|
611 |
-
<div className="mt-
|
612 |
<span className="font-medium">Error:</span> {provider.error}
|
613 |
</div>
|
614 |
)}
|
615 |
|
616 |
-
{/* Connection Info */}
|
617 |
{provider.url && (
|
618 |
-
<div className="text-bolt-elements-textSecondary">
|
619 |
<span className="font-medium">Endpoints checked:</span>
|
620 |
-
<ul className="list-disc list-inside pl-2 mt-1">
|
621 |
<li>{provider.url} (root)</li>
|
622 |
<li>{provider.url}/api/health</li>
|
623 |
<li>{provider.url}/v1/models</li>
|
@@ -631,8 +719,8 @@ export default function DebugTab() {
|
|
631 |
<div className="p-4 text-center text-bolt-elements-textSecondary">No local LLMs configured</div>
|
632 |
)}
|
633 |
</div>
|
634 |
-
</div>
|
635 |
-
</div>
|
636 |
</section>
|
637 |
</div>
|
638 |
);
|
|
|
2 |
import { useSettings } from '~/lib/hooks/useSettings';
|
3 |
import { toast } from 'react-toastify';
|
4 |
import { providerBaseUrlEnvKeys } from '~/utils/constants';
|
5 |
+
import { motion } from 'framer-motion';
|
6 |
+
import { classNames } from '~/utils/classNames';
|
7 |
+
import { settingsStyles } from '~/components/settings/settings.styles';
|
8 |
|
9 |
interface ProviderStatus {
|
10 |
name: string;
|
|
|
441 |
}, [activeProviders, systemInfo, isLatestBranch]);
|
442 |
|
443 |
return (
|
444 |
+
<div className="space-y-6">
|
445 |
<div className="flex items-center justify-between">
|
446 |
+
<div className="flex items-center gap-2">
|
447 |
+
<div className="i-ph:bug-fill text-xl text-purple-500" />
|
448 |
+
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">Debug Information</h3>
|
449 |
+
</div>
|
450 |
<div className="flex gap-2">
|
451 |
+
<motion.button
|
452 |
onClick={handleCopyToClipboard}
|
453 |
+
className={classNames(settingsStyles.button.base, settingsStyles.button.primary)}
|
454 |
+
whileHover={{ scale: 1.02 }}
|
455 |
+
whileTap={{ scale: 0.98 }}
|
456 |
>
|
457 |
+
<div className="i-ph:copy" />
|
458 |
Copy Debug Info
|
459 |
+
</motion.button>
|
460 |
+
<motion.button
|
461 |
onClick={handleCheckForUpdate}
|
462 |
disabled={isCheckingUpdate}
|
463 |
+
className={classNames(settingsStyles.button.base, settingsStyles.button.primary)}
|
464 |
+
whileHover={!isCheckingUpdate ? { scale: 1.02 } : undefined}
|
465 |
+
whileTap={!isCheckingUpdate ? { scale: 0.98 } : undefined}
|
466 |
>
|
467 |
+
{isCheckingUpdate ? (
|
468 |
+
<>
|
469 |
+
<div className={settingsStyles['loading-spinner']} />
|
470 |
+
Checking...
|
471 |
+
</>
|
472 |
+
) : (
|
473 |
+
<>
|
474 |
+
<div className="i-ph:arrow-clockwise" />
|
475 |
+
Check for Updates
|
476 |
+
</>
|
477 |
+
)}
|
478 |
+
</motion.button>
|
479 |
</div>
|
480 |
</div>
|
481 |
|
482 |
{updateMessage && (
|
483 |
+
<motion.div
|
484 |
+
className={classNames(
|
485 |
+
settingsStyles.card,
|
486 |
+
'bg-bolt-elements-background-depth-2',
|
487 |
+
updateMessage.includes('Update available') ? 'border-l-4 border-yellow-500' : '',
|
488 |
+
)}
|
489 |
+
initial={{ opacity: 0, y: -20 }}
|
490 |
+
animate={{ opacity: 1, y: 0 }}
|
491 |
>
|
492 |
+
<div className="flex items-start gap-3">
|
493 |
+
<div
|
494 |
+
className={classNames(
|
495 |
+
updateMessage.includes('Update available')
|
496 |
+
? 'i-ph:warning-fill text-yellow-500'
|
497 |
+
: 'i-ph:info text-bolt-elements-textSecondary',
|
498 |
+
'text-xl flex-shrink-0',
|
499 |
+
)}
|
500 |
+
/>
|
501 |
+
<div className="flex-1">
|
502 |
+
<p className="text-bolt-elements-textSecondary whitespace-pre-line">{updateMessage}</p>
|
503 |
+
{updateMessage.includes('Update available') && (
|
504 |
+
<div className="mt-3">
|
505 |
+
<p className="font-medium text-bolt-elements-textPrimary">To update:</p>
|
506 |
+
<ol className="list-decimal ml-4 mt-1 space-y-2">
|
507 |
+
<li className="text-bolt-elements-textSecondary">
|
508 |
+
<div className="flex items-center gap-2">
|
509 |
+
<div className="i-ph:git-branch text-purple-500" />
|
510 |
+
Pull the latest changes:{' '}
|
511 |
+
<code className="px-1.5 py-0.5 rounded bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary">
|
512 |
+
git pull upstream main
|
513 |
+
</code>
|
514 |
+
</div>
|
515 |
+
</li>
|
516 |
+
<li className="text-bolt-elements-textSecondary">
|
517 |
+
<div className="flex items-center gap-2">
|
518 |
+
<div className="i-ph:package text-purple-500" />
|
519 |
+
Install any new dependencies:{' '}
|
520 |
+
<code className="px-1.5 py-0.5 rounded bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary">
|
521 |
+
pnpm install
|
522 |
+
</code>
|
523 |
+
</div>
|
524 |
+
</li>
|
525 |
+
<li className="text-bolt-elements-textSecondary">
|
526 |
+
<div className="flex items-center gap-2">
|
527 |
+
<div className="i-ph:arrows-clockwise text-purple-500" />
|
528 |
+
Restart the application
|
529 |
+
</div>
|
530 |
+
</li>
|
531 |
+
</ol>
|
532 |
+
</div>
|
533 |
+
)}
|
534 |
</div>
|
535 |
+
</div>
|
536 |
+
</motion.div>
|
537 |
)}
|
538 |
|
539 |
<section className="space-y-4">
|
540 |
+
<motion.div className="space-y-4">
|
541 |
+
<div className="flex items-center gap-2 mb-4">
|
542 |
+
<div className="i-ph:desktop text-xl text-purple-500" />
|
543 |
+
<h4 className="text-md font-medium text-bolt-elements-textPrimary">System Information</h4>
|
544 |
+
</div>
|
545 |
+
<motion.div className={classNames(settingsStyles.card, 'bg-bolt-elements-background-depth-2')}>
|
546 |
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
547 |
<div>
|
548 |
+
<div className="flex items-center gap-2 mb-1">
|
549 |
+
<div className="i-ph:computer-tower text-bolt-elements-textSecondary" />
|
550 |
+
<p className="text-xs text-bolt-elements-textSecondary">Operating System</p>
|
551 |
+
</div>
|
552 |
<p className="text-sm font-medium text-bolt-elements-textPrimary">{systemInfo.os}</p>
|
553 |
</div>
|
554 |
<div>
|
555 |
+
<div className="flex items-center gap-2 mb-1">
|
556 |
+
<div className="i-ph:device-mobile text-bolt-elements-textSecondary" />
|
557 |
+
<p className="text-xs text-bolt-elements-textSecondary">Device Type</p>
|
558 |
+
</div>
|
559 |
<p className="text-sm font-medium text-bolt-elements-textPrimary">{systemInfo.deviceType}</p>
|
560 |
</div>
|
561 |
<div>
|
562 |
+
<div className="flex items-center gap-2 mb-1">
|
563 |
+
<div className="i-ph:browser text-bolt-elements-textSecondary" />
|
564 |
+
<p className="text-xs text-bolt-elements-textSecondary">Browser</p>
|
565 |
+
</div>
|
566 |
<p className="text-sm font-medium text-bolt-elements-textPrimary">{systemInfo.browser}</p>
|
567 |
</div>
|
568 |
<div>
|
569 |
+
<div className="flex items-center gap-2 mb-1">
|
570 |
+
<div className="i-ph:monitor text-bolt-elements-textSecondary" />
|
571 |
+
<p className="text-xs text-bolt-elements-textSecondary">Display</p>
|
572 |
+
</div>
|
573 |
<p className="text-sm font-medium text-bolt-elements-textPrimary">
|
574 |
{systemInfo.screen} ({systemInfo.colorDepth}) @{systemInfo.pixelRatio}x
|
575 |
</p>
|
576 |
</div>
|
577 |
<div>
|
578 |
+
<div className="flex items-center gap-2 mb-1">
|
579 |
+
<div className="i-ph:wifi-high text-bolt-elements-textSecondary" />
|
580 |
+
<p className="text-xs text-bolt-elements-textSecondary">Connection</p>
|
581 |
+
</div>
|
582 |
+
<div className="flex items-center gap-2 mt-1">
|
583 |
<span
|
584 |
+
className={classNames('w-2 h-2 rounded-full', systemInfo.online ? 'bg-green-500' : 'bg-red-500')}
|
585 |
/>
|
586 |
+
<span
|
587 |
+
className={classNames('text-sm font-medium', systemInfo.online ? 'text-green-500' : 'text-red-500')}
|
588 |
+
>
|
589 |
{systemInfo.online ? 'Online' : 'Offline'}
|
590 |
</span>
|
591 |
+
</div>
|
|
|
|
|
|
|
|
|
592 |
</div>
|
593 |
<div>
|
594 |
+
<div className="flex items-center gap-2 mb-1">
|
595 |
+
<div className="i-ph:translate text-bolt-elements-textSecondary" />
|
596 |
+
<p className="text-xs text-bolt-elements-textSecondary">Language</p>
|
597 |
+
</div>
|
598 |
<p className="text-sm font-medium text-bolt-elements-textPrimary">{systemInfo.language}</p>
|
599 |
</div>
|
600 |
<div>
|
601 |
+
<div className="flex items-center gap-2 mb-1">
|
602 |
+
<div className="i-ph:clock text-bolt-elements-textSecondary" />
|
603 |
+
<p className="text-xs text-bolt-elements-textSecondary">Timezone</p>
|
604 |
+
</div>
|
605 |
<p className="text-sm font-medium text-bolt-elements-textPrimary">{systemInfo.timezone}</p>
|
606 |
</div>
|
607 |
<div>
|
608 |
+
<div className="flex items-center gap-2 mb-1">
|
609 |
+
<div className="i-ph:cpu text-bolt-elements-textSecondary" />
|
610 |
+
<p className="text-xs text-bolt-elements-textSecondary">CPU Cores</p>
|
611 |
+
</div>
|
612 |
<p className="text-sm font-medium text-bolt-elements-textPrimary">{systemInfo.cores}</p>
|
613 |
</div>
|
614 |
</div>
|
615 |
+
<div className="mt-3 pt-3 border-t border-bolt-elements-borderColor">
|
616 |
+
<div className="flex items-center gap-2 mb-1">
|
617 |
+
<div className="i-ph:git-commit text-bolt-elements-textSecondary" />
|
618 |
+
<p className="text-xs text-bolt-elements-textSecondary">Version</p>
|
619 |
+
</div>
|
620 |
<p className="text-sm font-medium text-bolt-elements-textPrimary font-mono">
|
621 |
{connitJson.commit.slice(0, 7)}
|
622 |
<span className="ml-2 text-xs text-bolt-elements-textSecondary">
|
|
|
624 |
</span>
|
625 |
</p>
|
626 |
</div>
|
627 |
+
</motion.div>
|
628 |
+
</motion.div>
|
629 |
+
|
630 |
+
<motion.div
|
631 |
+
className="space-y-4"
|
632 |
+
initial={{ opacity: 0, y: 20 }}
|
633 |
+
animate={{ opacity: 1, y: 0 }}
|
634 |
+
transition={{ delay: 0.2 }}
|
635 |
+
>
|
636 |
+
<div className="flex items-center gap-2 mb-4">
|
637 |
+
<div className="i-ph:robot text-xl text-purple-500" />
|
638 |
+
<h4 className="text-md font-medium text-bolt-elements-textPrimary">Local LLM Status</h4>
|
639 |
</div>
|
640 |
+
<motion.div className={classNames(settingsStyles.card, 'bg-bolt-elements-background-depth-2')}>
|
641 |
+
<div className="divide-y divide-bolt-elements-borderColor">
|
|
|
|
|
|
|
|
|
642 |
{activeProviders.map((provider) => (
|
643 |
+
<div key={provider.name} className="p-4 first:pt-0 last:pb-0">
|
644 |
<div className="flex items-center justify-between">
|
645 |
<div className="flex items-center gap-3">
|
646 |
<div className="flex-shrink-0">
|
647 |
<div
|
648 |
+
className={classNames(
|
649 |
+
'w-2 h-2 rounded-full',
|
650 |
+
!provider.enabled ? 'bg-gray-400' : provider.isRunning ? 'bg-green-500' : 'bg-red-500',
|
651 |
+
)}
|
652 |
/>
|
653 |
</div>
|
654 |
<div>
|
|
|
662 |
</div>
|
663 |
<div className="flex items-center gap-2">
|
664 |
<span
|
665 |
+
className={classNames(
|
666 |
+
'px-2 py-0.5 text-xs rounded-full',
|
667 |
+
provider.enabled
|
668 |
+
? 'bg-green-500/10 text-green-500'
|
669 |
+
: 'bg-gray-500/10 text-bolt-elements-textSecondary',
|
670 |
+
)}
|
671 |
>
|
672 |
{provider.enabled ? 'Enabled' : 'Disabled'}
|
673 |
</span>
|
674 |
{provider.enabled && (
|
675 |
<span
|
676 |
+
className={classNames(
|
677 |
+
'px-2 py-0.5 text-xs rounded-full',
|
678 |
+
provider.isRunning ? 'bg-green-500/10 text-green-500' : 'bg-red-500/10 text-red-500',
|
679 |
+
)}
|
680 |
>
|
681 |
{provider.isRunning ? 'Running' : 'Not Running'}
|
682 |
</span>
|
|
|
684 |
</div>
|
685 |
</div>
|
686 |
|
687 |
+
<div className="pl-5 mt-2 space-y-2">
|
|
|
688 |
<div className="flex flex-wrap gap-2">
|
689 |
+
<span className="text-xs text-bolt-elements-textSecondary">
|
690 |
Last checked: {new Date(provider.lastChecked).toLocaleTimeString()}
|
691 |
</span>
|
692 |
{provider.responseTime && (
|
693 |
+
<span className="text-xs text-bolt-elements-textSecondary">
|
694 |
Response time: {Math.round(provider.responseTime)}ms
|
695 |
</span>
|
696 |
)}
|
697 |
</div>
|
698 |
|
|
|
699 |
{provider.error && (
|
700 |
+
<div className="mt-2 text-xs text-red-500 bg-red-500/10 rounded-md p-2">
|
701 |
<span className="font-medium">Error:</span> {provider.error}
|
702 |
</div>
|
703 |
)}
|
704 |
|
|
|
705 |
{provider.url && (
|
706 |
+
<div className="text-xs text-bolt-elements-textSecondary mt-2">
|
707 |
<span className="font-medium">Endpoints checked:</span>
|
708 |
+
<ul className="list-disc list-inside pl-2 mt-1 space-y-1">
|
709 |
<li>{provider.url} (root)</li>
|
710 |
<li>{provider.url}/api/health</li>
|
711 |
<li>{provider.url}/v1/models</li>
|
|
|
719 |
<div className="p-4 text-center text-bolt-elements-textSecondary">No local LLMs configured</div>
|
720 |
)}
|
721 |
</div>
|
722 |
+
</motion.div>
|
723 |
+
</motion.div>
|
724 |
</section>
|
725 |
</div>
|
726 |
);
|
@@ -1,22 +1,27 @@
|
|
1 |
-
import React, { useCallback, useEffect, useState, useMemo } from 'react';
|
2 |
import { useSettings } from '~/lib/hooks/useSettings';
|
3 |
import { toast } from 'react-toastify';
|
4 |
import { Switch } from '~/components/ui/Switch';
|
5 |
import { logStore, type LogEntry } from '~/lib/stores/logs';
|
6 |
import { useStore } from '@nanostores/react';
|
7 |
import { classNames } from '~/utils/classNames';
|
|
|
|
|
8 |
|
9 |
export default function EventLogsTab() {
|
10 |
const {} = useSettings();
|
11 |
const showLogs = useStore(logStore.showLogs);
|
|
|
12 |
const [logLevel, setLogLevel] = useState<LogEntry['level'] | 'all'>('info');
|
13 |
const [autoScroll, setAutoScroll] = useState(true);
|
14 |
const [searchQuery, setSearchQuery] = useState('');
|
15 |
const [, forceUpdate] = useState({});
|
|
|
|
|
16 |
|
17 |
const filteredLogs = useMemo(() => {
|
18 |
-
const
|
19 |
-
|
20 |
const matchesLevel = !logLevel || log.level === logLevel || logLevel === 'all';
|
21 |
const matchesSearch =
|
22 |
!searchQuery ||
|
@@ -25,7 +30,9 @@ export default function EventLogsTab() {
|
|
25 |
|
26 |
return matchesLevel && matchesSearch;
|
27 |
});
|
28 |
-
|
|
|
|
|
29 |
|
30 |
// Effect to initialize showLogs
|
31 |
useEffect(() => {
|
@@ -37,18 +44,51 @@ export default function EventLogsTab() {
|
|
37 |
logStore.logSystem('Application initialized', {
|
38 |
version: process.env.NEXT_PUBLIC_APP_VERSION,
|
39 |
environment: process.env.NODE_ENV,
|
|
|
|
|
40 |
});
|
41 |
|
42 |
// Debug logs for system state
|
43 |
logStore.logDebug('System configuration loaded', {
|
44 |
runtime: 'Next.js',
|
45 |
-
features: ['AI Chat', 'Event Logging'],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
46 |
});
|
47 |
|
48 |
// Warning logs for potential issues
|
49 |
logStore.logWarning('Resource usage threshold approaching', {
|
50 |
memoryUsage: '75%',
|
51 |
cpuLoad: '60%',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
52 |
});
|
53 |
|
54 |
// Error logs with detailed context
|
@@ -56,16 +96,50 @@ export default function EventLogsTab() {
|
|
56 |
endpoint: '/api/chat',
|
57 |
retryCount: 3,
|
58 |
lastAttempt: new Date().toISOString(),
|
|
|
59 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
60 |
}, []);
|
61 |
|
|
|
62 |
useEffect(() => {
|
63 |
-
const container =
|
64 |
|
65 |
-
if (container && autoScroll) {
|
66 |
-
container.scrollTop =
|
67 |
}
|
68 |
-
}, [filteredLogs, autoScroll]);
|
69 |
|
70 |
const handleClearLogs = useCallback(() => {
|
71 |
if (confirm('Are you sure you want to clear all logs?')) {
|
@@ -103,33 +177,56 @@ export default function EventLogsTab() {
|
|
103 |
}
|
104 |
}, []);
|
105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
106 |
const getLevelColor = (level: LogEntry['level']) => {
|
107 |
switch (level) {
|
108 |
case 'info':
|
109 |
-
return 'text-
|
110 |
case 'warning':
|
111 |
-
return 'text-
|
112 |
case 'error':
|
113 |
-
return 'text-
|
114 |
case 'debug':
|
115 |
-
return 'text-
|
116 |
default:
|
117 |
return 'text-bolt-elements-textPrimary';
|
118 |
}
|
119 |
};
|
120 |
|
121 |
return (
|
122 |
-
<div className="
|
123 |
-
<div className="flex flex-col space-y-4
|
124 |
{/* Title and Toggles Row */}
|
125 |
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
126 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
127 |
<div className="flex flex-wrap items-center gap-4">
|
128 |
-
<div className="flex items-center
|
|
|
129 |
<span className="text-sm text-bolt-elements-textSecondary whitespace-nowrap">Show Actions</span>
|
130 |
<Switch checked={showLogs} onCheckedChange={(checked) => logStore.showLogs.set(checked)} />
|
131 |
</div>
|
132 |
-
<div className="flex items-center
|
|
|
133 |
<span className="text-sm text-bolt-elements-textSecondary whitespace-nowrap">Auto-scroll</span>
|
134 |
<Switch checked={autoScroll} onCheckedChange={setAutoScroll} />
|
135 |
</div>
|
@@ -137,83 +234,166 @@ export default function EventLogsTab() {
|
|
137 |
</div>
|
138 |
|
139 |
{/* Controls Row */}
|
140 |
-
<div className="flex flex-wrap items-center gap-
|
141 |
-
<
|
142 |
-
|
143 |
-
|
144 |
-
|
145 |
-
|
146 |
-
|
147 |
-
|
148 |
-
|
149 |
-
|
150 |
-
|
151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
152 |
<div className="flex-1 min-w-[200px]">
|
153 |
-
<
|
154 |
-
|
155 |
-
|
156 |
-
|
157 |
-
|
158 |
-
|
159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
160 |
</div>
|
161 |
{showLogs && (
|
162 |
<div className="flex items-center gap-2 flex-nowrap">
|
163 |
-
<button
|
164 |
onClick={handleExportLogs}
|
165 |
-
className={classNames(
|
166 |
-
|
167 |
-
|
168 |
-
'hover:bg-bolt-elements-button-primary-backgroundHover',
|
169 |
-
'text-bolt-elements-button-primary-text',
|
170 |
-
)}
|
171 |
>
|
|
|
172 |
Export Logs
|
173 |
-
</button>
|
174 |
-
<button
|
175 |
onClick={handleClearLogs}
|
176 |
-
className={classNames(
|
177 |
-
|
178 |
-
|
179 |
-
'hover:bg-bolt-elements-button-danger-backgroundHover',
|
180 |
-
'text-bolt-elements-button-danger-text',
|
181 |
-
)}
|
182 |
>
|
|
|
183 |
Clear Logs
|
184 |
-
</button>
|
185 |
</div>
|
186 |
)}
|
187 |
</div>
|
188 |
</div>
|
189 |
|
190 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
191 |
{filteredLogs.length === 0 ? (
|
192 |
-
<div className="
|
193 |
-
|
194 |
-
|
195 |
-
|
196 |
-
|
197 |
-
className="
|
|
|
|
|
|
|
|
|
|
|
|
|
198 |
>
|
199 |
-
|
200 |
-
|
201 |
-
|
202 |
-
|
203 |
-
|
204 |
-
|
205 |
-
|
206 |
-
|
207 |
-
|
208 |
-
|
209 |
-
|
210 |
-
|
211 |
-
|
212 |
-
|
213 |
-
|
214 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
215 |
)}
|
216 |
-
</div>
|
217 |
</div>
|
218 |
);
|
219 |
}
|
|
|
1 |
+
import React, { useCallback, useEffect, useState, useMemo, useRef } from 'react';
|
2 |
import { useSettings } from '~/lib/hooks/useSettings';
|
3 |
import { toast } from 'react-toastify';
|
4 |
import { Switch } from '~/components/ui/Switch';
|
5 |
import { logStore, type LogEntry } from '~/lib/stores/logs';
|
6 |
import { useStore } from '@nanostores/react';
|
7 |
import { classNames } from '~/utils/classNames';
|
8 |
+
import { motion } from 'framer-motion';
|
9 |
+
import { settingsStyles } from '~/components/settings/settings.styles';
|
10 |
|
11 |
export default function EventLogsTab() {
|
12 |
const {} = useSettings();
|
13 |
const showLogs = useStore(logStore.showLogs);
|
14 |
+
const logs = useStore(logStore.logs);
|
15 |
const [logLevel, setLogLevel] = useState<LogEntry['level'] | 'all'>('info');
|
16 |
const [autoScroll, setAutoScroll] = useState(true);
|
17 |
const [searchQuery, setSearchQuery] = useState('');
|
18 |
const [, forceUpdate] = useState({});
|
19 |
+
const logsContainerRef = useRef<HTMLDivElement>(null);
|
20 |
+
const [isScrolledToBottom, setIsScrolledToBottom] = useState(true);
|
21 |
|
22 |
const filteredLogs = useMemo(() => {
|
23 |
+
const allLogs = Object.values(logs);
|
24 |
+
const filtered = allLogs.filter((log) => {
|
25 |
const matchesLevel = !logLevel || log.level === logLevel || logLevel === 'all';
|
26 |
const matchesSearch =
|
27 |
!searchQuery ||
|
|
|
30 |
|
31 |
return matchesLevel && matchesSearch;
|
32 |
});
|
33 |
+
|
34 |
+
return filtered.reverse();
|
35 |
+
}, [logs, logLevel, searchQuery]);
|
36 |
|
37 |
// Effect to initialize showLogs
|
38 |
useEffect(() => {
|
|
|
44 |
logStore.logSystem('Application initialized', {
|
45 |
version: process.env.NEXT_PUBLIC_APP_VERSION,
|
46 |
environment: process.env.NODE_ENV,
|
47 |
+
timestamp: new Date().toISOString(),
|
48 |
+
userAgent: navigator.userAgent,
|
49 |
});
|
50 |
|
51 |
// Debug logs for system state
|
52 |
logStore.logDebug('System configuration loaded', {
|
53 |
runtime: 'Next.js',
|
54 |
+
features: ['AI Chat', 'Event Logging', 'Provider Management', 'Theme Support'],
|
55 |
+
locale: navigator.language,
|
56 |
+
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
57 |
+
});
|
58 |
+
|
59 |
+
// Performance metrics
|
60 |
+
logStore.logSystem('Performance metrics', {
|
61 |
+
deviceMemory: (navigator as any).deviceMemory || 'unknown',
|
62 |
+
hardwareConcurrency: navigator.hardwareConcurrency,
|
63 |
+
connectionType: (navigator as any).connection?.effectiveType || 'unknown',
|
64 |
+
});
|
65 |
+
|
66 |
+
// Provider status
|
67 |
+
logStore.logProvider('Provider status check', {
|
68 |
+
availableProviders: ['OpenAI', 'Anthropic', 'Mistral', 'Ollama'],
|
69 |
+
defaultProvider: 'OpenAI',
|
70 |
+
status: 'operational',
|
71 |
+
});
|
72 |
+
|
73 |
+
// Theme and accessibility
|
74 |
+
logStore.logSystem('User preferences loaded', {
|
75 |
+
theme: document.documentElement.dataset.theme || 'system',
|
76 |
+
prefersReducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
77 |
+
prefersDarkMode: window.matchMedia('(prefers-color-scheme: dark)').matches,
|
78 |
});
|
79 |
|
80 |
// Warning logs for potential issues
|
81 |
logStore.logWarning('Resource usage threshold approaching', {
|
82 |
memoryUsage: '75%',
|
83 |
cpuLoad: '60%',
|
84 |
+
timestamp: new Date().toISOString(),
|
85 |
+
});
|
86 |
+
|
87 |
+
// Security checks
|
88 |
+
logStore.logSystem('Security status', {
|
89 |
+
httpsEnabled: window.location.protocol === 'https:',
|
90 |
+
cookiesEnabled: navigator.cookieEnabled,
|
91 |
+
storageQuota: 'checking...',
|
92 |
});
|
93 |
|
94 |
// Error logs with detailed context
|
|
|
96 |
endpoint: '/api/chat',
|
97 |
retryCount: 3,
|
98 |
lastAttempt: new Date().toISOString(),
|
99 |
+
statusCode: 408,
|
100 |
});
|
101 |
+
|
102 |
+
// Debug logs for development
|
103 |
+
if (process.env.NODE_ENV === 'development') {
|
104 |
+
logStore.logDebug('Development mode active', {
|
105 |
+
debugFlags: true,
|
106 |
+
mockServices: false,
|
107 |
+
apiEndpoint: 'local',
|
108 |
+
});
|
109 |
+
}
|
110 |
+
}, []);
|
111 |
+
|
112 |
+
// Scroll handling
|
113 |
+
useEffect(() => {
|
114 |
+
const container = logsContainerRef.current;
|
115 |
+
|
116 |
+
if (!container) {
|
117 |
+
return undefined;
|
118 |
+
}
|
119 |
+
|
120 |
+
const handleScroll = () => {
|
121 |
+
const { scrollTop, scrollHeight, clientHeight } = container;
|
122 |
+
const isBottom = Math.abs(scrollHeight - clientHeight - scrollTop) < 10;
|
123 |
+
setIsScrolledToBottom(isBottom);
|
124 |
+
};
|
125 |
+
|
126 |
+
container.addEventListener('scroll', handleScroll);
|
127 |
+
|
128 |
+
const cleanup = () => {
|
129 |
+
container.removeEventListener('scroll', handleScroll);
|
130 |
+
};
|
131 |
+
|
132 |
+
return cleanup;
|
133 |
}, []);
|
134 |
|
135 |
+
// Auto-scroll effect
|
136 |
useEffect(() => {
|
137 |
+
const container = logsContainerRef.current;
|
138 |
|
139 |
+
if (container && (autoScroll || isScrolledToBottom)) {
|
140 |
+
container.scrollTop = 0;
|
141 |
}
|
142 |
+
}, [filteredLogs, autoScroll, isScrolledToBottom]);
|
143 |
|
144 |
const handleClearLogs = useCallback(() => {
|
145 |
if (confirm('Are you sure you want to clear all logs?')) {
|
|
|
177 |
}
|
178 |
}, []);
|
179 |
|
180 |
+
const getLevelIcon = (level: LogEntry['level']): string => {
|
181 |
+
switch (level) {
|
182 |
+
case 'info':
|
183 |
+
return 'i-ph:info';
|
184 |
+
case 'warning':
|
185 |
+
return 'i-ph:warning';
|
186 |
+
case 'error':
|
187 |
+
return 'i-ph:x-circle';
|
188 |
+
case 'debug':
|
189 |
+
return 'i-ph:bug';
|
190 |
+
default:
|
191 |
+
return 'i-ph:circle';
|
192 |
+
}
|
193 |
+
};
|
194 |
+
|
195 |
const getLevelColor = (level: LogEntry['level']) => {
|
196 |
switch (level) {
|
197 |
case 'info':
|
198 |
+
return 'text-[#1389FD] dark:text-[#1389FD]';
|
199 |
case 'warning':
|
200 |
+
return 'text-[#FFDB6C] dark:text-[#FFDB6C]';
|
201 |
case 'error':
|
202 |
+
return 'text-[#EE4744] dark:text-[#EE4744]';
|
203 |
case 'debug':
|
204 |
+
return 'text-[#77828D] dark:text-[#77828D]';
|
205 |
default:
|
206 |
return 'text-bolt-elements-textPrimary';
|
207 |
}
|
208 |
};
|
209 |
|
210 |
return (
|
211 |
+
<div className="space-y-4">
|
212 |
+
<div className="flex flex-col space-y-4">
|
213 |
{/* Title and Toggles Row */}
|
214 |
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
215 |
+
<div className="flex items-center gap-2">
|
216 |
+
<div className="i-ph:list-bullets text-xl text-purple-500" />
|
217 |
+
<div>
|
218 |
+
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">Event Logs</h3>
|
219 |
+
<p className="text-sm text-bolt-elements-textSecondary">Track system events and debug information</p>
|
220 |
+
</div>
|
221 |
+
</div>
|
222 |
<div className="flex flex-wrap items-center gap-4">
|
223 |
+
<div className="flex items-center gap-2">
|
224 |
+
<div className="i-ph:eye text-bolt-elements-textSecondary" />
|
225 |
<span className="text-sm text-bolt-elements-textSecondary whitespace-nowrap">Show Actions</span>
|
226 |
<Switch checked={showLogs} onCheckedChange={(checked) => logStore.showLogs.set(checked)} />
|
227 |
</div>
|
228 |
+
<div className="flex items-center gap-2">
|
229 |
+
<div className="i-ph:arrow-clockwise text-bolt-elements-textSecondary" />
|
230 |
<span className="text-sm text-bolt-elements-textSecondary whitespace-nowrap">Auto-scroll</span>
|
231 |
<Switch checked={autoScroll} onCheckedChange={setAutoScroll} />
|
232 |
</div>
|
|
|
234 |
</div>
|
235 |
|
236 |
{/* Controls Row */}
|
237 |
+
<div className="flex flex-wrap items-center gap-4">
|
238 |
+
<div className="flex-1 min-w-[150px] max-w-[200px]">
|
239 |
+
<div className="relative group">
|
240 |
+
<select
|
241 |
+
value={logLevel}
|
242 |
+
onChange={(e) => setLogLevel(e.target.value as LogEntry['level'])}
|
243 |
+
className={classNames(
|
244 |
+
'w-full pl-9 pr-3 py-2 rounded-lg',
|
245 |
+
'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor',
|
246 |
+
'text-sm text-bolt-elements-textPrimary',
|
247 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
|
248 |
+
'group-hover:border-purple-500/30',
|
249 |
+
'transition-all duration-200',
|
250 |
+
)}
|
251 |
+
>
|
252 |
+
<option value="all">All Levels</option>
|
253 |
+
<option value="info">Info</option>
|
254 |
+
<option value="warning">Warning</option>
|
255 |
+
<option value="error">Error</option>
|
256 |
+
<option value="debug">Debug</option>
|
257 |
+
</select>
|
258 |
+
<div className="i-ph:funnel absolute left-3 top-1/2 -translate-y-1/2 text-bolt-elements-textSecondary group-hover:text-purple-500 transition-colors" />
|
259 |
+
</div>
|
260 |
+
</div>
|
261 |
<div className="flex-1 min-w-[200px]">
|
262 |
+
<div className="relative group">
|
263 |
+
<input
|
264 |
+
type="text"
|
265 |
+
placeholder="Search logs..."
|
266 |
+
value={searchQuery}
|
267 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
268 |
+
className={classNames(
|
269 |
+
'w-full pl-9 pr-3 py-2 rounded-lg',
|
270 |
+
'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor',
|
271 |
+
'text-sm text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
272 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
|
273 |
+
'group-hover:border-purple-500/30',
|
274 |
+
'transition-all duration-200',
|
275 |
+
)}
|
276 |
+
/>
|
277 |
+
<div className="i-ph:magnifying-glass absolute left-3 top-1/2 -translate-y-1/2 text-bolt-elements-textSecondary group-hover:text-purple-500 transition-colors" />
|
278 |
+
</div>
|
279 |
</div>
|
280 |
{showLogs && (
|
281 |
<div className="flex items-center gap-2 flex-nowrap">
|
282 |
+
<motion.button
|
283 |
onClick={handleExportLogs}
|
284 |
+
className={classNames(settingsStyles.button.base, settingsStyles.button.primary, 'group')}
|
285 |
+
whileHover={{ scale: 1.02 }}
|
286 |
+
whileTap={{ scale: 0.98 }}
|
|
|
|
|
|
|
287 |
>
|
288 |
+
<div className="i-ph:download-simple group-hover:scale-110 transition-transform" />
|
289 |
Export Logs
|
290 |
+
</motion.button>
|
291 |
+
<motion.button
|
292 |
onClick={handleClearLogs}
|
293 |
+
className={classNames(settingsStyles.button.base, settingsStyles.button.danger, 'group')}
|
294 |
+
whileHover={{ scale: 1.02 }}
|
295 |
+
whileTap={{ scale: 0.98 }}
|
|
|
|
|
|
|
296 |
>
|
297 |
+
<div className="i-ph:trash group-hover:scale-110 transition-transform" />
|
298 |
Clear Logs
|
299 |
+
</motion.button>
|
300 |
</div>
|
301 |
)}
|
302 |
</div>
|
303 |
</div>
|
304 |
|
305 |
+
<motion.div
|
306 |
+
ref={logsContainerRef}
|
307 |
+
className={classNames(
|
308 |
+
settingsStyles.card,
|
309 |
+
'h-[calc(100vh-250px)] min-h-[400px] overflow-y-auto logs-container',
|
310 |
+
'scrollbar-thin scrollbar-thumb-bolt-elements-borderColor scrollbar-track-transparent hover:scrollbar-thumb-purple-500/30',
|
311 |
+
)}
|
312 |
+
initial={{ opacity: 0, y: 20 }}
|
313 |
+
animate={{ opacity: 1, y: 0 }}
|
314 |
+
>
|
315 |
{filteredLogs.length === 0 ? (
|
316 |
+
<div className="flex flex-col items-center justify-center h-full text-center p-8">
|
317 |
+
<motion.div
|
318 |
+
initial={{ scale: 0.8, opacity: 0 }}
|
319 |
+
animate={{ scale: 1, opacity: 1 }}
|
320 |
+
transition={{ type: 'spring', duration: 0.5 }}
|
321 |
+
className="i-ph:clipboard-text text-6xl text-bolt-elements-textSecondary mb-4"
|
322 |
+
/>
|
323 |
+
<motion.p
|
324 |
+
initial={{ y: 10, opacity: 0 }}
|
325 |
+
animate={{ y: 0, opacity: 1 }}
|
326 |
+
transition={{ delay: 0.2 }}
|
327 |
+
className="text-bolt-elements-textSecondary"
|
328 |
>
|
329 |
+
No logs found
|
330 |
+
</motion.p>
|
331 |
+
</div>
|
332 |
+
) : (
|
333 |
+
<div className="divide-y divide-bolt-elements-borderColor">
|
334 |
+
{filteredLogs.map((log, index) => (
|
335 |
+
<motion.div
|
336 |
+
key={index}
|
337 |
+
className={classNames(
|
338 |
+
'p-4 font-mono hover:bg-bolt-elements-background-depth-3 transition-colors duration-200',
|
339 |
+
{ 'border-t border-bolt-elements-borderColor': index === 0 },
|
340 |
+
)}
|
341 |
+
initial={{ opacity: 0, y: 20 }}
|
342 |
+
animate={{ opacity: 1, y: 0 }}
|
343 |
+
transition={{ delay: index * 0.03 }}
|
344 |
+
>
|
345 |
+
<div className="flex items-start gap-3">
|
346 |
+
<div
|
347 |
+
className={classNames(
|
348 |
+
getLevelIcon(log.level),
|
349 |
+
getLevelColor(log.level),
|
350 |
+
'mt-1 flex-shrink-0 text-lg',
|
351 |
+
)}
|
352 |
+
/>
|
353 |
+
<div className="flex-1 min-w-0">
|
354 |
+
<div className="flex items-center gap-2 flex-wrap">
|
355 |
+
<span
|
356 |
+
className={classNames(
|
357 |
+
'font-bold whitespace-nowrap px-2 py-0.5 rounded-full text-xs',
|
358 |
+
{
|
359 |
+
'bg-blue-500/10': log.level === 'info',
|
360 |
+
'bg-yellow-500/10': log.level === 'warning',
|
361 |
+
'bg-red-500/10': log.level === 'error',
|
362 |
+
'bg-bolt-elements-textSecondary/10': log.level === 'debug',
|
363 |
+
},
|
364 |
+
getLevelColor(log.level),
|
365 |
+
)}
|
366 |
+
>
|
367 |
+
{log.level.toUpperCase()}
|
368 |
+
</span>
|
369 |
+
<span className="text-bolt-elements-textSecondary whitespace-nowrap text-xs">
|
370 |
+
{new Date(log.timestamp).toLocaleString()}
|
371 |
+
</span>
|
372 |
+
<span className="text-bolt-elements-textPrimary break-all">{log.message}</span>
|
373 |
+
</div>
|
374 |
+
{log.details && (
|
375 |
+
<motion.pre
|
376 |
+
initial={{ opacity: 0, height: 0 }}
|
377 |
+
animate={{ opacity: 1, height: 'auto' }}
|
378 |
+
transition={{ duration: 0.2 }}
|
379 |
+
className={classNames(
|
380 |
+
'mt-2 text-xs',
|
381 |
+
'overflow-x-auto whitespace-pre-wrap break-all',
|
382 |
+
'bg-[#1A1A1A] dark:bg-[#0A0A0A] rounded-md p-3',
|
383 |
+
'border border-[#333333] dark:border-[#1A1A1A]',
|
384 |
+
'text-[#666666] dark:text-[#999999]',
|
385 |
+
)}
|
386 |
+
>
|
387 |
+
{JSON.stringify(log.details, null, 2)}
|
388 |
+
</motion.pre>
|
389 |
+
)}
|
390 |
+
</div>
|
391 |
+
</div>
|
392 |
+
</motion.div>
|
393 |
+
))}
|
394 |
+
</div>
|
395 |
)}
|
396 |
+
</motion.div>
|
397 |
</div>
|
398 |
);
|
399 |
}
|
@@ -1,7 +1,22 @@
|
|
1 |
-
import React from 'react';
|
2 |
import { Switch } from '~/components/ui/Switch';
|
3 |
import { PromptLibrary } from '~/lib/common/prompt-library';
|
4 |
import { useSettings } from '~/lib/hooks/useSettings';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
5 |
|
6 |
export default function FeaturesTab() {
|
7 |
const {
|
@@ -20,88 +35,266 @@ export default function FeaturesTab() {
|
|
20 |
contextOptimizationEnabled,
|
21 |
} = useSettings();
|
22 |
|
|
|
|
|
|
|
23 |
const handleToggle = (enabled: boolean) => {
|
24 |
enableDebugMode(enabled);
|
25 |
enableEventLogs(enabled);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
26 |
};
|
27 |
|
28 |
return (
|
29 |
-
<div className="
|
30 |
-
<div
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
52 |
</div>
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
61 |
</div>
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
|
|
|
|
|
|
|
|
66 |
/>
|
67 |
-
</div>
|
68 |
-
|
69 |
-
</div>
|
70 |
|
71 |
-
<div
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
value={promptId}
|
94 |
-
onChange={(e) => setPromptId(e.target.value)}
|
95 |
-
className="flex-1 p-2 ml-auto rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus transition-all text-sm min-w-[100px]"
|
96 |
>
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
103 |
</div>
|
104 |
-
</div>
|
105 |
</div>
|
106 |
);
|
107 |
}
|
|
|
1 |
+
import React, { useState } from 'react';
|
2 |
import { Switch } from '~/components/ui/Switch';
|
3 |
import { PromptLibrary } from '~/lib/common/prompt-library';
|
4 |
import { useSettings } from '~/lib/hooks/useSettings';
|
5 |
+
import { motion, AnimatePresence } from 'framer-motion';
|
6 |
+
import { classNames } from '~/utils/classNames';
|
7 |
+
import { settingsStyles } from '~/components/settings/settings.styles';
|
8 |
+
import { toast } from 'react-toastify';
|
9 |
+
|
10 |
+
interface FeatureToggle {
|
11 |
+
id: string;
|
12 |
+
title: string;
|
13 |
+
description: string;
|
14 |
+
icon: string;
|
15 |
+
enabled: boolean;
|
16 |
+
beta?: boolean;
|
17 |
+
experimental?: boolean;
|
18 |
+
tooltip?: string;
|
19 |
+
}
|
20 |
|
21 |
export default function FeaturesTab() {
|
22 |
const {
|
|
|
35 |
contextOptimizationEnabled,
|
36 |
} = useSettings();
|
37 |
|
38 |
+
const [hoveredFeature, setHoveredFeature] = useState<string | null>(null);
|
39 |
+
const [expandedFeature, setExpandedFeature] = useState<string | null>(null);
|
40 |
+
|
41 |
const handleToggle = (enabled: boolean) => {
|
42 |
enableDebugMode(enabled);
|
43 |
enableEventLogs(enabled);
|
44 |
+
toast.success(`Debug features ${enabled ? 'enabled' : 'disabled'}`);
|
45 |
+
};
|
46 |
+
|
47 |
+
const features: FeatureToggle[] = [
|
48 |
+
{
|
49 |
+
id: 'debug',
|
50 |
+
title: 'Debug Features',
|
51 |
+
description: 'Enable debugging tools and detailed logging',
|
52 |
+
icon: 'i-ph:bug',
|
53 |
+
enabled: debug,
|
54 |
+
experimental: true,
|
55 |
+
tooltip: 'Access advanced debugging tools and view detailed system logs',
|
56 |
+
},
|
57 |
+
{
|
58 |
+
id: 'latestBranch',
|
59 |
+
title: 'Use Main Branch',
|
60 |
+
description: 'Check for updates against the main branch instead of stable',
|
61 |
+
icon: 'i-ph:git-branch',
|
62 |
+
enabled: isLatestBranch,
|
63 |
+
beta: true,
|
64 |
+
tooltip: 'Get the latest features and improvements before they are officially released',
|
65 |
+
},
|
66 |
+
{
|
67 |
+
id: 'autoTemplate',
|
68 |
+
title: 'Auto Select Code Template',
|
69 |
+
description: 'Let Bolt select the best starter template for your project',
|
70 |
+
icon: 'i-ph:magic-wand',
|
71 |
+
enabled: autoSelectTemplate,
|
72 |
+
tooltip: 'Automatically choose the most suitable template based on your project type',
|
73 |
+
},
|
74 |
+
{
|
75 |
+
id: 'contextOptimization',
|
76 |
+
title: 'Context Optimization',
|
77 |
+
description: 'Optimize chat context by redacting file contents and using system prompts',
|
78 |
+
icon: 'i-ph:arrows-in',
|
79 |
+
enabled: contextOptimizationEnabled,
|
80 |
+
tooltip: 'Improve AI responses by optimizing the context window and system prompts',
|
81 |
+
},
|
82 |
+
{
|
83 |
+
id: 'experimentalProviders',
|
84 |
+
title: 'Experimental Providers',
|
85 |
+
description: 'Enable experimental providers like Ollama, LMStudio, and OpenAILike',
|
86 |
+
icon: 'i-ph:robot',
|
87 |
+
enabled: isLocalModel,
|
88 |
+
experimental: true,
|
89 |
+
tooltip: 'Try out new AI providers and models in development',
|
90 |
+
},
|
91 |
+
];
|
92 |
+
|
93 |
+
const handleToggleFeature = (featureId: string, enabled: boolean) => {
|
94 |
+
switch (featureId) {
|
95 |
+
case 'debug':
|
96 |
+
handleToggle(enabled);
|
97 |
+
break;
|
98 |
+
case 'latestBranch':
|
99 |
+
enableLatestBranch(enabled);
|
100 |
+
toast.success(`Main branch updates ${enabled ? 'enabled' : 'disabled'}`);
|
101 |
+
break;
|
102 |
+
case 'autoTemplate':
|
103 |
+
setAutoSelectTemplate(enabled);
|
104 |
+
toast.success(`Auto template selection ${enabled ? 'enabled' : 'disabled'}`);
|
105 |
+
break;
|
106 |
+
case 'contextOptimization':
|
107 |
+
enableContextOptimization(enabled);
|
108 |
+
toast.success(`Context optimization ${enabled ? 'enabled' : 'disabled'}`);
|
109 |
+
break;
|
110 |
+
case 'experimentalProviders':
|
111 |
+
enableLocalModels(enabled);
|
112 |
+
toast.success(`Experimental providers ${enabled ? 'enabled' : 'disabled'}`);
|
113 |
+
break;
|
114 |
+
}
|
115 |
};
|
116 |
|
117 |
return (
|
118 |
+
<div className="space-y-6">
|
119 |
+
<motion.div
|
120 |
+
className="flex items-center gap-2"
|
121 |
+
initial={{ opacity: 0, y: -20 }}
|
122 |
+
animate={{ opacity: 1, y: 0 }}
|
123 |
+
transition={{ duration: 0.3 }}
|
124 |
+
>
|
125 |
+
<div className="i-ph:puzzle-piece text-xl text-purple-500" />
|
126 |
+
<div>
|
127 |
+
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">Features</h3>
|
128 |
+
<p className="text-sm text-bolt-elements-textSecondary">Customize your Bolt experience</p>
|
129 |
+
</div>
|
130 |
+
</motion.div>
|
131 |
+
|
132 |
+
<motion.div
|
133 |
+
className="grid grid-cols-1 md:grid-cols-2 gap-4"
|
134 |
+
initial={{ opacity: 0, y: 20 }}
|
135 |
+
animate={{ opacity: 1, y: 0 }}
|
136 |
+
transition={{ duration: 0.3 }}
|
137 |
+
>
|
138 |
+
{features.map((feature, index) => (
|
139 |
+
<motion.div
|
140 |
+
key={feature.id}
|
141 |
+
className={classNames(
|
142 |
+
settingsStyles.card,
|
143 |
+
'bg-bolt-elements-background-depth-2',
|
144 |
+
'hover:bg-bolt-elements-background-depth-3',
|
145 |
+
'transition-colors duration-200',
|
146 |
+
'relative overflow-hidden group cursor-pointer',
|
147 |
+
)}
|
148 |
+
initial={{ opacity: 0, y: 20 }}
|
149 |
+
animate={{ opacity: 1, y: 0 }}
|
150 |
+
transition={{ delay: index * 0.1 }}
|
151 |
+
onHoverStart={() => setHoveredFeature(feature.id)}
|
152 |
+
onHoverEnd={() => setHoveredFeature(null)}
|
153 |
+
onClick={() => setExpandedFeature(expandedFeature === feature.id ? null : feature.id)}
|
154 |
+
>
|
155 |
+
<AnimatePresence>
|
156 |
+
{hoveredFeature === feature.id && feature.tooltip && (
|
157 |
+
<motion.div
|
158 |
+
className={classNames(
|
159 |
+
'absolute -top-12 left-1/2 transform -translate-x-1/2',
|
160 |
+
'px-3 py-2 rounded-lg text-xs',
|
161 |
+
'bg-bolt-elements-background-depth-4 text-bolt-elements-textPrimary',
|
162 |
+
'border border-bolt-elements-borderColor',
|
163 |
+
'whitespace-nowrap z-10',
|
164 |
+
)}
|
165 |
+
initial={{ opacity: 0, y: 10 }}
|
166 |
+
animate={{ opacity: 1, y: 0 }}
|
167 |
+
exit={{ opacity: 0, y: 10 }}
|
168 |
+
>
|
169 |
+
{feature.tooltip}
|
170 |
+
<div className="absolute -bottom-1 left-1/2 transform -translate-x-1/2 rotate-45 w-2 h-2 bg-bolt-elements-background-depth-4 border-r border-b border-bolt-elements-borderColor" />
|
171 |
+
</motion.div>
|
172 |
+
)}
|
173 |
+
</AnimatePresence>
|
174 |
+
|
175 |
+
<div className="absolute top-0 right-0 p-2 flex gap-1">
|
176 |
+
{feature.beta && (
|
177 |
+
<motion.span
|
178 |
+
className="px-2 py-0.5 text-xs rounded-full bg-blue-500/10 text-blue-500 font-medium"
|
179 |
+
whileHover={{ scale: 1.05 }}
|
180 |
+
whileTap={{ scale: 0.95 }}
|
181 |
+
>
|
182 |
+
Beta
|
183 |
+
</motion.span>
|
184 |
+
)}
|
185 |
+
{feature.experimental && (
|
186 |
+
<motion.span
|
187 |
+
className="px-2 py-0.5 text-xs rounded-full bg-purple-500/10 text-purple-500 font-medium"
|
188 |
+
whileHover={{ scale: 1.05 }}
|
189 |
+
whileTap={{ scale: 0.95 }}
|
190 |
+
>
|
191 |
+
Experimental
|
192 |
+
</motion.span>
|
193 |
+
)}
|
194 |
</div>
|
195 |
+
|
196 |
+
<div className="flex items-start gap-4 p-4">
|
197 |
+
<motion.div
|
198 |
+
className={classNames(
|
199 |
+
'p-2 rounded-lg text-xl',
|
200 |
+
'bg-bolt-elements-background-depth-3 group-hover:bg-bolt-elements-background-depth-4',
|
201 |
+
'transition-colors duration-200',
|
202 |
+
)}
|
203 |
+
whileHover={{ scale: 1.1 }}
|
204 |
+
whileTap={{ scale: 0.9 }}
|
205 |
+
>
|
206 |
+
<div className={classNames(feature.icon, 'text-purple-500')} />
|
207 |
+
</motion.div>
|
208 |
+
|
209 |
+
<div className="flex-1 min-w-0">
|
210 |
+
<div className="flex items-center justify-between gap-4">
|
211 |
+
<div>
|
212 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-purple-500 transition-colors">
|
213 |
+
{feature.title}
|
214 |
+
</h4>
|
215 |
+
<p className="text-xs text-bolt-elements-textSecondary mt-0.5">{feature.description}</p>
|
216 |
+
</div>
|
217 |
+
<Switch
|
218 |
+
checked={feature.enabled}
|
219 |
+
onCheckedChange={(checked) => handleToggleFeature(feature.id, checked)}
|
220 |
+
/>
|
221 |
+
</div>
|
222 |
+
</div>
|
223 |
</div>
|
224 |
+
|
225 |
+
<motion.div
|
226 |
+
className="absolute inset-0 border-2 border-purple-500/0 rounded-lg pointer-events-none"
|
227 |
+
animate={{
|
228 |
+
borderColor: feature.enabled ? 'rgba(168, 85, 247, 0.2)' : 'rgba(168, 85, 247, 0)',
|
229 |
+
scale: feature.enabled ? 1 : 0.98,
|
230 |
+
}}
|
231 |
+
transition={{ duration: 0.2 }}
|
232 |
/>
|
233 |
+
</motion.div>
|
234 |
+
))}
|
235 |
+
</motion.div>
|
236 |
|
237 |
+
<motion.div
|
238 |
+
className={classNames(
|
239 |
+
settingsStyles.card,
|
240 |
+
'bg-bolt-elements-background-depth-2',
|
241 |
+
'hover:bg-bolt-elements-background-depth-3',
|
242 |
+
'transition-all duration-200',
|
243 |
+
'group',
|
244 |
+
)}
|
245 |
+
initial={{ opacity: 0, y: 20 }}
|
246 |
+
animate={{ opacity: 1, y: 0 }}
|
247 |
+
transition={{ delay: 0.6 }}
|
248 |
+
whileHover={{ scale: 1.01 }}
|
249 |
+
>
|
250 |
+
<div className="flex items-start gap-4 p-4">
|
251 |
+
<motion.div
|
252 |
+
className={classNames(
|
253 |
+
'p-2 rounded-lg text-xl',
|
254 |
+
'bg-bolt-elements-background-depth-3 group-hover:bg-bolt-elements-background-depth-4',
|
255 |
+
'transition-colors duration-200',
|
256 |
+
)}
|
257 |
+
whileHover={{ scale: 1.1 }}
|
258 |
+
whileTap={{ scale: 0.9 }}
|
|
|
|
|
|
|
259 |
>
|
260 |
+
<div className="i-ph:book text-purple-500" />
|
261 |
+
</motion.div>
|
262 |
+
|
263 |
+
<div className="flex-1 min-w-0">
|
264 |
+
<div className="flex items-center justify-between gap-4">
|
265 |
+
<div>
|
266 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-purple-500 transition-colors">
|
267 |
+
Prompt Library
|
268 |
+
</h4>
|
269 |
+
<p className="text-xs text-bolt-elements-textSecondary mt-0.5">
|
270 |
+
Choose a prompt from the library to use as the system prompt
|
271 |
+
</p>
|
272 |
+
</div>
|
273 |
+
<select
|
274 |
+
value={promptId}
|
275 |
+
onChange={(e) => {
|
276 |
+
setPromptId(e.target.value);
|
277 |
+
toast.success('Prompt template updated');
|
278 |
+
}}
|
279 |
+
className={classNames(
|
280 |
+
'p-2 rounded-lg text-sm min-w-[200px]',
|
281 |
+
'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
|
282 |
+
'text-bolt-elements-textPrimary',
|
283 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
|
284 |
+
'group-hover:border-purple-500/30',
|
285 |
+
'transition-all duration-200',
|
286 |
+
)}
|
287 |
+
>
|
288 |
+
{PromptLibrary.getList().map((x) => (
|
289 |
+
<option key={x.id} value={x.id}>
|
290 |
+
{x.label}
|
291 |
+
</option>
|
292 |
+
))}
|
293 |
+
</select>
|
294 |
+
</div>
|
295 |
+
</div>
|
296 |
</div>
|
297 |
+
</motion.div>
|
298 |
</div>
|
299 |
);
|
300 |
}
|
@@ -0,0 +1,399 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useState, useRef, useEffect } from 'react';
|
2 |
+
import { AnimatePresence } from 'framer-motion';
|
3 |
+
import { toast } from 'react-toastify';
|
4 |
+
import { classNames } from '~/utils/classNames';
|
5 |
+
import { Switch } from '~/components/ui/Switch';
|
6 |
+
import type { UserProfile } from '~/components/settings/settings.types';
|
7 |
+
import { themeStore, kTheme } from '~/lib/stores/theme';
|
8 |
+
import { motion } from 'framer-motion';
|
9 |
+
|
10 |
+
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
11 |
+
const ALLOWED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/gif'];
|
12 |
+
const MIN_PASSWORD_LENGTH = 8;
|
13 |
+
|
14 |
+
export default function ProfileTab() {
|
15 |
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
16 |
+
const [isLoading, setIsLoading] = useState(false);
|
17 |
+
const [showPassword, setShowPassword] = useState(false);
|
18 |
+
const [currentTimezone, setCurrentTimezone] = useState('');
|
19 |
+
const [profile, setProfile] = useState<UserProfile>(() => {
|
20 |
+
const saved = localStorage.getItem('bolt_user_profile');
|
21 |
+
return saved
|
22 |
+
? JSON.parse(saved)
|
23 |
+
: {
|
24 |
+
name: '',
|
25 |
+
email: '',
|
26 |
+
theme: 'system',
|
27 |
+
notifications: true,
|
28 |
+
language: 'en',
|
29 |
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
30 |
+
password: '',
|
31 |
+
bio: '',
|
32 |
+
};
|
33 |
+
});
|
34 |
+
|
35 |
+
useEffect(() => {
|
36 |
+
setCurrentTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
|
37 |
+
}, []);
|
38 |
+
|
39 |
+
// Apply theme when profile changes
|
40 |
+
useEffect(() => {
|
41 |
+
if (profile.theme === 'system') {
|
42 |
+
// Remove theme override
|
43 |
+
localStorage.removeItem(kTheme);
|
44 |
+
|
45 |
+
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
46 |
+
document.querySelector('html')?.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
|
47 |
+
} else {
|
48 |
+
// Set specific theme
|
49 |
+
localStorage.setItem(kTheme, profile.theme);
|
50 |
+
document.querySelector('html')?.setAttribute('data-theme', profile.theme);
|
51 |
+
themeStore.set(profile.theme);
|
52 |
+
}
|
53 |
+
}, [profile.theme]);
|
54 |
+
|
55 |
+
const handleAvatarUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
56 |
+
const file = event.target.files?.[0];
|
57 |
+
|
58 |
+
if (!file) {
|
59 |
+
return;
|
60 |
+
}
|
61 |
+
|
62 |
+
if (!ALLOWED_FILE_TYPES.includes(file.type)) {
|
63 |
+
toast.error('Please upload a valid image file (JPEG, PNG, or GIF)');
|
64 |
+
return;
|
65 |
+
}
|
66 |
+
|
67 |
+
if (file.size > MAX_FILE_SIZE) {
|
68 |
+
toast.error('File size must be less than 5MB');
|
69 |
+
return;
|
70 |
+
}
|
71 |
+
|
72 |
+
setIsLoading(true);
|
73 |
+
|
74 |
+
try {
|
75 |
+
const reader = new FileReader();
|
76 |
+
|
77 |
+
reader.onloadend = () => {
|
78 |
+
setProfile((prev) => ({ ...prev, avatar: reader.result as string }));
|
79 |
+
setIsLoading(false);
|
80 |
+
};
|
81 |
+
reader.readAsDataURL(file);
|
82 |
+
} catch (error) {
|
83 |
+
console.error('Error uploading avatar:', error);
|
84 |
+
toast.error('Failed to upload avatar');
|
85 |
+
setIsLoading(false);
|
86 |
+
}
|
87 |
+
};
|
88 |
+
|
89 |
+
const handleSave = async () => {
|
90 |
+
if (!profile.name.trim()) {
|
91 |
+
toast.error('Name is required');
|
92 |
+
return;
|
93 |
+
}
|
94 |
+
|
95 |
+
if (!profile.email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(profile.email)) {
|
96 |
+
toast.error('Please enter a valid email address');
|
97 |
+
return;
|
98 |
+
}
|
99 |
+
|
100 |
+
if (profile.password && profile.password.length < MIN_PASSWORD_LENGTH) {
|
101 |
+
toast.error(`Password must be at least ${MIN_PASSWORD_LENGTH} characters long`);
|
102 |
+
return;
|
103 |
+
}
|
104 |
+
|
105 |
+
setIsLoading(true);
|
106 |
+
|
107 |
+
try {
|
108 |
+
localStorage.setItem('bolt_user_profile', JSON.stringify(profile));
|
109 |
+
toast.success('Profile settings saved successfully');
|
110 |
+
} catch (error) {
|
111 |
+
console.error('Error saving profile:', error);
|
112 |
+
toast.error('Failed to save profile settings');
|
113 |
+
} finally {
|
114 |
+
setIsLoading(false);
|
115 |
+
}
|
116 |
+
};
|
117 |
+
|
118 |
+
return (
|
119 |
+
<div className="space-y-4">
|
120 |
+
<div className="grid grid-cols-1 gap-4">
|
121 |
+
{/* Profile Information */}
|
122 |
+
<motion.div
|
123 |
+
className="bg-white dark:bg-[#0A0A0A] rounded-lg shadow-sm dark:shadow-none"
|
124 |
+
initial={{ opacity: 0, y: 20 }}
|
125 |
+
animate={{ opacity: 1, y: 0 }}
|
126 |
+
transition={{ delay: 0.1 }}
|
127 |
+
>
|
128 |
+
<div className="flex items-center gap-2 px-4 pt-4 pb-2">
|
129 |
+
<div className="i-ph:user-circle-fill w-4 h-4 text-purple-500" />
|
130 |
+
<span className="text-sm font-medium text-bolt-elements-textPrimary">Personal Information</span>
|
131 |
+
</div>
|
132 |
+
<div className="flex items-start gap-4 p-4">
|
133 |
+
{/* Avatar */}
|
134 |
+
<div className="relative group">
|
135 |
+
<div className="w-12 h-12 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] flex items-center justify-center overflow-hidden">
|
136 |
+
<AnimatePresence mode="wait">
|
137 |
+
{isLoading ? (
|
138 |
+
<div className="i-ph:spinner-gap-bold animate-spin text-purple-500" />
|
139 |
+
) : profile.avatar ? (
|
140 |
+
<img src={profile.avatar} alt="Profile" className="w-full h-full object-cover" />
|
141 |
+
) : (
|
142 |
+
<div className="i-ph:user-circle-fill text-bolt-elements-textSecondary" />
|
143 |
+
)}
|
144 |
+
</AnimatePresence>
|
145 |
+
</div>
|
146 |
+
<button
|
147 |
+
onClick={() => fileInputRef.current?.click()}
|
148 |
+
disabled={isLoading}
|
149 |
+
className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg"
|
150 |
+
>
|
151 |
+
<div className="i-ph:camera-fill text-white" />
|
152 |
+
</button>
|
153 |
+
<input
|
154 |
+
ref={fileInputRef}
|
155 |
+
type="file"
|
156 |
+
accept={ALLOWED_FILE_TYPES.join(',')}
|
157 |
+
onChange={handleAvatarUpload}
|
158 |
+
className="hidden"
|
159 |
+
/>
|
160 |
+
</div>
|
161 |
+
|
162 |
+
{/* Profile Fields */}
|
163 |
+
<div className="flex-1 space-y-3">
|
164 |
+
<div className="relative">
|
165 |
+
<div className="absolute left-3 top-1/2 -translate-y-1/2">
|
166 |
+
<div className="i-ph:user-fill w-4 h-4 text-bolt-elements-textTertiary" />
|
167 |
+
</div>
|
168 |
+
<input
|
169 |
+
type="text"
|
170 |
+
value={profile.name}
|
171 |
+
onChange={(e) => setProfile((prev) => ({ ...prev, name: e.target.value }))}
|
172 |
+
placeholder="Enter your name"
|
173 |
+
className={classNames(
|
174 |
+
'w-full px-3 py-1.5 rounded-lg text-sm',
|
175 |
+
'pl-10',
|
176 |
+
'bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E5E5E5] dark:border-[#333333]',
|
177 |
+
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
178 |
+
'focus:outline-none focus:ring-1 focus:ring-purple-500',
|
179 |
+
)}
|
180 |
+
/>
|
181 |
+
</div>
|
182 |
+
|
183 |
+
<div className="relative">
|
184 |
+
<div className="absolute left-3 top-1/2 -translate-y-1/2">
|
185 |
+
<div className="i-ph:envelope-fill w-4 h-4 text-bolt-elements-textTertiary" />
|
186 |
+
</div>
|
187 |
+
<input
|
188 |
+
type="email"
|
189 |
+
value={profile.email}
|
190 |
+
onChange={(e) => setProfile((prev) => ({ ...prev, email: e.target.value }))}
|
191 |
+
placeholder="Enter your email"
|
192 |
+
className={classNames(
|
193 |
+
'w-full px-3 py-1.5 rounded-lg text-sm',
|
194 |
+
'pl-10',
|
195 |
+
'bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E5E5E5] dark:border-[#333333]',
|
196 |
+
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
197 |
+
'focus:outline-none focus:ring-1 focus:ring-purple-500',
|
198 |
+
)}
|
199 |
+
/>
|
200 |
+
</div>
|
201 |
+
|
202 |
+
<div className="relative">
|
203 |
+
<input
|
204 |
+
type={showPassword ? 'text' : 'password'}
|
205 |
+
value={profile.password}
|
206 |
+
onChange={(e) => setProfile((prev) => ({ ...prev, password: e.target.value }))}
|
207 |
+
placeholder="Enter new password"
|
208 |
+
className={classNames(
|
209 |
+
'w-full px-3 py-1.5 rounded-lg text-sm',
|
210 |
+
'bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E5E5E5] dark:border-[#333333]',
|
211 |
+
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
212 |
+
'focus:outline-none focus:ring-1 focus:ring-purple-500',
|
213 |
+
)}
|
214 |
+
/>
|
215 |
+
<button
|
216 |
+
type="button"
|
217 |
+
onClick={() => setShowPassword(!showPassword)}
|
218 |
+
className={classNames(
|
219 |
+
'absolute right-3 top-1/2 -translate-y-1/2',
|
220 |
+
'flex items-center justify-center',
|
221 |
+
'w-6 h-6 rounded-md',
|
222 |
+
'text-bolt-elements-textSecondary',
|
223 |
+
'hover:text-bolt-elements-item-contentActive',
|
224 |
+
'hover:bg-bolt-elements-item-backgroundActive',
|
225 |
+
'transition-colors',
|
226 |
+
)}
|
227 |
+
>
|
228 |
+
<div className={classNames(showPassword ? 'i-ph:eye-slash-fill' : 'i-ph:eye-fill', 'w-4 h-4')} />
|
229 |
+
</button>
|
230 |
+
</div>
|
231 |
+
</div>
|
232 |
+
</div>
|
233 |
+
</motion.div>
|
234 |
+
|
235 |
+
{/* Theme & Language */}
|
236 |
+
<motion.div
|
237 |
+
className="bg-white dark:bg-[#0A0A0A] rounded-lg shadow-sm dark:shadow-none p-4 space-y-4"
|
238 |
+
initial={{ opacity: 0, y: 20 }}
|
239 |
+
animate={{ opacity: 1, y: 0 }}
|
240 |
+
transition={{ delay: 0.2 }}
|
241 |
+
>
|
242 |
+
<div className="flex items-center gap-2 mb-4">
|
243 |
+
<div className="i-ph:palette-fill w-4 h-4 text-purple-500" />
|
244 |
+
<span className="text-sm font-medium text-bolt-elements-textPrimary">Appearance</span>
|
245 |
+
</div>
|
246 |
+
|
247 |
+
<div>
|
248 |
+
<div className="flex items-center gap-2 mb-2">
|
249 |
+
<div className="i-ph:paint-brush-fill w-4 h-4 text-bolt-elements-textSecondary" />
|
250 |
+
<label className="block text-sm text-bolt-elements-textSecondary">Theme</label>
|
251 |
+
</div>
|
252 |
+
<div className="flex gap-2">
|
253 |
+
{(['light', 'dark', 'system'] as const).map((theme) => (
|
254 |
+
<button
|
255 |
+
key={theme}
|
256 |
+
onClick={() => setProfile((prev) => ({ ...prev, theme }))}
|
257 |
+
className={classNames(
|
258 |
+
'px-3 py-1.5 rounded-lg text-sm flex items-center gap-2 transition-colors',
|
259 |
+
profile.theme === theme
|
260 |
+
? 'bg-purple-500 text-white hover:bg-purple-600'
|
261 |
+
: 'bg-[#F5F5F5] dark:bg-[#1A1A1A] text-bolt-elements-textSecondary hover:bg-[#E5E5E5] dark:hover:bg-[#252525] hover:text-bolt-elements-textPrimary',
|
262 |
+
)}
|
263 |
+
>
|
264 |
+
<div
|
265 |
+
className={`w-4 h-4 ${
|
266 |
+
theme === 'light'
|
267 |
+
? 'i-ph:sun-fill'
|
268 |
+
: theme === 'dark'
|
269 |
+
? 'i-ph:moon-stars-fill'
|
270 |
+
: 'i-ph:monitor-fill'
|
271 |
+
}`}
|
272 |
+
/>
|
273 |
+
<span className="capitalize">{theme}</span>
|
274 |
+
</button>
|
275 |
+
))}
|
276 |
+
</div>
|
277 |
+
</div>
|
278 |
+
|
279 |
+
<div>
|
280 |
+
<div className="flex items-center gap-2 mb-2">
|
281 |
+
<div className="i-ph:translate-fill w-4 h-4 text-bolt-elements-textSecondary" />
|
282 |
+
<label className="block text-sm text-bolt-elements-textSecondary">Language</label>
|
283 |
+
</div>
|
284 |
+
<select
|
285 |
+
value={profile.language}
|
286 |
+
onChange={(e) => setProfile((prev) => ({ ...prev, language: e.target.value }))}
|
287 |
+
className={classNames(
|
288 |
+
'w-full px-3 py-1.5 rounded-lg text-sm',
|
289 |
+
'bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E5E5E5] dark:border-[#333333]',
|
290 |
+
'text-bolt-elements-textPrimary',
|
291 |
+
'focus:outline-none focus:ring-1 focus:ring-purple-500',
|
292 |
+
)}
|
293 |
+
>
|
294 |
+
<option value="en">English</option>
|
295 |
+
<option value="es">EspaΓ±ol</option>
|
296 |
+
<option value="fr">FranΓ§ais</option>
|
297 |
+
<option value="de">Deutsch</option>
|
298 |
+
<option value="it">Italiano</option>
|
299 |
+
<option value="pt">PortuguΓͺs</option>
|
300 |
+
<option value="ru">Π ΡΡΡΠΊΠΈΠΉ</option>
|
301 |
+
<option value="zh">δΈζ</option>
|
302 |
+
<option value="ja">ζ₯ζ¬θͺ</option>
|
303 |
+
<option value="ko">νκ΅μ΄</option>
|
304 |
+
</select>
|
305 |
+
</div>
|
306 |
+
|
307 |
+
<div>
|
308 |
+
<div className="flex items-center gap-2 mb-2">
|
309 |
+
<div className="i-ph:bell-fill w-4 h-4 text-bolt-elements-textSecondary" />
|
310 |
+
<label className="block text-sm text-bolt-elements-textSecondary">Notifications</label>
|
311 |
+
</div>
|
312 |
+
<div className="flex items-center justify-between">
|
313 |
+
<span className="text-sm text-bolt-elements-textSecondary">
|
314 |
+
{profile.notifications ? 'Notifications are enabled' : 'Notifications are disabled'}
|
315 |
+
</span>
|
316 |
+
<Switch
|
317 |
+
checked={profile.notifications}
|
318 |
+
onCheckedChange={(checked) => setProfile((prev) => ({ ...prev, notifications: checked }))}
|
319 |
+
/>
|
320 |
+
</div>
|
321 |
+
</div>
|
322 |
+
</motion.div>
|
323 |
+
|
324 |
+
{/* Timezone */}
|
325 |
+
<div className="bg-white dark:bg-[#0A0A0A] rounded-lg shadow-sm dark:shadow-none p-4">
|
326 |
+
<div className="flex items-center gap-2 mb-4">
|
327 |
+
<div className="i-ph:clock-fill w-4 h-4 text-purple-500" />
|
328 |
+
<span className="text-sm font-medium text-bolt-elements-textPrimary">Time Settings</span>
|
329 |
+
</div>
|
330 |
+
|
331 |
+
<div className="flex items-center gap-2 mb-2">
|
332 |
+
<div className="i-ph:globe-fill w-4 h-4 text-bolt-elements-textSecondary" />
|
333 |
+
<label className="block text-sm text-bolt-elements-textSecondary">Timezone</label>
|
334 |
+
</div>
|
335 |
+
<div className="flex gap-2">
|
336 |
+
<select
|
337 |
+
value={profile.timezone}
|
338 |
+
onChange={(e) => setProfile((prev) => ({ ...prev, timezone: e.target.value }))}
|
339 |
+
className={classNames(
|
340 |
+
'flex-1 px-3 py-1.5 rounded-lg text-sm',
|
341 |
+
'bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E5E5E5] dark:border-[#333333]',
|
342 |
+
'text-bolt-elements-textPrimary',
|
343 |
+
'focus:outline-none focus:ring-1 focus:ring-purple-500',
|
344 |
+
)}
|
345 |
+
>
|
346 |
+
{Intl.supportedValuesOf('timeZone').map((tz) => (
|
347 |
+
<option key={tz} value={tz}>
|
348 |
+
{tz.replace(/_/g, ' ')}
|
349 |
+
</option>
|
350 |
+
))}
|
351 |
+
</select>
|
352 |
+
<button
|
353 |
+
onClick={() => setProfile((prev) => ({ ...prev, timezone: currentTimezone }))}
|
354 |
+
className={classNames(
|
355 |
+
'px-3 py-1.5 rounded-lg text-sm flex items-center gap-2',
|
356 |
+
'bg-[#F5F5F5] dark:bg-[#1A1A1A] text-bolt-elements-textSecondary',
|
357 |
+
'hover:text-bolt-elements-textPrimary',
|
358 |
+
)}
|
359 |
+
>
|
360 |
+
<div className="i-ph:crosshair-simple-fill" />
|
361 |
+
Auto-detect
|
362 |
+
</button>
|
363 |
+
</div>
|
364 |
+
</div>
|
365 |
+
</div>
|
366 |
+
|
367 |
+
{/* Save Button */}
|
368 |
+
<motion.div
|
369 |
+
className="flex justify-end mt-6"
|
370 |
+
initial={{ opacity: 0, y: 20 }}
|
371 |
+
animate={{ opacity: 1, y: 0 }}
|
372 |
+
transition={{ delay: 0.3 }}
|
373 |
+
>
|
374 |
+
<button
|
375 |
+
onClick={handleSave}
|
376 |
+
disabled={isLoading}
|
377 |
+
className={classNames(
|
378 |
+
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
379 |
+
'bg-purple-500 text-white',
|
380 |
+
'hover:bg-purple-600',
|
381 |
+
'disabled:opacity-50 disabled:cursor-not-allowed',
|
382 |
+
)}
|
383 |
+
>
|
384 |
+
{isLoading ? (
|
385 |
+
<>
|
386 |
+
<div className="i-ph:spinner-gap-bold animate-spin" />
|
387 |
+
Saving...
|
388 |
+
</>
|
389 |
+
) : (
|
390 |
+
<>
|
391 |
+
<div className="i-ph:check-circle-fill" />
|
392 |
+
Save Changes
|
393 |
+
</>
|
394 |
+
)}
|
395 |
+
</button>
|
396 |
+
</motion.div>
|
397 |
+
</div>
|
398 |
+
);
|
399 |
+
}
|
@@ -0,0 +1,295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useEffect, useState } from 'react';
|
2 |
+
import { motion } from 'framer-motion';
|
3 |
+
import { toast } from 'react-toastify';
|
4 |
+
import { classNames } from '~/utils/classNames';
|
5 |
+
import { settingsStyles } from '~/components/settings/settings.styles';
|
6 |
+
import { DialogTitle, DialogDescription } from '~/components/ui/Dialog';
|
7 |
+
|
8 |
+
interface OllamaModel {
|
9 |
+
name: string;
|
10 |
+
digest: string;
|
11 |
+
size: number;
|
12 |
+
modified_at: string;
|
13 |
+
details?: {
|
14 |
+
family: string;
|
15 |
+
parameter_size: string;
|
16 |
+
quantization_level: string;
|
17 |
+
};
|
18 |
+
status?: 'idle' | 'updating' | 'updated' | 'error' | 'checking';
|
19 |
+
error?: string;
|
20 |
+
newDigest?: string;
|
21 |
+
progress?: {
|
22 |
+
current: number;
|
23 |
+
total: number;
|
24 |
+
status: string;
|
25 |
+
};
|
26 |
+
}
|
27 |
+
|
28 |
+
interface OllamaTagResponse {
|
29 |
+
models: Array<{
|
30 |
+
name: string;
|
31 |
+
digest: string;
|
32 |
+
size: number;
|
33 |
+
modified_at: string;
|
34 |
+
details?: {
|
35 |
+
family: string;
|
36 |
+
parameter_size: string;
|
37 |
+
quantization_level: string;
|
38 |
+
};
|
39 |
+
}>;
|
40 |
+
}
|
41 |
+
|
42 |
+
interface OllamaPullResponse {
|
43 |
+
status: string;
|
44 |
+
digest?: string;
|
45 |
+
total?: number;
|
46 |
+
completed?: number;
|
47 |
+
}
|
48 |
+
|
49 |
+
export default function OllamaModelUpdater() {
|
50 |
+
const [models, setModels] = useState<OllamaModel[]>([]);
|
51 |
+
const [isLoading, setIsLoading] = useState(true);
|
52 |
+
const [isBulkUpdating, setIsBulkUpdating] = useState(false);
|
53 |
+
|
54 |
+
useEffect(() => {
|
55 |
+
fetchModels();
|
56 |
+
}, []);
|
57 |
+
|
58 |
+
const fetchModels = async () => {
|
59 |
+
try {
|
60 |
+
setIsLoading(true);
|
61 |
+
|
62 |
+
const response = await fetch('http://localhost:11434/api/tags');
|
63 |
+
const data = (await response.json()) as OllamaTagResponse;
|
64 |
+
setModels(
|
65 |
+
data.models.map((model) => ({
|
66 |
+
name: model.name,
|
67 |
+
digest: model.digest,
|
68 |
+
size: model.size,
|
69 |
+
modified_at: model.modified_at,
|
70 |
+
details: model.details,
|
71 |
+
status: 'idle' as const,
|
72 |
+
})),
|
73 |
+
);
|
74 |
+
} catch (error) {
|
75 |
+
toast.error('Failed to fetch Ollama models');
|
76 |
+
console.error('Error fetching models:', error);
|
77 |
+
} finally {
|
78 |
+
setIsLoading(false);
|
79 |
+
}
|
80 |
+
};
|
81 |
+
|
82 |
+
const updateModel = async (modelName: string): Promise<{ success: boolean; newDigest?: string }> => {
|
83 |
+
try {
|
84 |
+
const response = await fetch('http://localhost:11434/api/pull', {
|
85 |
+
method: 'POST',
|
86 |
+
headers: {
|
87 |
+
'Content-Type': 'application/json',
|
88 |
+
},
|
89 |
+
body: JSON.stringify({ name: modelName }),
|
90 |
+
});
|
91 |
+
|
92 |
+
if (!response.ok) {
|
93 |
+
throw new Error(`Failed to update ${modelName}`);
|
94 |
+
}
|
95 |
+
|
96 |
+
const reader = response.body?.getReader();
|
97 |
+
|
98 |
+
if (!reader) {
|
99 |
+
throw new Error('No response reader available');
|
100 |
+
}
|
101 |
+
|
102 |
+
while (true) {
|
103 |
+
const { done, value } = await reader.read();
|
104 |
+
|
105 |
+
if (done) {
|
106 |
+
break;
|
107 |
+
}
|
108 |
+
|
109 |
+
const text = new TextDecoder().decode(value);
|
110 |
+
const lines = text.split('\n').filter(Boolean);
|
111 |
+
|
112 |
+
for (const line of lines) {
|
113 |
+
const data = JSON.parse(line) as OllamaPullResponse;
|
114 |
+
|
115 |
+
setModels((current) =>
|
116 |
+
current.map((m) =>
|
117 |
+
m.name === modelName
|
118 |
+
? {
|
119 |
+
...m,
|
120 |
+
progress: {
|
121 |
+
current: data.completed || 0,
|
122 |
+
total: data.total || 0,
|
123 |
+
status: data.status,
|
124 |
+
},
|
125 |
+
newDigest: data.digest,
|
126 |
+
}
|
127 |
+
: m,
|
128 |
+
),
|
129 |
+
);
|
130 |
+
}
|
131 |
+
}
|
132 |
+
|
133 |
+
setModels((current) => current.map((m) => (m.name === modelName ? { ...m, status: 'checking' } : m)));
|
134 |
+
|
135 |
+
const updatedResponse = await fetch('http://localhost:11434/api/tags');
|
136 |
+
const data = (await updatedResponse.json()) as OllamaTagResponse;
|
137 |
+
const updatedModel = data.models.find((m) => m.name === modelName);
|
138 |
+
|
139 |
+
return { success: true, newDigest: updatedModel?.digest };
|
140 |
+
} catch (error) {
|
141 |
+
console.error(`Error updating ${modelName}:`, error);
|
142 |
+
return { success: false };
|
143 |
+
}
|
144 |
+
};
|
145 |
+
|
146 |
+
const handleBulkUpdate = async () => {
|
147 |
+
setIsBulkUpdating(true);
|
148 |
+
|
149 |
+
for (const model of models) {
|
150 |
+
setModels((current) => current.map((m) => (m.name === model.name ? { ...m, status: 'updating' } : m)));
|
151 |
+
|
152 |
+
const { success, newDigest } = await updateModel(model.name);
|
153 |
+
|
154 |
+
setModels((current) =>
|
155 |
+
current.map((m) =>
|
156 |
+
m.name === model.name
|
157 |
+
? {
|
158 |
+
...m,
|
159 |
+
status: success ? 'updated' : 'error',
|
160 |
+
error: success ? undefined : 'Update failed',
|
161 |
+
newDigest,
|
162 |
+
}
|
163 |
+
: m,
|
164 |
+
),
|
165 |
+
);
|
166 |
+
}
|
167 |
+
|
168 |
+
setIsBulkUpdating(false);
|
169 |
+
toast.success('Bulk update completed');
|
170 |
+
};
|
171 |
+
|
172 |
+
const handleSingleUpdate = async (modelName: string) => {
|
173 |
+
setModels((current) => current.map((m) => (m.name === modelName ? { ...m, status: 'updating' } : m)));
|
174 |
+
|
175 |
+
const { success, newDigest } = await updateModel(modelName);
|
176 |
+
|
177 |
+
setModels((current) =>
|
178 |
+
current.map((m) =>
|
179 |
+
m.name === modelName
|
180 |
+
? {
|
181 |
+
...m,
|
182 |
+
status: success ? 'updated' : 'error',
|
183 |
+
error: success ? undefined : 'Update failed',
|
184 |
+
newDigest,
|
185 |
+
}
|
186 |
+
: m,
|
187 |
+
),
|
188 |
+
);
|
189 |
+
|
190 |
+
if (success) {
|
191 |
+
toast.success(`Updated ${modelName}`);
|
192 |
+
} else {
|
193 |
+
toast.error(`Failed to update ${modelName}`);
|
194 |
+
}
|
195 |
+
};
|
196 |
+
|
197 |
+
if (isLoading) {
|
198 |
+
return (
|
199 |
+
<div className="flex items-center justify-center p-4">
|
200 |
+
<div className={settingsStyles['loading-spinner']} />
|
201 |
+
<span className="ml-2 text-bolt-elements-textSecondary">Loading models...</span>
|
202 |
+
</div>
|
203 |
+
);
|
204 |
+
}
|
205 |
+
|
206 |
+
return (
|
207 |
+
<div className="space-y-4">
|
208 |
+
<div className="space-y-2">
|
209 |
+
<DialogTitle>Ollama Model Manager</DialogTitle>
|
210 |
+
<DialogDescription>Update your local Ollama models to their latest versions</DialogDescription>
|
211 |
+
</div>
|
212 |
+
|
213 |
+
<div className="flex items-center justify-between">
|
214 |
+
<div className="flex items-center gap-2">
|
215 |
+
<div className="i-ph:arrows-clockwise text-purple-500" />
|
216 |
+
<span className="text-sm text-bolt-elements-textPrimary">{models.length} models available</span>
|
217 |
+
</div>
|
218 |
+
<motion.button
|
219 |
+
onClick={handleBulkUpdate}
|
220 |
+
disabled={isBulkUpdating}
|
221 |
+
className={classNames(settingsStyles.button.base, settingsStyles.button.primary)}
|
222 |
+
whileHover={{ scale: 1.02 }}
|
223 |
+
whileTap={{ scale: 0.98 }}
|
224 |
+
>
|
225 |
+
{isBulkUpdating ? (
|
226 |
+
<>
|
227 |
+
<div className={settingsStyles['loading-spinner']} />
|
228 |
+
Updating All...
|
229 |
+
</>
|
230 |
+
) : (
|
231 |
+
<>
|
232 |
+
<div className="i-ph:arrows-clockwise" />
|
233 |
+
Update All Models
|
234 |
+
</>
|
235 |
+
)}
|
236 |
+
</motion.button>
|
237 |
+
</div>
|
238 |
+
|
239 |
+
<div className="space-y-2">
|
240 |
+
{models.map((model) => (
|
241 |
+
<div
|
242 |
+
key={model.name}
|
243 |
+
className={classNames(
|
244 |
+
'flex items-center justify-between p-3 rounded-lg',
|
245 |
+
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
|
246 |
+
'border border-[#E5E5E5] dark:border-[#333333]',
|
247 |
+
)}
|
248 |
+
>
|
249 |
+
<div className="flex flex-col gap-1">
|
250 |
+
<div className="flex items-center gap-2">
|
251 |
+
<div className="i-ph:cube text-purple-500" />
|
252 |
+
<span className="text-sm text-bolt-elements-textPrimary">{model.name}</span>
|
253 |
+
{model.status === 'updating' && <div className={settingsStyles['loading-spinner']} />}
|
254 |
+
{model.status === 'updated' && <div className="i-ph:check-circle text-green-500" />}
|
255 |
+
{model.status === 'error' && <div className="i-ph:x-circle text-red-500" />}
|
256 |
+
</div>
|
257 |
+
<div className="flex items-center gap-2 text-xs text-bolt-elements-textSecondary">
|
258 |
+
<span>Version: {model.digest.substring(0, 7)}</span>
|
259 |
+
{model.status === 'updated' && model.newDigest && (
|
260 |
+
<>
|
261 |
+
<div className="i-ph:arrow-right w-3 h-3" />
|
262 |
+
<span className="text-green-500">{model.newDigest.substring(0, 7)}</span>
|
263 |
+
</>
|
264 |
+
)}
|
265 |
+
{model.progress && (
|
266 |
+
<span className="ml-2">
|
267 |
+
{model.progress.status}{' '}
|
268 |
+
{model.progress.total > 0 && (
|
269 |
+
<>({Math.round((model.progress.current / model.progress.total) * 100)}%)</>
|
270 |
+
)}
|
271 |
+
</span>
|
272 |
+
)}
|
273 |
+
{model.details && (
|
274 |
+
<span className="ml-2">
|
275 |
+
({model.details.parameter_size}, {model.details.quantization_level})
|
276 |
+
</span>
|
277 |
+
)}
|
278 |
+
</div>
|
279 |
+
</div>
|
280 |
+
<motion.button
|
281 |
+
onClick={() => handleSingleUpdate(model.name)}
|
282 |
+
disabled={model.status === 'updating'}
|
283 |
+
className={classNames(settingsStyles.button.base, settingsStyles.button.secondary)}
|
284 |
+
whileHover={{ scale: 1.02 }}
|
285 |
+
whileTap={{ scale: 0.98 }}
|
286 |
+
>
|
287 |
+
<div className="i-ph:arrows-clockwise" />
|
288 |
+
Update
|
289 |
+
</motion.button>
|
290 |
+
</div>
|
291 |
+
))}
|
292 |
+
</div>
|
293 |
+
</div>
|
294 |
+
);
|
295 |
+
}
|
@@ -1,34 +1,157 @@
|
|
1 |
-
import React, { useEffect, useState } from 'react';
|
2 |
import { Switch } from '~/components/ui/Switch';
|
|
|
3 |
import { useSettings } from '~/lib/hooks/useSettings';
|
4 |
import { LOCAL_PROVIDERS, URL_CONFIGURABLE_PROVIDERS } from '~/lib/stores/settings';
|
5 |
import type { IProviderConfig } from '~/types/model';
|
6 |
import { logStore } from '~/lib/stores/logs';
|
7 |
-
|
8 |
-
|
|
|
|
|
9 |
import { providerBaseUrlEnvKeys } from '~/utils/constants';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
10 |
|
11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
12 |
|
13 |
export default function ProvidersTab() {
|
14 |
const { providers, updateProviderSettings, isLocalModel } = useSettings();
|
|
|
15 |
const [filteredProviders, setFilteredProviders] = useState<IProviderConfig[]>([]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
16 |
|
17 |
-
|
18 |
-
|
19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
20 |
useEffect(() => {
|
21 |
let newFilteredProviders: IProviderConfig[] = Object.entries(providers).map(([key, value]) => ({
|
22 |
...value,
|
23 |
name: key,
|
24 |
}));
|
25 |
|
26 |
-
if (searchTerm && searchTerm.length > 0) {
|
27 |
-
newFilteredProviders = newFilteredProviders.filter((provider) =>
|
28 |
-
provider.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
29 |
-
);
|
30 |
-
}
|
31 |
-
|
32 |
if (!isLocalModel) {
|
33 |
newFilteredProviders = newFilteredProviders.filter((provider) => !LOCAL_PROVIDERS.includes(provider.name));
|
34 |
}
|
@@ -40,108 +163,245 @@ export default function ProvidersTab() {
|
|
40 |
const urlConfigurable = newFilteredProviders.filter((p) => URL_CONFIGURABLE_PROVIDERS.includes(p.name));
|
41 |
|
42 |
setFilteredProviders([...regular, ...urlConfigurable]);
|
43 |
-
}, [providers,
|
44 |
-
|
45 |
-
const
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
<div className="flex items-center justify-between mb-2">
|
56 |
-
<div className="flex items-center gap-2">
|
57 |
-
<img
|
58 |
-
src={`/icons/${provider.name}.svg`}
|
59 |
-
onError={(e) => {
|
60 |
-
e.currentTarget.src = DefaultIcon;
|
61 |
-
}}
|
62 |
-
alt={`${provider.name} icon`}
|
63 |
-
className="w-6 h-6 dark:invert"
|
64 |
-
/>
|
65 |
-
<span className="text-bolt-elements-textPrimary">{provider.name}</span>
|
66 |
-
</div>
|
67 |
-
<Switch
|
68 |
-
className="ml-auto"
|
69 |
-
checked={provider.settings.enabled}
|
70 |
-
onCheckedChange={(enabled) => {
|
71 |
-
updateProviderSettings(provider.name, { ...provider.settings, enabled });
|
72 |
-
|
73 |
-
if (enabled) {
|
74 |
-
logStore.logProvider(`Provider ${provider.name} enabled`, { provider: provider.name });
|
75 |
-
} else {
|
76 |
-
logStore.logProvider(`Provider ${provider.name} disabled`, { provider: provider.name });
|
77 |
-
}
|
78 |
-
}}
|
79 |
-
/>
|
80 |
-
</div>
|
81 |
-
{isUrlConfigurable && provider.settings.enabled && (
|
82 |
-
<div className="mt-2">
|
83 |
-
{envBaseUrl && (
|
84 |
-
<label className="block text-xs text-bolt-elements-textSecondary text-green-300 mb-2">
|
85 |
-
Set On (.env) : {envBaseUrl}
|
86 |
-
</label>
|
87 |
-
)}
|
88 |
-
<label className="block text-sm text-bolt-elements-textSecondary mb-2">
|
89 |
-
{envBaseUrl ? 'Override Base Url' : 'Base URL '}:{' '}
|
90 |
-
</label>
|
91 |
-
<input
|
92 |
-
type="text"
|
93 |
-
value={provider.settings.baseUrl || ''}
|
94 |
-
onChange={(e) => {
|
95 |
-
let newBaseUrl: string | undefined = e.target.value;
|
96 |
-
|
97 |
-
if (newBaseUrl && newBaseUrl.trim().length === 0) {
|
98 |
-
newBaseUrl = undefined;
|
99 |
-
}
|
100 |
-
|
101 |
-
updateProviderSettings(provider.name, { ...provider.settings, baseUrl: newBaseUrl });
|
102 |
-
logStore.logProvider(`Base URL updated for ${provider.name}`, {
|
103 |
-
provider: provider.name,
|
104 |
-
baseUrl: newBaseUrl,
|
105 |
-
});
|
106 |
-
}}
|
107 |
-
placeholder={`Enter ${provider.name} base URL`}
|
108 |
-
className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor"
|
109 |
-
/>
|
110 |
-
</div>
|
111 |
-
)}
|
112 |
-
</div>
|
113 |
-
);
|
114 |
};
|
115 |
|
116 |
-
const
|
117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
118 |
|
119 |
return (
|
120 |
-
<div className="
|
121 |
-
|
122 |
-
<
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
|
127 |
-
|
128 |
-
|
129 |
-
|
130 |
-
|
131 |
-
|
132 |
-
|
133 |
-
|
134 |
-
|
135 |
-
|
136 |
-
|
137 |
-
|
138 |
-
|
139 |
-
|
140 |
-
|
141 |
-
|
142 |
-
|
143 |
-
|
144 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
145 |
</div>
|
146 |
);
|
147 |
}
|
|
|
1 |
+
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
2 |
import { Switch } from '~/components/ui/Switch';
|
3 |
+
import Separator from '~/components/ui/Separator';
|
4 |
import { useSettings } from '~/lib/hooks/useSettings';
|
5 |
import { LOCAL_PROVIDERS, URL_CONFIGURABLE_PROVIDERS } from '~/lib/stores/settings';
|
6 |
import type { IProviderConfig } from '~/types/model';
|
7 |
import { logStore } from '~/lib/stores/logs';
|
8 |
+
import { motion } from 'framer-motion';
|
9 |
+
import { classNames } from '~/utils/classNames';
|
10 |
+
import { settingsStyles } from '~/components/settings/settings.styles';
|
11 |
+
import { toast } from 'react-toastify';
|
12 |
import { providerBaseUrlEnvKeys } from '~/utils/constants';
|
13 |
+
import { SiAmazon, SiOpenai, SiGoogle, SiHuggingface, SiPerplexity } from 'react-icons/si';
|
14 |
+
import { BsRobot, BsCloud, BsCodeSquare, BsCpu, BsBox } from 'react-icons/bs';
|
15 |
+
import { TbBrandOpenai, TbBrain, TbCloudComputing } from 'react-icons/tb';
|
16 |
+
import { BiCodeBlock, BiChip } from 'react-icons/bi';
|
17 |
+
import { FaCloud, FaBrain } from 'react-icons/fa';
|
18 |
+
import type { IconType } from 'react-icons';
|
19 |
+
import OllamaModelUpdater from './OllamaModelUpdater';
|
20 |
+
import { DialogRoot, Dialog } from '~/components/ui/Dialog';
|
21 |
+
|
22 |
+
// Add type for provider names to ensure type safety
|
23 |
+
type ProviderName =
|
24 |
+
| 'AmazonBedrock'
|
25 |
+
| 'Anthropic'
|
26 |
+
| 'Cohere'
|
27 |
+
| 'Deepseek'
|
28 |
+
| 'Google'
|
29 |
+
| 'Groq'
|
30 |
+
| 'HuggingFace'
|
31 |
+
| 'Hyperbolic'
|
32 |
+
| 'LMStudio'
|
33 |
+
| 'Mistral'
|
34 |
+
| 'Ollama'
|
35 |
+
| 'OpenAI'
|
36 |
+
| 'OpenAILike'
|
37 |
+
| 'OpenRouter'
|
38 |
+
| 'Perplexity'
|
39 |
+
| 'Together'
|
40 |
+
| 'XAI';
|
41 |
+
|
42 |
+
// Update the PROVIDER_ICONS type to use the ProviderName type
|
43 |
+
const PROVIDER_ICONS: Record<ProviderName, IconType> = {
|
44 |
+
AmazonBedrock: SiAmazon,
|
45 |
+
Anthropic: FaBrain,
|
46 |
+
Cohere: BiChip,
|
47 |
+
Deepseek: BiCodeBlock,
|
48 |
+
Google: SiGoogle,
|
49 |
+
Groq: BsCpu,
|
50 |
+
HuggingFace: SiHuggingface,
|
51 |
+
Hyperbolic: TbCloudComputing,
|
52 |
+
LMStudio: BsCodeSquare,
|
53 |
+
Mistral: TbBrain,
|
54 |
+
Ollama: BsBox,
|
55 |
+
OpenAI: SiOpenai,
|
56 |
+
OpenAILike: TbBrandOpenai,
|
57 |
+
OpenRouter: FaCloud,
|
58 |
+
Perplexity: SiPerplexity,
|
59 |
+
Together: BsCloud,
|
60 |
+
XAI: BsRobot,
|
61 |
+
};
|
62 |
+
|
63 |
+
// Update PROVIDER_DESCRIPTIONS to use the same type
|
64 |
+
const PROVIDER_DESCRIPTIONS: Partial<Record<ProviderName, string>> = {
|
65 |
+
OpenAI: 'Use GPT-4, GPT-3.5, and other OpenAI models',
|
66 |
+
Anthropic: 'Access Claude and other Anthropic models',
|
67 |
+
Ollama: 'Run open-source models locally on your machine',
|
68 |
+
LMStudio: 'Local model inference with LM Studio',
|
69 |
+
OpenAILike: 'Connect to OpenAI-compatible API endpoints',
|
70 |
+
};
|
71 |
+
|
72 |
+
// Add these types and helper functions
|
73 |
+
type ProviderCategory = 'cloud' | 'local';
|
74 |
|
75 |
+
interface ProviderGroup {
|
76 |
+
title: string;
|
77 |
+
description: string;
|
78 |
+
icon: string;
|
79 |
+
providers: IProviderConfig[];
|
80 |
+
}
|
81 |
+
|
82 |
+
// Add this type
|
83 |
+
interface CategoryToggleState {
|
84 |
+
cloud: boolean;
|
85 |
+
local: boolean;
|
86 |
+
}
|
87 |
|
88 |
export default function ProvidersTab() {
|
89 |
const { providers, updateProviderSettings, isLocalModel } = useSettings();
|
90 |
+
const [editingProvider, setEditingProvider] = useState<string | null>(null);
|
91 |
const [filteredProviders, setFilteredProviders] = useState<IProviderConfig[]>([]);
|
92 |
+
const [categoryEnabled, setCategoryEnabled] = useState<CategoryToggleState>({
|
93 |
+
cloud: false,
|
94 |
+
local: false,
|
95 |
+
});
|
96 |
+
const [showOllamaUpdater, setShowOllamaUpdater] = useState(false);
|
97 |
+
|
98 |
+
// Group providers by category
|
99 |
+
const groupedProviders = useMemo(() => {
|
100 |
+
const groups: Record<ProviderCategory, ProviderGroup> = {
|
101 |
+
cloud: {
|
102 |
+
title: 'Cloud Providers',
|
103 |
+
description: 'AI models hosted on cloud platforms',
|
104 |
+
icon: 'i-ph:cloud-duotone',
|
105 |
+
providers: [],
|
106 |
+
},
|
107 |
+
local: {
|
108 |
+
title: 'Local Providers',
|
109 |
+
description: 'Run models locally on your machine',
|
110 |
+
icon: 'i-ph:desktop-duotone',
|
111 |
+
providers: [],
|
112 |
+
},
|
113 |
+
};
|
114 |
+
|
115 |
+
filteredProviders.forEach((provider) => {
|
116 |
+
const category: ProviderCategory = LOCAL_PROVIDERS.includes(provider.name) ? 'local' : 'cloud';
|
117 |
+
groups[category].providers.push(provider);
|
118 |
+
});
|
119 |
|
120 |
+
return groups;
|
121 |
+
}, [filteredProviders]);
|
122 |
|
123 |
+
// Update the toggle handler
|
124 |
+
const handleToggleCategory = useCallback(
|
125 |
+
(category: ProviderCategory, enabled: boolean) => {
|
126 |
+
setCategoryEnabled((prev) => ({ ...prev, [category]: enabled }));
|
127 |
+
|
128 |
+
// Get providers for this category
|
129 |
+
const categoryProviders = groupedProviders[category].providers;
|
130 |
+
categoryProviders.forEach((provider) => {
|
131 |
+
updateProviderSettings(provider.name, { ...provider.settings, enabled });
|
132 |
+
});
|
133 |
+
|
134 |
+
toast.success(enabled ? `All ${category} providers enabled` : `All ${category} providers disabled`);
|
135 |
+
},
|
136 |
+
[groupedProviders, updateProviderSettings],
|
137 |
+
);
|
138 |
+
|
139 |
+
// Add effect to update category toggle states based on provider states
|
140 |
+
useEffect(() => {
|
141 |
+
const newCategoryState = {
|
142 |
+
cloud: groupedProviders.cloud.providers.every((p) => p.settings.enabled),
|
143 |
+
local: groupedProviders.local.providers.every((p) => p.settings.enabled),
|
144 |
+
};
|
145 |
+
setCategoryEnabled(newCategoryState);
|
146 |
+
}, [groupedProviders]);
|
147 |
+
|
148 |
+
// Effect to filter and sort providers
|
149 |
useEffect(() => {
|
150 |
let newFilteredProviders: IProviderConfig[] = Object.entries(providers).map(([key, value]) => ({
|
151 |
...value,
|
152 |
name: key,
|
153 |
}));
|
154 |
|
|
|
|
|
|
|
|
|
|
|
|
|
155 |
if (!isLocalModel) {
|
156 |
newFilteredProviders = newFilteredProviders.filter((provider) => !LOCAL_PROVIDERS.includes(provider.name));
|
157 |
}
|
|
|
163 |
const urlConfigurable = newFilteredProviders.filter((p) => URL_CONFIGURABLE_PROVIDERS.includes(p.name));
|
164 |
|
165 |
setFilteredProviders([...regular, ...urlConfigurable]);
|
166 |
+
}, [providers, isLocalModel]);
|
167 |
+
|
168 |
+
const handleToggleProvider = (provider: IProviderConfig, enabled: boolean) => {
|
169 |
+
updateProviderSettings(provider.name, { ...provider.settings, enabled });
|
170 |
+
|
171 |
+
if (enabled) {
|
172 |
+
logStore.logProvider(`Provider ${provider.name} enabled`, { provider: provider.name });
|
173 |
+
toast.success(`${provider.name} enabled`);
|
174 |
+
} else {
|
175 |
+
logStore.logProvider(`Provider ${provider.name} disabled`, { provider: provider.name });
|
176 |
+
toast.success(`${provider.name} disabled`);
|
177 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
178 |
};
|
179 |
|
180 |
+
const handleUpdateBaseUrl = (provider: IProviderConfig, baseUrl: string) => {
|
181 |
+
let newBaseUrl: string | undefined = baseUrl;
|
182 |
+
|
183 |
+
if (newBaseUrl && newBaseUrl.trim().length === 0) {
|
184 |
+
newBaseUrl = undefined;
|
185 |
+
}
|
186 |
+
|
187 |
+
updateProviderSettings(provider.name, { ...provider.settings, baseUrl: newBaseUrl });
|
188 |
+
logStore.logProvider(`Base URL updated for ${provider.name}`, {
|
189 |
+
provider: provider.name,
|
190 |
+
baseUrl: newBaseUrl,
|
191 |
+
});
|
192 |
+
toast.success(`${provider.name} base URL updated`);
|
193 |
+
setEditingProvider(null);
|
194 |
+
};
|
195 |
|
196 |
return (
|
197 |
+
<div className="space-y-6">
|
198 |
+
{Object.entries(groupedProviders).map(([category, group]) => (
|
199 |
+
<motion.div
|
200 |
+
key={category}
|
201 |
+
className="space-y-4"
|
202 |
+
initial={{ opacity: 0, y: 20 }}
|
203 |
+
animate={{ opacity: 1, y: 0 }}
|
204 |
+
transition={{ duration: 0.3 }}
|
205 |
+
>
|
206 |
+
<div className="flex items-center justify-between gap-4 mt-8 mb-4">
|
207 |
+
<div className="flex items-center gap-2">
|
208 |
+
<div
|
209 |
+
className={classNames(
|
210 |
+
'w-8 h-8 flex items-center justify-center rounded-lg',
|
211 |
+
'bg-bolt-elements-background-depth-3',
|
212 |
+
'text-purple-500',
|
213 |
+
)}
|
214 |
+
>
|
215 |
+
<div className={group.icon} />
|
216 |
+
</div>
|
217 |
+
<div>
|
218 |
+
<h4 className="text-md font-medium text-bolt-elements-textPrimary">{group.title}</h4>
|
219 |
+
<p className="text-sm text-bolt-elements-textSecondary">{group.description}</p>
|
220 |
+
</div>
|
221 |
+
</div>
|
222 |
+
|
223 |
+
<div className="flex items-center gap-2">
|
224 |
+
<span className="text-sm text-bolt-elements-textSecondary">
|
225 |
+
Enable All {category === 'cloud' ? 'Cloud' : 'Local'}
|
226 |
+
</span>
|
227 |
+
<Switch
|
228 |
+
checked={categoryEnabled[category as ProviderCategory]}
|
229 |
+
onCheckedChange={(checked) => handleToggleCategory(category as ProviderCategory, checked)}
|
230 |
+
/>
|
231 |
+
</div>
|
232 |
+
</div>
|
233 |
+
|
234 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
235 |
+
{group.providers.map((provider, index) => (
|
236 |
+
<motion.div
|
237 |
+
key={provider.name}
|
238 |
+
className={classNames(
|
239 |
+
settingsStyles.card,
|
240 |
+
'bg-bolt-elements-background-depth-2',
|
241 |
+
'hover:bg-bolt-elements-background-depth-3',
|
242 |
+
'transition-all duration-200',
|
243 |
+
'relative overflow-hidden group',
|
244 |
+
'flex flex-col',
|
245 |
+
)}
|
246 |
+
initial={{ opacity: 0, y: 20 }}
|
247 |
+
animate={{ opacity: 1, y: 0 }}
|
248 |
+
transition={{ delay: index * 0.1 }}
|
249 |
+
whileHover={{ scale: 1.02 }}
|
250 |
+
>
|
251 |
+
<div className="absolute top-0 right-0 p-2 flex gap-1">
|
252 |
+
{LOCAL_PROVIDERS.includes(provider.name) && (
|
253 |
+
<motion.span
|
254 |
+
className="px-2 py-0.5 text-xs rounded-full bg-green-500/10 text-green-500 font-medium"
|
255 |
+
whileHover={{ scale: 1.05 }}
|
256 |
+
whileTap={{ scale: 0.95 }}
|
257 |
+
>
|
258 |
+
Local
|
259 |
+
</motion.span>
|
260 |
+
)}
|
261 |
+
{URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
|
262 |
+
<motion.span
|
263 |
+
className="px-2 py-0.5 text-xs rounded-full bg-purple-500/10 text-purple-500 font-medium"
|
264 |
+
whileHover={{ scale: 1.05 }}
|
265 |
+
whileTap={{ scale: 0.95 }}
|
266 |
+
>
|
267 |
+
Configurable
|
268 |
+
</motion.span>
|
269 |
+
)}
|
270 |
+
</div>
|
271 |
+
|
272 |
+
<div className="flex items-start gap-4 p-4">
|
273 |
+
<motion.div
|
274 |
+
className={classNames(
|
275 |
+
'w-10 h-10 flex items-center justify-center rounded-xl',
|
276 |
+
'bg-bolt-elements-background-depth-3 group-hover:bg-bolt-elements-background-depth-4',
|
277 |
+
'transition-all duration-200',
|
278 |
+
provider.settings.enabled ? 'text-purple-500' : 'text-bolt-elements-textSecondary',
|
279 |
+
)}
|
280 |
+
whileHover={{ scale: 1.1 }}
|
281 |
+
whileTap={{ scale: 0.9 }}
|
282 |
+
>
|
283 |
+
<div
|
284 |
+
className={classNames('w-6 h-6', 'transition-transform duration-200', 'group-hover:rotate-12')}
|
285 |
+
>
|
286 |
+
{React.createElement(PROVIDER_ICONS[provider.name as ProviderName] || BsRobot, {
|
287 |
+
className: 'w-full h-full',
|
288 |
+
'aria-label': `${provider.name} logo`,
|
289 |
+
})}
|
290 |
+
</div>
|
291 |
+
</motion.div>
|
292 |
+
|
293 |
+
<div className="flex-1 min-w-0">
|
294 |
+
<div className="flex items-center justify-between gap-4 mb-2">
|
295 |
+
<div>
|
296 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-purple-500 transition-colors">
|
297 |
+
{provider.name}
|
298 |
+
</h4>
|
299 |
+
<p className="text-xs text-bolt-elements-textSecondary mt-0.5">
|
300 |
+
{PROVIDER_DESCRIPTIONS[provider.name as keyof typeof PROVIDER_DESCRIPTIONS] ||
|
301 |
+
(URL_CONFIGURABLE_PROVIDERS.includes(provider.name)
|
302 |
+
? 'Configure custom endpoint for this provider'
|
303 |
+
: 'Standard AI provider integration')}
|
304 |
+
</p>
|
305 |
+
</div>
|
306 |
+
<Switch
|
307 |
+
checked={provider.settings.enabled}
|
308 |
+
onCheckedChange={(checked) => handleToggleProvider(provider, checked)}
|
309 |
+
/>
|
310 |
+
</div>
|
311 |
+
|
312 |
+
{provider.settings.enabled && URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
|
313 |
+
<motion.div
|
314 |
+
initial={{ opacity: 0, height: 0 }}
|
315 |
+
animate={{ opacity: 1, height: 'auto' }}
|
316 |
+
exit={{ opacity: 0, height: 0 }}
|
317 |
+
transition={{ duration: 0.2 }}
|
318 |
+
>
|
319 |
+
<div className="flex items-center gap-2 mt-4">
|
320 |
+
{editingProvider === provider.name ? (
|
321 |
+
<input
|
322 |
+
type="text"
|
323 |
+
defaultValue={provider.settings.baseUrl}
|
324 |
+
placeholder={`Enter ${provider.name} base URL`}
|
325 |
+
className={classNames(
|
326 |
+
'flex-1 px-3 py-1.5 rounded-lg text-sm',
|
327 |
+
'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
|
328 |
+
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
329 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
|
330 |
+
'transition-all duration-200',
|
331 |
+
)}
|
332 |
+
onKeyDown={(e) => {
|
333 |
+
if (e.key === 'Enter') {
|
334 |
+
handleUpdateBaseUrl(provider, e.currentTarget.value);
|
335 |
+
} else if (e.key === 'Escape') {
|
336 |
+
setEditingProvider(null);
|
337 |
+
}
|
338 |
+
}}
|
339 |
+
onBlur={(e) => handleUpdateBaseUrl(provider, e.target.value)}
|
340 |
+
autoFocus
|
341 |
+
/>
|
342 |
+
) : (
|
343 |
+
<div
|
344 |
+
className="flex-1 px-3 py-1.5 rounded-lg text-sm cursor-pointer group/url"
|
345 |
+
onClick={() => setEditingProvider(provider.name)}
|
346 |
+
>
|
347 |
+
<div className="flex items-center gap-2 text-bolt-elements-textSecondary">
|
348 |
+
<div className="i-ph:link text-sm" />
|
349 |
+
<span className="group-hover/url:text-purple-500 transition-colors">
|
350 |
+
{provider.settings.baseUrl || 'Click to set base URL'}
|
351 |
+
</span>
|
352 |
+
</div>
|
353 |
+
</div>
|
354 |
+
)}
|
355 |
+
</div>
|
356 |
+
|
357 |
+
{providerBaseUrlEnvKeys[provider.name]?.baseUrlKey && (
|
358 |
+
<div className="mt-2 text-xs text-green-500">
|
359 |
+
<div className="flex items-center gap-1">
|
360 |
+
<div className="i-ph:info" />
|
361 |
+
<span>Environment URL set in .env file</span>
|
362 |
+
</div>
|
363 |
+
</div>
|
364 |
+
)}
|
365 |
+
</motion.div>
|
366 |
+
)}
|
367 |
+
</div>
|
368 |
+
</div>
|
369 |
+
|
370 |
+
<motion.div
|
371 |
+
className="absolute inset-0 border-2 border-purple-500/0 rounded-lg pointer-events-none"
|
372 |
+
animate={{
|
373 |
+
borderColor: provider.settings.enabled ? 'rgba(168, 85, 247, 0.2)' : 'rgba(168, 85, 247, 0)',
|
374 |
+
scale: provider.settings.enabled ? 1 : 0.98,
|
375 |
+
}}
|
376 |
+
transition={{ duration: 0.2 }}
|
377 |
+
/>
|
378 |
+
|
379 |
+
{provider.name === 'Ollama' && provider.settings.enabled && (
|
380 |
+
<motion.button
|
381 |
+
onClick={() => setShowOllamaUpdater(true)}
|
382 |
+
className={classNames(settingsStyles.button.base, settingsStyles.button.secondary, 'ml-2')}
|
383 |
+
whileHover={{ scale: 1.02 }}
|
384 |
+
whileTap={{ scale: 0.98 }}
|
385 |
+
>
|
386 |
+
<div className="i-ph:arrows-clockwise" />
|
387 |
+
Update Models
|
388 |
+
</motion.button>
|
389 |
+
)}
|
390 |
+
|
391 |
+
<DialogRoot open={showOllamaUpdater} onOpenChange={setShowOllamaUpdater}>
|
392 |
+
<Dialog>
|
393 |
+
<div className="p-6">
|
394 |
+
<OllamaModelUpdater />
|
395 |
+
</div>
|
396 |
+
</Dialog>
|
397 |
+
</DialogRoot>
|
398 |
+
</motion.div>
|
399 |
+
))}
|
400 |
+
</div>
|
401 |
+
|
402 |
+
{category === 'cloud' && <Separator className="my-8" />}
|
403 |
+
</motion.div>
|
404 |
+
))}
|
405 |
</div>
|
406 |
);
|
407 |
}
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { type ClassValue, clsx } from 'clsx';
|
2 |
+
import { twMerge } from 'tailwind-merge';
|
3 |
+
|
4 |
+
export function cn(...inputs: ClassValue[]) {
|
5 |
+
return twMerge(clsx(inputs));
|
6 |
+
}
|
7 |
+
|
8 |
+
export const settingsStyles = {
|
9 |
+
// Card styles
|
10 |
+
card: 'bg-white dark:bg-[#0A0A0A] rounded-lg p-6 border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
11 |
+
|
12 |
+
// Button styles
|
13 |
+
button: {
|
14 |
+
base: 'inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm disabled:opacity-50 disabled:cursor-not-allowed',
|
15 |
+
primary: 'bg-purple-500 text-white hover:bg-purple-600',
|
16 |
+
secondary:
|
17 |
+
'bg-[#F5F5F5] dark:bg-[#1A1A1A] text-[#666666] dark:text-[#999999] hover:text-[#333333] dark:hover:text-white',
|
18 |
+
danger: 'bg-red-50 text-red-500 hover:bg-red-100 dark:bg-red-500/10 dark:hover:bg-red-500/20',
|
19 |
+
warning: 'bg-yellow-50 text-yellow-600 hover:bg-yellow-100 dark:bg-yellow-500/10 dark:hover:bg-yellow-500/20',
|
20 |
+
success: 'bg-green-50 text-green-600 hover:bg-green-100 dark:bg-green-500/10 dark:hover:bg-green-500/20',
|
21 |
+
},
|
22 |
+
|
23 |
+
// Form styles
|
24 |
+
form: {
|
25 |
+
label: 'block text-sm text-bolt-elements-textSecondary mb-2',
|
26 |
+
input:
|
27 |
+
'w-full px-3 py-2 rounded-lg text-sm bg-[#F8F8F8] dark:bg-[#1A1A1A] border border-[#E5E5E5] dark:border-[#333333] text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary focus:outline-none focus:ring-1 focus:ring-purple-500',
|
28 |
+
},
|
29 |
+
|
30 |
+
// Search container
|
31 |
+
search: {
|
32 |
+
input:
|
33 |
+
'w-full h-10 pl-10 pr-4 rounded-lg text-sm bg-[#F8F8F8] dark:bg-[#1A1A1A] border border-[#E5E5E5] dark:border-[#333333] text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary focus:outline-none focus:ring-1 focus:ring-purple-500 transition-all',
|
34 |
+
},
|
35 |
+
|
36 |
+
'loading-spinner': 'i-ph:spinner-gap-bold animate-spin w-4 h-4',
|
37 |
+
} as const;
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import type { ReactNode } from 'react';
|
2 |
+
|
3 |
+
export type SettingCategory = 'profile' | 'file_sharing' | 'connectivity' | 'system' | 'services' | 'preferences';
|
4 |
+
export type TabType =
|
5 |
+
| 'profile'
|
6 |
+
| 'data'
|
7 |
+
| 'providers'
|
8 |
+
| 'features'
|
9 |
+
| 'debug'
|
10 |
+
| 'event-logs'
|
11 |
+
| 'connection'
|
12 |
+
| 'preferences';
|
13 |
+
|
14 |
+
export interface UserProfile {
|
15 |
+
name: string;
|
16 |
+
email: string;
|
17 |
+
avatar?: string;
|
18 |
+
theme: 'light' | 'dark' | 'system';
|
19 |
+
notifications: boolean;
|
20 |
+
password?: string;
|
21 |
+
bio?: string;
|
22 |
+
language: string;
|
23 |
+
timezone: string;
|
24 |
+
}
|
25 |
+
|
26 |
+
export interface SettingItem {
|
27 |
+
id: TabType;
|
28 |
+
label: string;
|
29 |
+
icon: string;
|
30 |
+
category: SettingCategory;
|
31 |
+
description?: string;
|
32 |
+
component: () => ReactNode;
|
33 |
+
badge?: string;
|
34 |
+
keywords?: string[];
|
35 |
+
}
|
36 |
+
|
37 |
+
export const categoryLabels: Record<SettingCategory, string> = {
|
38 |
+
profile: 'Profile & Account',
|
39 |
+
file_sharing: 'File Sharing',
|
40 |
+
connectivity: 'Connectivity',
|
41 |
+
system: 'System',
|
42 |
+
services: 'Services',
|
43 |
+
preferences: 'Preferences',
|
44 |
+
};
|
45 |
+
|
46 |
+
export const categoryIcons: Record<SettingCategory, string> = {
|
47 |
+
profile: 'i-ph:user-circle',
|
48 |
+
file_sharing: 'i-ph:folder-simple',
|
49 |
+
connectivity: 'i-ph:wifi-high',
|
50 |
+
system: 'i-ph:gear',
|
51 |
+
services: 'i-ph:cube',
|
52 |
+
preferences: 'i-ph:sliders',
|
53 |
+
};
|
@@ -7,60 +7,23 @@ import { IconButton } from './IconButton';
|
|
7 |
|
8 |
export { Close as DialogClose, Root as DialogRoot } from '@radix-ui/react-dialog';
|
9 |
|
10 |
-
const transition = {
|
11 |
-
duration: 0.15,
|
12 |
-
ease: cubicEasingFn,
|
13 |
-
};
|
14 |
-
|
15 |
-
export const dialogBackdropVariants = {
|
16 |
-
closed: {
|
17 |
-
opacity: 0,
|
18 |
-
transition,
|
19 |
-
},
|
20 |
-
open: {
|
21 |
-
opacity: 1,
|
22 |
-
transition,
|
23 |
-
},
|
24 |
-
} satisfies Variants;
|
25 |
-
|
26 |
-
export const dialogVariants = {
|
27 |
-
closed: {
|
28 |
-
x: '-50%',
|
29 |
-
y: '-40%',
|
30 |
-
scale: 0.96,
|
31 |
-
opacity: 0,
|
32 |
-
transition,
|
33 |
-
},
|
34 |
-
open: {
|
35 |
-
x: '-50%',
|
36 |
-
y: '-50%',
|
37 |
-
scale: 1,
|
38 |
-
opacity: 1,
|
39 |
-
transition,
|
40 |
-
},
|
41 |
-
} satisfies Variants;
|
42 |
-
|
43 |
interface DialogButtonProps {
|
44 |
type: 'primary' | 'secondary' | 'danger';
|
45 |
children: ReactNode;
|
46 |
-
onClick?: (event: React.
|
|
|
47 |
}
|
48 |
|
49 |
-
export const DialogButton = memo(({ type, children, onClick }: DialogButtonProps) => {
|
50 |
return (
|
51 |
<button
|
52 |
-
className={classNames(
|
53 |
-
'
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
'bg-bolt-elements-button-secondary-background text-bolt-elements-button-secondary-text hover:bg-bolt-elements-button-secondary-backgroundHover':
|
58 |
-
type === 'secondary',
|
59 |
-
'bg-bolt-elements-button-danger-background text-bolt-elements-button-danger-text hover:bg-bolt-elements-button-danger-backgroundHover':
|
60 |
-
type === 'danger',
|
61 |
-
},
|
62 |
-
)}
|
63 |
onClick={onClick}
|
|
|
64 |
>
|
65 |
{children}
|
66 |
</button>
|
@@ -70,10 +33,7 @@ export const DialogButton = memo(({ type, children, onClick }: DialogButtonProps
|
|
70 |
export const DialogTitle = memo(({ className, children, ...props }: RadixDialog.DialogTitleProps) => {
|
71 |
return (
|
72 |
<RadixDialog.Title
|
73 |
-
className={classNames(
|
74 |
-
'px-5 py-4 flex items-center justify-between border-b border-bolt-elements-borderColor text-lg font-semibold leading-6 text-bolt-elements-textPrimary',
|
75 |
-
className,
|
76 |
-
)}
|
77 |
{...props}
|
78 |
>
|
79 |
{children}
|
@@ -84,7 +44,7 @@ export const DialogTitle = memo(({ className, children, ...props }: RadixDialog.
|
|
84 |
export const DialogDescription = memo(({ className, children, ...props }: RadixDialog.DialogDescriptionProps) => {
|
85 |
return (
|
86 |
<RadixDialog.Description
|
87 |
-
className={classNames('
|
88 |
{...props}
|
89 |
>
|
90 |
{children}
|
@@ -92,29 +52,72 @@ export const DialogDescription = memo(({ className, children, ...props }: RadixD
|
|
92 |
);
|
93 |
});
|
94 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
95 |
interface DialogProps {
|
96 |
-
children: ReactNode
|
97 |
className?: string;
|
98 |
-
|
99 |
-
onClose?: (
|
|
|
100 |
}
|
101 |
|
102 |
-
export const Dialog = memo(({
|
103 |
return (
|
104 |
<RadixDialog.Portal>
|
105 |
-
<RadixDialog.Overlay
|
106 |
<motion.div
|
107 |
-
className=
|
|
|
|
|
|
|
|
|
108 |
initial="closed"
|
109 |
animate="open"
|
110 |
exit="closed"
|
111 |
variants={dialogBackdropVariants}
|
|
|
112 |
/>
|
113 |
</RadixDialog.Overlay>
|
114 |
<RadixDialog.Content asChild>
|
115 |
<motion.div
|
116 |
className={classNames(
|
117 |
-
'fixed top-
|
|
|
|
|
|
|
|
|
118 |
className,
|
119 |
)}
|
120 |
initial="closed"
|
@@ -122,10 +125,17 @@ export const Dialog = memo(({ className, children, onBackdrop, onClose }: Dialog
|
|
122 |
exit="closed"
|
123 |
variants={dialogVariants}
|
124 |
>
|
125 |
-
|
126 |
-
|
127 |
-
|
128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
129 |
</motion.div>
|
130 |
</RadixDialog.Content>
|
131 |
</RadixDialog.Portal>
|
|
|
7 |
|
8 |
export { Close as DialogClose, Root as DialogRoot } from '@radix-ui/react-dialog';
|
9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
10 |
interface DialogButtonProps {
|
11 |
type: 'primary' | 'secondary' | 'danger';
|
12 |
children: ReactNode;
|
13 |
+
onClick?: (event: React.MouseEvent) => void;
|
14 |
+
disabled?: boolean;
|
15 |
}
|
16 |
|
17 |
+
export const DialogButton = memo(({ type, children, onClick, disabled }: DialogButtonProps) => {
|
18 |
return (
|
19 |
<button
|
20 |
+
className={classNames('inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm', {
|
21 |
+
'bg-purple-500 text-white hover:bg-purple-600': type === 'primary',
|
22 |
+
'text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary': type === 'secondary',
|
23 |
+
'text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10': type === 'danger',
|
24 |
+
})}
|
|
|
|
|
|
|
|
|
|
|
|
|
25 |
onClick={onClick}
|
26 |
+
disabled={disabled}
|
27 |
>
|
28 |
{children}
|
29 |
</button>
|
|
|
33 |
export const DialogTitle = memo(({ className, children, ...props }: RadixDialog.DialogTitleProps) => {
|
34 |
return (
|
35 |
<RadixDialog.Title
|
36 |
+
className={classNames('text-lg font-medium text-bolt-elements-textPrimary', 'flex items-center gap-2', className)}
|
|
|
|
|
|
|
37 |
{...props}
|
38 |
>
|
39 |
{children}
|
|
|
44 |
export const DialogDescription = memo(({ className, children, ...props }: RadixDialog.DialogDescriptionProps) => {
|
45 |
return (
|
46 |
<RadixDialog.Description
|
47 |
+
className={classNames('text-sm text-bolt-elements-textSecondary', 'mt-1', className)}
|
48 |
{...props}
|
49 |
>
|
50 |
{children}
|
|
|
52 |
);
|
53 |
});
|
54 |
|
55 |
+
const transition = {
|
56 |
+
duration: 0.15,
|
57 |
+
ease: cubicEasingFn,
|
58 |
+
};
|
59 |
+
|
60 |
+
export const dialogBackdropVariants = {
|
61 |
+
closed: {
|
62 |
+
opacity: 0,
|
63 |
+
transition,
|
64 |
+
},
|
65 |
+
open: {
|
66 |
+
opacity: 1,
|
67 |
+
transition,
|
68 |
+
},
|
69 |
+
} satisfies Variants;
|
70 |
+
|
71 |
+
export const dialogVariants = {
|
72 |
+
closed: {
|
73 |
+
x: '-50%',
|
74 |
+
y: '-40%',
|
75 |
+
scale: 0.96,
|
76 |
+
opacity: 0,
|
77 |
+
transition,
|
78 |
+
},
|
79 |
+
open: {
|
80 |
+
x: '-50%',
|
81 |
+
y: '-50%',
|
82 |
+
scale: 1,
|
83 |
+
opacity: 1,
|
84 |
+
transition,
|
85 |
+
},
|
86 |
+
} satisfies Variants;
|
87 |
+
|
88 |
interface DialogProps {
|
89 |
+
children: ReactNode;
|
90 |
className?: string;
|
91 |
+
showCloseButton?: boolean;
|
92 |
+
onClose?: () => void;
|
93 |
+
onBackdrop?: () => void;
|
94 |
}
|
95 |
|
96 |
+
export const Dialog = memo(({ children, className, showCloseButton = true, onClose, onBackdrop }: DialogProps) => {
|
97 |
return (
|
98 |
<RadixDialog.Portal>
|
99 |
+
<RadixDialog.Overlay asChild>
|
100 |
<motion.div
|
101 |
+
className={classNames(
|
102 |
+
'fixed inset-0 z-[9999]',
|
103 |
+
'bg-[#FAFAFA]/80 dark:bg-[#0A0A0A]/80',
|
104 |
+
'backdrop-blur-[2px]',
|
105 |
+
)}
|
106 |
initial="closed"
|
107 |
animate="open"
|
108 |
exit="closed"
|
109 |
variants={dialogBackdropVariants}
|
110 |
+
onClick={onBackdrop}
|
111 |
/>
|
112 |
</RadixDialog.Overlay>
|
113 |
<RadixDialog.Content asChild>
|
114 |
<motion.div
|
115 |
className={classNames(
|
116 |
+
'fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2',
|
117 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
118 |
+
'rounded-lg shadow-lg',
|
119 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
120 |
+
'z-[9999] w-[520px]',
|
121 |
className,
|
122 |
)}
|
123 |
initial="closed"
|
|
|
125 |
exit="closed"
|
126 |
variants={dialogVariants}
|
127 |
>
|
128 |
+
<div className="flex flex-col">
|
129 |
+
{children}
|
130 |
+
{showCloseButton && (
|
131 |
+
<RadixDialog.Close asChild onClick={onClose}>
|
132 |
+
<IconButton
|
133 |
+
icon="i-ph:x"
|
134 |
+
className="absolute top-3 right-3 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary"
|
135 |
+
/>
|
136 |
+
</RadixDialog.Close>
|
137 |
+
)}
|
138 |
+
</div>
|
139 |
</motion.div>
|
140 |
</RadixDialog.Content>
|
141 |
</RadixDialog.Portal>
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
2 |
+
import { classNames } from '~/utils/classNames';
|
3 |
+
|
4 |
+
interface SeparatorProps {
|
5 |
+
className?: string;
|
6 |
+
orientation?: 'horizontal' | 'vertical';
|
7 |
+
}
|
8 |
+
|
9 |
+
export const Separator = ({ className, orientation = 'horizontal' }: SeparatorProps) => {
|
10 |
+
return (
|
11 |
+
<SeparatorPrimitive.Root
|
12 |
+
className={classNames(
|
13 |
+
'bg-bolt-elements-borderColor',
|
14 |
+
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
|
15 |
+
className,
|
16 |
+
)}
|
17 |
+
orientation={orientation}
|
18 |
+
/>
|
19 |
+
);
|
20 |
+
};
|
21 |
+
|
22 |
+
export default Separator;
|
@@ -24,6 +24,11 @@ class LogStore {
|
|
24 |
this._loadLogs();
|
25 |
}
|
26 |
|
|
|
|
|
|
|
|
|
|
|
27 |
private _loadLogs() {
|
28 |
const savedLogs = Cookies.get('eventLogs');
|
29 |
|
|
|
24 |
this._loadLogs();
|
25 |
}
|
26 |
|
27 |
+
// Expose the logs store for subscription
|
28 |
+
get logs() {
|
29 |
+
return this._logs;
|
30 |
+
}
|
31 |
+
|
32 |
private _loadLogs() {
|
33 |
const savedLogs = Cookies.get('eventLogs');
|
34 |
|
@@ -30,12 +30,12 @@
|
|
30 |
"node": ">=18.18.0"
|
31 |
},
|
32 |
"dependencies": {
|
|
|
33 |
"@ai-sdk/anthropic": "^0.0.39",
|
34 |
"@ai-sdk/cohere": "^1.0.3",
|
35 |
"@ai-sdk/google": "^0.0.52",
|
36 |
"@ai-sdk/mistral": "^0.0.43",
|
37 |
"@ai-sdk/openai": "^0.0.66",
|
38 |
-
"@ai-sdk/amazon-bedrock": "1.0.6",
|
39 |
"@codemirror/autocomplete": "^6.18.3",
|
40 |
"@codemirror/commands": "^6.7.1",
|
41 |
"@codemirror/lang-cpp": "^6.0.2",
|
@@ -52,7 +52,7 @@
|
|
52 |
"@codemirror/search": "^6.5.8",
|
53 |
"@codemirror/state": "^6.4.1",
|
54 |
"@codemirror/view": "^6.35.0",
|
55 |
-
"@
|
56 |
"@iconify-json/svg-spinners": "^1.2.1",
|
57 |
"@lezer/highlight": "^1.2.1",
|
58 |
"@nanostores/react": "^0.7.3",
|
@@ -76,6 +76,7 @@
|
|
76 |
"@xterm/xterm": "^5.5.0",
|
77 |
"ai": "^4.0.13",
|
78 |
"chalk": "^5.4.1",
|
|
|
79 |
"date-fns": "^3.6.0",
|
80 |
"diff": "^5.2.0",
|
81 |
"dotenv": "^16.4.7",
|
@@ -93,6 +94,7 @@
|
|
93 |
"react": "^18.3.1",
|
94 |
"react-dom": "^18.3.1",
|
95 |
"react-hotkeys-hook": "^4.6.1",
|
|
|
96 |
"react-markdown": "^9.0.1",
|
97 |
"react-resizable-panels": "^2.1.7",
|
98 |
"react-toastify": "^10.0.6",
|
@@ -102,11 +104,14 @@
|
|
102 |
"remix-island": "^0.2.0",
|
103 |
"remix-utils": "^7.7.0",
|
104 |
"shiki": "^1.24.0",
|
|
|
105 |
"unist-util-visit": "^5.0.0"
|
106 |
},
|
107 |
"devDependencies": {
|
108 |
"@blitz/eslint-plugin": "0.1.0",
|
109 |
"@cloudflare/workers-types": "^4.20241127.0",
|
|
|
|
|
110 |
"@remix-run/dev": "^2.15.0",
|
111 |
"@types/diff": "^5.2.3",
|
112 |
"@types/dom-speech-recognition": "^0.0.4",
|
|
|
30 |
"node": ">=18.18.0"
|
31 |
},
|
32 |
"dependencies": {
|
33 |
+
"@ai-sdk/amazon-bedrock": "1.0.6",
|
34 |
"@ai-sdk/anthropic": "^0.0.39",
|
35 |
"@ai-sdk/cohere": "^1.0.3",
|
36 |
"@ai-sdk/google": "^0.0.52",
|
37 |
"@ai-sdk/mistral": "^0.0.43",
|
38 |
"@ai-sdk/openai": "^0.0.66",
|
|
|
39 |
"@codemirror/autocomplete": "^6.18.3",
|
40 |
"@codemirror/commands": "^6.7.1",
|
41 |
"@codemirror/lang-cpp": "^6.0.2",
|
|
|
52 |
"@codemirror/search": "^6.5.8",
|
53 |
"@codemirror/state": "^6.4.1",
|
54 |
"@codemirror/view": "^6.35.0",
|
55 |
+
"@headlessui/react": "^2.2.0",
|
56 |
"@iconify-json/svg-spinners": "^1.2.1",
|
57 |
"@lezer/highlight": "^1.2.1",
|
58 |
"@nanostores/react": "^0.7.3",
|
|
|
76 |
"@xterm/xterm": "^5.5.0",
|
77 |
"ai": "^4.0.13",
|
78 |
"chalk": "^5.4.1",
|
79 |
+
"clsx": "^2.1.1",
|
80 |
"date-fns": "^3.6.0",
|
81 |
"diff": "^5.2.0",
|
82 |
"dotenv": "^16.4.7",
|
|
|
94 |
"react": "^18.3.1",
|
95 |
"react-dom": "^18.3.1",
|
96 |
"react-hotkeys-hook": "^4.6.1",
|
97 |
+
"react-icons": "^5.4.0",
|
98 |
"react-markdown": "^9.0.1",
|
99 |
"react-resizable-panels": "^2.1.7",
|
100 |
"react-toastify": "^10.0.6",
|
|
|
104 |
"remix-island": "^0.2.0",
|
105 |
"remix-utils": "^7.7.0",
|
106 |
"shiki": "^1.24.0",
|
107 |
+
"tailwind-merge": "^2.6.0",
|
108 |
"unist-util-visit": "^5.0.0"
|
109 |
},
|
110 |
"devDependencies": {
|
111 |
"@blitz/eslint-plugin": "0.1.0",
|
112 |
"@cloudflare/workers-types": "^4.20241127.0",
|
113 |
+
"@iconify-json/ph": "^1.2.1",
|
114 |
+
"@iconify/types": "^2.0.0",
|
115 |
"@remix-run/dev": "^2.15.0",
|
116 |
"@types/diff": "^5.2.3",
|
117 |
"@types/dom-speech-recognition": "^0.0.4",
|
@@ -77,9 +77,9 @@ importers:
|
|
77 |
'@codemirror/view':
|
78 |
specifier: ^6.35.0
|
79 |
version: 6.35.0
|
80 |
-
'@
|
81 |
-
specifier: ^
|
82 |
-
version:
|
83 |
'@iconify-json/svg-spinners':
|
84 |
specifier: ^1.2.1
|
85 |
version: 1.2.1
|
@@ -149,6 +149,9 @@ importers:
|
|
149 |
chalk:
|
150 |
specifier: ^5.4.1
|
151 |
version: 5.4.1
|
|
|
|
|
|
|
152 |
date-fns:
|
153 |
specifier: ^3.6.0
|
154 |
version: 3.6.0
|
@@ -200,6 +203,9 @@ importers:
|
|
200 |
react-hotkeys-hook:
|
201 |
specifier: ^4.6.1
|
202 |
version: 4.6.1([email protected]([email protected]))([email protected])
|
|
|
|
|
|
|
203 |
react-markdown:
|
204 |
specifier: ^9.0.1
|
205 |
version: 9.0.1(@types/[email protected])([email protected])
|
@@ -227,6 +233,9 @@ importers:
|
|
227 |
shiki:
|
228 |
specifier: ^1.24.0
|
229 |
version: 1.24.0
|
|
|
|
|
|
|
230 |
unist-util-visit:
|
231 |
specifier: ^5.0.0
|
232 |
version: 5.0.0
|
@@ -237,6 +246,12 @@ importers:
|
|
237 |
'@cloudflare/workers-types':
|
238 |
specifier: ^4.20241127.0
|
239 |
version: 4.20241127.0
|
|
|
|
|
|
|
|
|
|
|
|
|
240 |
'@remix-run/dev':
|
241 |
specifier: ^2.15.0
|
242 |
version: 2.15.0(@remix-run/[email protected]([email protected]([email protected]))([email protected])([email protected]))(@types/[email protected])([email protected])([email protected])([email protected](@types/[email protected])([email protected]))([email protected](@cloudflare/[email protected]))
|
@@ -1437,9 +1452,22 @@ packages:
|
|
1437 |
react: '>=16.8.0'
|
1438 |
react-dom: '>=16.8.0'
|
1439 |
|
|
|
|
|
|
|
|
|
|
|
|
|
1440 |
'@floating-ui/[email protected]':
|
1441 |
resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==}
|
1442 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1443 |
'@humanfs/[email protected]':
|
1444 |
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
1445 |
engines: {node: '>=18.18.0'}
|
@@ -1997,6 +2025,40 @@ packages:
|
|
1997 |
'@radix-ui/[email protected]':
|
1998 |
resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==}
|
1999 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2000 |
'@remix-run/[email protected]':
|
2001 |
resolution: {integrity: sha512-3FjiON0BmEH3fwGdmP6eEf9TL5BejCt9LOMnszefDGdwY7kgXCodJNr8TAYseor6m7LlC4xgSkgkgj/YRIZTGA==}
|
2002 |
engines: {node: '>=18.0.0'}
|
@@ -2398,6 +2460,18 @@ packages:
|
|
2398 |
peerDependencies:
|
2399 |
eslint: '>=8.40.0'
|
2400 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2401 |
'@types/[email protected]':
|
2402 |
resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==}
|
2403 |
|
@@ -4965,6 +5039,11 @@ packages:
|
|
4965 |
react: '>=16.8.1'
|
4966 |
react-dom: '>=16.8.1'
|
4967 |
|
|
|
|
|
|
|
|
|
|
|
4968 | |
4969 |
resolution: {integrity: sha512-186Gw/vF1uRkydbsOIkcGXw7aHq0sZOCRFFjGrr7b9+nVZg4UfA4enXCaxm4fUzecU38sWfrNDitGhshuU7rdg==}
|
4970 |
peerDependencies:
|
@@ -5559,6 +5638,12 @@ packages:
|
|
5559 |
resolution: {integrity: sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==}
|
5560 |
engines: {node: ^14.18.0 || >=16.0.0}
|
5561 |
|
|
|
|
|
|
|
|
|
|
|
|
|
5562 | |
5563 |
resolution: {integrity: sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==}
|
5564 |
|
@@ -7372,8 +7457,25 @@ snapshots:
|
|
7372 |
react: 18.3.1
|
7373 |
react-dom: 18.3.1([email protected])
|
7374 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
7375 |
'@floating-ui/[email protected]': {}
|
7376 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
7377 |
'@humanfs/[email protected]': {}
|
7378 |
|
7379 |
'@humanfs/[email protected]':
|
@@ -7980,6 +8082,49 @@ snapshots:
|
|
7980 |
|
7981 |
'@radix-ui/[email protected]': {}
|
7982 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
7983 |
'@remix-run/[email protected](@cloudflare/[email protected])([email protected])':
|
7984 |
dependencies:
|
7985 |
'@cloudflare/workers-types': 4.20241127.0
|
@@ -8543,6 +8688,18 @@ snapshots:
|
|
8543 |
- supports-color
|
8544 |
- typescript
|
8545 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
8546 |
'@types/[email protected]':
|
8547 |
dependencies:
|
8548 |
'@types/estree': 1.0.6
|
@@ -11823,6 +11980,10 @@ snapshots:
|
|
11823 |
react: 18.3.1
|
11824 |
react-dom: 18.3.1([email protected])
|
11825 |
|
|
|
|
|
|
|
|
|
11826 | |
11827 |
dependencies:
|
11828 |
'@types/hast': 3.0.4
|
@@ -12456,6 +12617,10 @@ snapshots:
|
|
12456 |
'@pkgr/core': 0.1.1
|
12457 |
tslib: 2.8.1
|
12458 |
|
|
|
|
|
|
|
|
|
12459 | |
12460 |
dependencies:
|
12461 |
chownr: 1.1.4
|
|
|
77 |
'@codemirror/view':
|
78 |
specifier: ^6.35.0
|
79 |
version: 6.35.0
|
80 |
+
'@headlessui/react':
|
81 |
+
specifier: ^2.2.0
|
82 |
+
version: 2.2.0([email protected].1([email protected]))([email protected])
|
83 |
'@iconify-json/svg-spinners':
|
84 |
specifier: ^1.2.1
|
85 |
version: 1.2.1
|
|
|
149 |
chalk:
|
150 |
specifier: ^5.4.1
|
151 |
version: 5.4.1
|
152 |
+
clsx:
|
153 |
+
specifier: ^2.1.1
|
154 |
+
version: 2.1.1
|
155 |
date-fns:
|
156 |
specifier: ^3.6.0
|
157 |
version: 3.6.0
|
|
|
203 |
react-hotkeys-hook:
|
204 |
specifier: ^4.6.1
|
205 |
version: 4.6.1([email protected]([email protected]))([email protected])
|
206 |
+
react-icons:
|
207 |
+
specifier: ^5.4.0
|
208 |
+
version: 5.4.0([email protected])
|
209 |
react-markdown:
|
210 |
specifier: ^9.0.1
|
211 |
version: 9.0.1(@types/[email protected])([email protected])
|
|
|
233 |
shiki:
|
234 |
specifier: ^1.24.0
|
235 |
version: 1.24.0
|
236 |
+
tailwind-merge:
|
237 |
+
specifier: ^2.6.0
|
238 |
+
version: 2.6.0
|
239 |
unist-util-visit:
|
240 |
specifier: ^5.0.0
|
241 |
version: 5.0.0
|
|
|
246 |
'@cloudflare/workers-types':
|
247 |
specifier: ^4.20241127.0
|
248 |
version: 4.20241127.0
|
249 |
+
'@iconify-json/ph':
|
250 |
+
specifier: ^1.2.1
|
251 |
+
version: 1.2.1
|
252 |
+
'@iconify/types':
|
253 |
+
specifier: ^2.0.0
|
254 |
+
version: 2.0.0
|
255 |
'@remix-run/dev':
|
256 |
specifier: ^2.15.0
|
257 |
version: 2.15.0(@remix-run/[email protected]([email protected]([email protected]))([email protected])([email protected]))(@types/[email protected])([email protected])([email protected])([email protected](@types/[email protected])([email protected]))([email protected](@cloudflare/[email protected]))
|
|
|
1452 |
react: '>=16.8.0'
|
1453 |
react-dom: '>=16.8.0'
|
1454 |
|
1455 |
+
'@floating-ui/[email protected]':
|
1456 |
+
resolution: {integrity: sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==}
|
1457 |
+
peerDependencies:
|
1458 |
+
react: '>=16.8.0'
|
1459 |
+
react-dom: '>=16.8.0'
|
1460 |
+
|
1461 |
'@floating-ui/[email protected]':
|
1462 |
resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==}
|
1463 |
|
1464 |
+
'@headlessui/[email protected]':
|
1465 |
+
resolution: {integrity: sha512-RzCEg+LXsuI7mHiSomsu/gBJSjpupm6A1qIZ5sWjd7JhARNlMiSA4kKfJpCKwU9tE+zMRterhhrP74PvfJrpXQ==}
|
1466 |
+
engines: {node: '>=10'}
|
1467 |
+
peerDependencies:
|
1468 |
+
react: ^18 || ^19 || ^19.0.0-rc
|
1469 |
+
react-dom: ^18 || ^19 || ^19.0.0-rc
|
1470 |
+
|
1471 |
'@humanfs/[email protected]':
|
1472 |
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
1473 |
engines: {node: '>=18.18.0'}
|
|
|
2025 |
'@radix-ui/[email protected]':
|
2026 |
resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==}
|
2027 |
|
2028 |
+
'@react-aria/[email protected]':
|
2029 |
+
resolution: {integrity: sha512-bix9Bu1Ue7RPcYmjwcjhB14BMu2qzfJ3tMQLqDc9pweJA66nOw8DThy3IfVr8Z7j2PHktOLf9kcbiZpydKHqzg==}
|
2030 |
+
peerDependencies:
|
2031 |
+
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
|
2032 |
+
react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
|
2033 |
+
|
2034 |
+
'@react-aria/[email protected]':
|
2035 |
+
resolution: {integrity: sha512-0qR1atBIWrb7FzQ+Tmr3s8uH5mQdyRH78n0krYaG8tng9+u1JlSi8DGRSaC9ezKyNB84m7vHT207xnHXGeJ3Fg==}
|
2036 |
+
peerDependencies:
|
2037 |
+
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
|
2038 |
+
react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
|
2039 |
+
|
2040 |
+
'@react-aria/[email protected]':
|
2041 |
+
resolution: {integrity: sha512-GQygZaGlmYjmYM+tiNBA5C6acmiDWF52Nqd40bBp0Znk4M4hP+LTmI0lpI1BuKMw45T8RIhrAsICIfKwZvi2Gg==}
|
2042 |
+
engines: {node: '>= 12'}
|
2043 |
+
peerDependencies:
|
2044 |
+
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
|
2045 |
+
|
2046 |
+
'@react-aria/[email protected]':
|
2047 |
+
resolution: {integrity: sha512-p681OtApnKOdbeN8ITfnnYqfdHS0z7GE+4l8EXlfLnr70Rp/9xicBO6d2rU+V/B3JujDw2gPWxYKEnEeh0CGCw==}
|
2048 |
+
peerDependencies:
|
2049 |
+
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
|
2050 |
+
react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
|
2051 |
+
|
2052 |
+
'@react-stately/[email protected]':
|
2053 |
+
resolution: {integrity: sha512-iMQSGcpaecghDIh3mZEpZfoFH3ExBwTtuBEcvZ2XnGzCgQjeYXcMdIUwAfVQLXFTdHUHGF6Gu6/dFrYsCzySBQ==}
|
2054 |
+
peerDependencies:
|
2055 |
+
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
|
2056 |
+
|
2057 |
+
'@react-types/[email protected]':
|
2058 |
+
resolution: {integrity: sha512-gvznmLhi6JPEf0bsq7SwRYTHAKKq/wcmKqFez9sRdbED+SPMUmK5omfZ6w3EwUFQHbYUa4zPBYedQ7Knv70RMw==}
|
2059 |
+
peerDependencies:
|
2060 |
+
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
|
2061 |
+
|
2062 |
'@remix-run/[email protected]':
|
2063 |
resolution: {integrity: sha512-3FjiON0BmEH3fwGdmP6eEf9TL5BejCt9LOMnszefDGdwY7kgXCodJNr8TAYseor6m7LlC4xgSkgkgj/YRIZTGA==}
|
2064 |
engines: {node: '>=18.0.0'}
|
|
|
2460 |
peerDependencies:
|
2461 |
eslint: '>=8.40.0'
|
2462 |
|
2463 |
+
'@swc/[email protected]':
|
2464 |
+
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
2465 |
+
|
2466 |
+
'@tanstack/[email protected]':
|
2467 |
+
resolution: {integrity: sha512-OuFzMXPF4+xZgx8UzJha0AieuMihhhaWG0tCqpp6tDzlFwOmNBPYMuLOtMJ1Tr4pXLHmgjcWhG6RlknY2oNTdQ==}
|
2468 |
+
peerDependencies:
|
2469 |
+
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
2470 |
+
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
2471 |
+
|
2472 |
+
'@tanstack/[email protected]':
|
2473 |
+
resolution: {integrity: sha512-vTtpNt7mKCiZ1pwU9hfKPhpdVO2sVzFQsxoVBGtOSHxlrRRzYr8iQ2TlwbAcRYCcEiZ9ECAM8kBzH0v2+VzfKw==}
|
2474 |
+
|
2475 |
'@types/[email protected]':
|
2476 |
resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==}
|
2477 |
|
|
|
5039 |
react: '>=16.8.1'
|
5040 |
react-dom: '>=16.8.1'
|
5041 |
|
5042 | |
5043 |
+
resolution: {integrity: sha512-7eltJxgVt7X64oHh6wSWNwwbKTCtMfK35hcjvJS0yxEAhPM8oUKdS3+kqaW1vicIltw+kR2unHaa12S9pPALoQ==}
|
5044 |
+
peerDependencies:
|
5045 |
+
react: '*'
|
5046 |
+
|
5047 | |
5048 |
resolution: {integrity: sha512-186Gw/vF1uRkydbsOIkcGXw7aHq0sZOCRFFjGrr7b9+nVZg4UfA4enXCaxm4fUzecU38sWfrNDitGhshuU7rdg==}
|
5049 |
peerDependencies:
|
|
|
5638 |
resolution: {integrity: sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==}
|
5639 |
engines: {node: ^14.18.0 || >=16.0.0}
|
5640 |
|
5641 | |
5642 |
+
resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==}
|
5643 |
+
|
5644 | |
5645 |
+
resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==}
|
5646 |
+
|
5647 | |
5648 |
resolution: {integrity: sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==}
|
5649 |
|
|
|
7457 |
react: 18.3.1
|
7458 |
react-dom: 18.3.1([email protected])
|
7459 |
|
7460 |
+
'@floating-ui/[email protected]([email protected]([email protected]))([email protected])':
|
7461 |
+
dependencies:
|
7462 |
+
'@floating-ui/react-dom': 2.1.2([email protected]([email protected]))([email protected])
|
7463 |
+
'@floating-ui/utils': 0.2.8
|
7464 |
+
react: 18.3.1
|
7465 |
+
react-dom: 18.3.1([email protected])
|
7466 |
+
tabbable: 6.2.0
|
7467 |
+
|
7468 |
'@floating-ui/[email protected]': {}
|
7469 |
|
7470 |
+
'@headlessui/[email protected]([email protected]([email protected]))([email protected])':
|
7471 |
+
dependencies:
|
7472 |
+
'@floating-ui/react': 0.26.28([email protected]([email protected]))([email protected])
|
7473 |
+
'@react-aria/focus': 3.19.1([email protected]([email protected]))([email protected])
|
7474 |
+
'@react-aria/interactions': 3.23.0([email protected]([email protected]))([email protected])
|
7475 |
+
'@tanstack/react-virtual': 3.11.2([email protected]([email protected]))([email protected])
|
7476 |
+
react: 18.3.1
|
7477 |
+
react-dom: 18.3.1([email protected])
|
7478 |
+
|
7479 |
'@humanfs/[email protected]': {}
|
7480 |
|
7481 |
'@humanfs/[email protected]':
|
|
|
8082 |
|
8083 |
'@radix-ui/[email protected]': {}
|
8084 |
|
8085 |
+
'@react-aria/[email protected]([email protected]([email protected]))([email protected])':
|
8086 |
+
dependencies:
|
8087 |
+
'@react-aria/interactions': 3.23.0([email protected]([email protected]))([email protected])
|
8088 |
+
'@react-aria/utils': 3.27.0([email protected]([email protected]))([email protected])
|
8089 |
+
'@react-types/shared': 3.27.0([email protected])
|
8090 |
+
'@swc/helpers': 0.5.15
|
8091 |
+
clsx: 2.1.1
|
8092 |
+
react: 18.3.1
|
8093 |
+
react-dom: 18.3.1([email protected])
|
8094 |
+
|
8095 |
+
'@react-aria/[email protected]([email protected]([email protected]))([email protected])':
|
8096 |
+
dependencies:
|
8097 |
+
'@react-aria/ssr': 3.9.7([email protected])
|
8098 |
+
'@react-aria/utils': 3.27.0([email protected]([email protected]))([email protected])
|
8099 |
+
'@react-types/shared': 3.27.0([email protected])
|
8100 |
+
'@swc/helpers': 0.5.15
|
8101 |
+
react: 18.3.1
|
8102 |
+
react-dom: 18.3.1([email protected])
|
8103 |
+
|
8104 |
+
'@react-aria/[email protected]([email protected])':
|
8105 |
+
dependencies:
|
8106 |
+
'@swc/helpers': 0.5.15
|
8107 |
+
react: 18.3.1
|
8108 |
+
|
8109 |
+
'@react-aria/[email protected]([email protected]([email protected]))([email protected])':
|
8110 |
+
dependencies:
|
8111 |
+
'@react-aria/ssr': 3.9.7([email protected])
|
8112 |
+
'@react-stately/utils': 3.10.5([email protected])
|
8113 |
+
'@react-types/shared': 3.27.0([email protected])
|
8114 |
+
'@swc/helpers': 0.5.15
|
8115 |
+
clsx: 2.1.1
|
8116 |
+
react: 18.3.1
|
8117 |
+
react-dom: 18.3.1([email protected])
|
8118 |
+
|
8119 |
+
'@react-stately/[email protected]([email protected])':
|
8120 |
+
dependencies:
|
8121 |
+
'@swc/helpers': 0.5.15
|
8122 |
+
react: 18.3.1
|
8123 |
+
|
8124 |
+
'@react-types/[email protected]([email protected])':
|
8125 |
+
dependencies:
|
8126 |
+
react: 18.3.1
|
8127 |
+
|
8128 |
'@remix-run/[email protected](@cloudflare/[email protected])([email protected])':
|
8129 |
dependencies:
|
8130 |
'@cloudflare/workers-types': 4.20241127.0
|
|
|
8688 |
- supports-color
|
8689 |
- typescript
|
8690 |
|
8691 |
+
'@swc/[email protected]':
|
8692 |
+
dependencies:
|
8693 |
+
tslib: 2.8.1
|
8694 |
+
|
8695 |
+
'@tanstack/[email protected]([email protected]([email protected]))([email protected])':
|
8696 |
+
dependencies:
|
8697 |
+
'@tanstack/virtual-core': 3.11.2
|
8698 |
+
react: 18.3.1
|
8699 |
+
react-dom: 18.3.1([email protected])
|
8700 |
+
|
8701 |
+
'@tanstack/[email protected]': {}
|
8702 |
+
|
8703 |
'@types/[email protected]':
|
8704 |
dependencies:
|
8705 |
'@types/estree': 1.0.6
|
|
|
11980 |
react: 18.3.1
|
11981 |
react-dom: 18.3.1([email protected])
|
11982 |
|
11983 | |
11984 |
+
dependencies:
|
11985 |
+
react: 18.3.1
|
11986 |
+
|
11987 | |
11988 |
dependencies:
|
11989 |
'@types/hast': 3.0.4
|
|
|
12617 |
'@pkgr/core': 0.1.1
|
12618 |
tslib: 2.8.1
|
12619 |
|
12620 |
+
[email protected]: {}
|
12621 |
+
|
12622 |
+
[email protected]: {}
|
12623 |
+
|
12624 | |
12625 |
dependencies:
|
12626 |
chownr: 1.1.4
|
@@ -1,23 +1,43 @@
|
|
1 |
import { globSync } from 'fast-glob';
|
2 |
import fs from 'node:fs/promises';
|
3 |
-
import { basename } from 'node:path';
|
4 |
import { defineConfig, presetIcons, presetUno, transformerDirectives } from 'unocss';
|
|
|
5 |
|
6 |
-
|
|
|
|
|
|
|
|
|
7 |
|
8 |
const collectionName = 'bolt';
|
9 |
|
10 |
-
const customIconCollection =
|
11 |
-
(
|
12 |
-
|
|
|
13 |
|
14 |
-
|
15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
16 |
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
)
|
|
|
21 |
|
22 |
const BASE_COLORS = {
|
23 |
white: '#FFFFFF',
|
@@ -98,9 +118,7 @@ const COLOR_PRIMITIVES = {
|
|
98 |
};
|
99 |
|
100 |
export default defineConfig({
|
101 |
-
safelist: [
|
102 |
-
...Object.keys(customIconCollection[collectionName]||{}).map(x=>`i-bolt:${x}`)
|
103 |
-
],
|
104 |
shortcuts: {
|
105 |
'bolt-ease-cubic-bezier': 'ease-[cubic-bezier(0.4,0,0.2,1)]',
|
106 |
'transition-theme': 'transition-[background-color,border-color,color] duration-150 bolt-ease-cubic-bezier',
|
@@ -242,9 +260,27 @@ export default defineConfig({
|
|
242 |
presetIcons({
|
243 |
warn: true,
|
244 |
collections: {
|
245 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
246 |
},
|
247 |
-
unit: 'em',
|
248 |
}),
|
249 |
],
|
250 |
});
|
|
|
1 |
import { globSync } from 'fast-glob';
|
2 |
import fs from 'node:fs/promises';
|
3 |
+
import { basename, join } from 'node:path';
|
4 |
import { defineConfig, presetIcons, presetUno, transformerDirectives } from 'unocss';
|
5 |
+
import type { IconifyJSON } from '@iconify/types';
|
6 |
|
7 |
+
// Debug: Log the current working directory and icon paths
|
8 |
+
console.log('CWD:', process.cwd());
|
9 |
+
|
10 |
+
const iconPaths = globSync(join(process.cwd(), 'public/icons/*.svg'));
|
11 |
+
console.log('Found icons:', iconPaths);
|
12 |
|
13 |
const collectionName = 'bolt';
|
14 |
|
15 |
+
const customIconCollection = {
|
16 |
+
[collectionName]: iconPaths.reduce(
|
17 |
+
(acc, iconPath) => {
|
18 |
+
const [iconName] = basename(iconPath).split('.');
|
19 |
|
20 |
+
acc[iconName] = async () => {
|
21 |
+
try {
|
22 |
+
const content = await fs.readFile(iconPath, 'utf8');
|
23 |
+
return content
|
24 |
+
.replace(/fill="[^"]*"/g, '')
|
25 |
+
.replace(/fill='[^']*'/g, '')
|
26 |
+
.replace(/width="[^"]*"/g, '')
|
27 |
+
.replace(/height="[^"]*"/g, '')
|
28 |
+
.replace(/viewBox="[^"]*"/g, 'viewBox="0 0 24 24"')
|
29 |
+
.replace(/<svg([^>]*)>/, '<svg $1 fill="currentColor">');
|
30 |
+
} catch (error) {
|
31 |
+
console.error(`Error loading icon ${iconName}:`, error);
|
32 |
+
return '';
|
33 |
+
}
|
34 |
+
};
|
35 |
|
36 |
+
return acc;
|
37 |
+
},
|
38 |
+
{} as Record<string, () => Promise<string>>,
|
39 |
+
),
|
40 |
+
};
|
41 |
|
42 |
const BASE_COLORS = {
|
43 |
white: '#FFFFFF',
|
|
|
118 |
};
|
119 |
|
120 |
export default defineConfig({
|
121 |
+
safelist: [...Object.keys(customIconCollection[collectionName] || {}).map((x) => `i-bolt:${x}`)],
|
|
|
|
|
122 |
shortcuts: {
|
123 |
'bolt-ease-cubic-bezier': 'ease-[cubic-bezier(0.4,0,0.2,1)]',
|
124 |
'transition-theme': 'transition-[background-color,border-color,color] duration-150 bolt-ease-cubic-bezier',
|
|
|
260 |
presetIcons({
|
261 |
warn: true,
|
262 |
collections: {
|
263 |
+
bolt: customIconCollection.bolt,
|
264 |
+
ph: async () => {
|
265 |
+
const icons = await import('@iconify-json/ph/icons.json');
|
266 |
+
return icons.default as IconifyJSON;
|
267 |
+
},
|
268 |
+
},
|
269 |
+
extraProperties: {
|
270 |
+
display: 'inline-block',
|
271 |
+
'vertical-align': 'middle',
|
272 |
+
width: '24px',
|
273 |
+
height: '24px',
|
274 |
+
},
|
275 |
+
customizations: {
|
276 |
+
customize(props) {
|
277 |
+
return {
|
278 |
+
...props,
|
279 |
+
width: '24px',
|
280 |
+
height: '24px',
|
281 |
+
};
|
282 |
+
},
|
283 |
},
|
|
|
284 |
}),
|
285 |
],
|
286 |
});
|