Spaces:
Running
Running
Upload 1138 files
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .gitattributes +12 -0
- 22dc0511fe69_add_toolsource_table.cpython-311.pyc +0 -0
- 2ea570019b8f_add_apikey_table.cpython-311.pyc +0 -0
- 2ea570019b8f_add_apikey_table.py +58 -0
- 4af13678b83c_add_toolsource_table.cpython-311.pyc +0 -0
- 4af13678b83c_add_toolsource_table.py +50 -0
- APIKeyManager.tsx +169 -0
- AUTHORS +6 -0
- Activate.ps1 +241 -0
- Artifact.tsx +263 -0
- AssistantMessage.tsx +113 -0
- AvatarDropdown.tsx +158 -0
- BaseChat.module.scss +47 -0
- BaseChat.tsx +630 -0
- CITATION.cff +21 -0
- CODE_OF_CONDUCT.md +40 -0
- CONTRIBUTING.md +38 -0
- CObjects.cpp +157 -0
- Chat.client.tsx +524 -0
- ChatAlert.tsx +108 -0
- ChatInterface.tsx +30 -0
- CloudProvidersTab.tsx +305 -0
- CodeBlock.module.scss +10 -0
- CodeBlock.tsx +82 -0
- ConnectionForm.tsx +180 -0
- ConnectionsTab.tsx +28 -0
- ControlPanel.tsx +555 -0
- CreateBranchDialog.tsx +150 -0
- DataTab.tsx +452 -0
- DebugTab.tsx +2045 -0
- DraggableTabList.tsx +163 -0
- EventLogsTab.tsx +1013 -0
- ExamplePrompts.tsx +36 -0
- ExportChatButton.tsx +13 -0
- FeaturesTab.tsx +295 -0
- FilePreview.tsx +35 -0
- GitCloneButton.tsx +181 -0
- GitHub.ts +95 -0
- GithubConnection.tsx +557 -0
- INSTALLER +1 -0
- ImportButtons.tsx +96 -0
- ImportFolderButton.tsx +141 -0
- LICENSE +29 -0
- LICENSE.APACHE2 +202 -0
- LICENSE.MIT +20 -0
- LICENSE.PSF +47 -0
- LICENSE.md +31 -0
- LICENSE.txt +20 -0
- Layout.tsx +41 -0
- LocalProvidersTab.tsx +777 -0
.gitattributes
CHANGED
@@ -33,3 +33,15 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
36 |
+
_greenlet.cpython-39-darwin.so filter=lfs diff=lfs merge=lfs -text
|
37 |
+
_psycopg.cpython-39-darwin.so filter=lfs diff=lfs merge=lfs -text
|
38 |
+
_pydantic_core.cpython-39-darwin.so filter=lfs diff=lfs merge=lfs -text
|
39 |
+
collections.cpython-39-darwin.so filter=lfs diff=lfs merge=lfs -text
|
40 |
+
immutabledict.cpython-39-darwin.so filter=lfs diff=lfs merge=lfs -text
|
41 |
+
pgproto.cpython-39-darwin.so filter=lfs diff=lfs merge=lfs -text
|
42 |
+
processors.cpython-39-darwin.so filter=lfs diff=lfs merge=lfs -text
|
43 |
+
protocol.cpython-39-darwin.so filter=lfs diff=lfs merge=lfs -text
|
44 |
+
python3.9 filter=lfs diff=lfs merge=lfs -text
|
45 |
+
resultproxy.cpython-39-darwin.so filter=lfs diff=lfs merge=lfs -text
|
46 |
+
t64.exe filter=lfs diff=lfs merge=lfs -text
|
47 |
+
util.cpython-39-darwin.so filter=lfs diff=lfs merge=lfs -text
|
22dc0511fe69_add_toolsource_table.cpython-311.pyc
ADDED
Binary file (1.33 kB). View file
|
|
2ea570019b8f_add_apikey_table.cpython-311.pyc
ADDED
Binary file (4.54 kB). View file
|
|
2ea570019b8f_add_apikey_table.py
ADDED
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""Add ApiKey table
|
2 |
+
|
3 |
+
Revision ID: 2ea570019b8f
|
4 |
+
Revises: 4af13678b83c
|
5 |
+
Create Date: 2025-05-03 18:56:32.989446
|
6 |
+
|
7 |
+
"""
|
8 |
+
from typing import Sequence, Union
|
9 |
+
|
10 |
+
from alembic import op
|
11 |
+
import sqlalchemy as sa
|
12 |
+
from sqlalchemy.dialects import postgresql
|
13 |
+
|
14 |
+
# revision identifiers, used by Alembic.
|
15 |
+
revision: str = '2ea570019b8f'
|
16 |
+
down_revision: Union[str, None] = '4af13678b83c'
|
17 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
18 |
+
depends_on: Union[str, Sequence[str], None] = None
|
19 |
+
|
20 |
+
|
21 |
+
def upgrade() -> None:
|
22 |
+
"""Upgrade schema."""
|
23 |
+
# ### commands auto generated by Alembic - adjusted ###
|
24 |
+
op.create_table('api_keys',
|
25 |
+
sa.Column('id', sa.UUID(), nullable=False),
|
26 |
+
sa.Column('user_id', sa.UUID(), nullable=False),
|
27 |
+
sa.Column('name', sa.String(), nullable=False),
|
28 |
+
sa.Column('description', sa.Text(), nullable=True),
|
29 |
+
sa.Column('key_prefix', sa.String(length=8), nullable=False),
|
30 |
+
sa.Column('hashed_key', sa.String(), nullable=False),
|
31 |
+
sa.Column('scopes', postgresql.ARRAY(sa.String()), nullable=True),
|
32 |
+
sa.Column('is_active', sa.Boolean(), nullable=False),
|
33 |
+
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
34 |
+
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True),
|
35 |
+
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_api_keys_user_id_users_id')),
|
36 |
+
sa.PrimaryKeyConstraint('id', name=op.f('pk_api_keys'))
|
37 |
+
)
|
38 |
+
with op.batch_alter_table('api_keys', schema=None) as batch_op:
|
39 |
+
batch_op.create_index(batch_op.f('ix_api_keys_hashed_key'), ['hashed_key'], unique=False)
|
40 |
+
batch_op.create_index(batch_op.f('ix_api_keys_key_prefix'), ['key_prefix'], unique=True)
|
41 |
+
batch_op.create_index(batch_op.f('ix_api_keys_user_id'), ['user_id'], unique=False)
|
42 |
+
|
43 |
+
# Removed incorrect drop/alter table commands for other tables
|
44 |
+
# ### end Alembic commands ###
|
45 |
+
|
46 |
+
|
47 |
+
def downgrade() -> None:
|
48 |
+
"""Downgrade schema."""
|
49 |
+
# ### commands auto generated by Alembic - adjusted ###
|
50 |
+
with op.batch_alter_table('api_keys', schema=None) as batch_op:
|
51 |
+
batch_op.drop_index(batch_op.f('ix_api_keys_user_id'))
|
52 |
+
batch_op.drop_index(batch_op.f('ix_api_keys_key_prefix'))
|
53 |
+
batch_op.drop_index(batch_op.f('ix_api_keys_hashed_key'))
|
54 |
+
|
55 |
+
op.drop_table('api_keys')
|
56 |
+
# Removed incorrect create/alter table commands for other tables
|
57 |
+
# ### end Alembic commands ###
|
58 |
+
|
4af13678b83c_add_toolsource_table.cpython-311.pyc
ADDED
Binary file (3.61 kB). View file
|
|
4af13678b83c_add_toolsource_table.py
ADDED
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""Add ToolSource table
|
2 |
+
|
3 |
+
Revision ID: 4af13678b83c
|
4 |
+
Revises: e2ca2546bf71
|
5 |
+
Create Date: 2025-05-03 18:51:11.601728
|
6 |
+
|
7 |
+
"""
|
8 |
+
from typing import Sequence, Union
|
9 |
+
|
10 |
+
from alembic import op
|
11 |
+
import sqlalchemy as sa
|
12 |
+
from sqlalchemy.dialects import postgresql
|
13 |
+
|
14 |
+
# revision identifiers, used by Alembic.
|
15 |
+
revision: str = '4af13678b83c'
|
16 |
+
down_revision: Union[str, None] = 'e2ca2546bf71'
|
17 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
18 |
+
depends_on: Union[str, Sequence[str], None] = None
|
19 |
+
|
20 |
+
|
21 |
+
def upgrade() -> None:
|
22 |
+
"""Upgrade schema."""
|
23 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
24 |
+
# Manually corrected: Remove incorrect drop commands and add create_table for tool_sources
|
25 |
+
op.create_table('tool_sources',
|
26 |
+
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
27 |
+
sa.Column('github_url', sa.String(), nullable=False),
|
28 |
+
sa.Column('description', sa.Text(), nullable=True),
|
29 |
+
sa.Column('status', sa.String(), nullable=False, server_default='active'), # Match default from model
|
30 |
+
sa.Column('last_checked_at', sa.DateTime(timezone=True), nullable=True),
|
31 |
+
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
32 |
+
sa.PrimaryKeyConstraint('id', name=op.f('pk_tool_sources'))
|
33 |
+
)
|
34 |
+
with op.batch_alter_table('tool_sources', schema=None) as batch_op:
|
35 |
+
batch_op.create_index(batch_op.f('ix_tool_sources_github_url'), ['github_url'], unique=True)
|
36 |
+
batch_op.create_index(batch_op.f('ix_tool_sources_status'), ['status'], unique=False)
|
37 |
+
|
38 |
+
# ### end Alembic commands ###
|
39 |
+
|
40 |
+
|
41 |
+
def downgrade() -> None:
|
42 |
+
"""Downgrade schema."""
|
43 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
44 |
+
with op.batch_alter_table('tool_sources', schema=None) as batch_op:
|
45 |
+
batch_op.drop_index(batch_op.f('ix_tool_sources_status'))
|
46 |
+
batch_op.drop_index(batch_op.f('ix_tool_sources_github_url'))
|
47 |
+
|
48 |
+
op.drop_table('tool_sources')
|
49 |
+
# ### end Alembic commands ###
|
50 |
+
|
APIKeyManager.tsx
ADDED
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useState, useEffect, useCallback } from 'react';
|
2 |
+
import { IconButton } from '~/components/ui/IconButton';
|
3 |
+
import type { ProviderInfo } from '~/types/model';
|
4 |
+
import Cookies from 'js-cookie';
|
5 |
+
|
6 |
+
interface APIKeyManagerProps {
|
7 |
+
provider: ProviderInfo;
|
8 |
+
apiKey: string;
|
9 |
+
setApiKey: (key: string) => void;
|
10 |
+
getApiKeyLink?: string;
|
11 |
+
labelForGetApiKey?: string;
|
12 |
+
}
|
13 |
+
|
14 |
+
// cache which stores whether the provider's API key is set via environment variable
|
15 |
+
const providerEnvKeyStatusCache: Record<string, boolean> = {};
|
16 |
+
|
17 |
+
const apiKeyMemoizeCache: { [k: string]: Record<string, string> } = {};
|
18 |
+
|
19 |
+
export function getApiKeysFromCookies() {
|
20 |
+
const storedApiKeys = Cookies.get('apiKeys');
|
21 |
+
let parsedKeys: Record<string, string> = {};
|
22 |
+
|
23 |
+
if (storedApiKeys) {
|
24 |
+
parsedKeys = apiKeyMemoizeCache[storedApiKeys];
|
25 |
+
|
26 |
+
if (!parsedKeys) {
|
27 |
+
parsedKeys = apiKeyMemoizeCache[storedApiKeys] = JSON.parse(storedApiKeys);
|
28 |
+
}
|
29 |
+
}
|
30 |
+
|
31 |
+
return parsedKeys;
|
32 |
+
}
|
33 |
+
|
34 |
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
35 |
+
export const APIKeyManager: React.FC<APIKeyManagerProps> = ({ provider, apiKey, setApiKey }) => {
|
36 |
+
const [isEditing, setIsEditing] = useState(false);
|
37 |
+
const [tempKey, setTempKey] = useState(apiKey);
|
38 |
+
const [isEnvKeySet, setIsEnvKeySet] = useState(false);
|
39 |
+
|
40 |
+
// Reset states and load saved key when provider changes
|
41 |
+
useEffect(() => {
|
42 |
+
// Load saved API key from cookies for this provider
|
43 |
+
const savedKeys = getApiKeysFromCookies();
|
44 |
+
const savedKey = savedKeys[provider.name] || '';
|
45 |
+
|
46 |
+
setTempKey(savedKey);
|
47 |
+
setApiKey(savedKey);
|
48 |
+
setIsEditing(false);
|
49 |
+
}, [provider.name]);
|
50 |
+
|
51 |
+
const checkEnvApiKey = useCallback(async () => {
|
52 |
+
// Check cache first
|
53 |
+
if (providerEnvKeyStatusCache[provider.name] !== undefined) {
|
54 |
+
setIsEnvKeySet(providerEnvKeyStatusCache[provider.name]);
|
55 |
+
return;
|
56 |
+
}
|
57 |
+
|
58 |
+
try {
|
59 |
+
const response = await fetch(`/api/check-env-key?provider=${encodeURIComponent(provider.name)}`);
|
60 |
+
const data = await response.json();
|
61 |
+
const isSet = (data as { isSet: boolean }).isSet;
|
62 |
+
|
63 |
+
// Cache the result
|
64 |
+
providerEnvKeyStatusCache[provider.name] = isSet;
|
65 |
+
setIsEnvKeySet(isSet);
|
66 |
+
} catch (error) {
|
67 |
+
console.error('Failed to check environment API key:', error);
|
68 |
+
setIsEnvKeySet(false);
|
69 |
+
}
|
70 |
+
}, [provider.name]);
|
71 |
+
|
72 |
+
useEffect(() => {
|
73 |
+
checkEnvApiKey();
|
74 |
+
}, [checkEnvApiKey]);
|
75 |
+
|
76 |
+
const handleSave = () => {
|
77 |
+
// Save to parent state
|
78 |
+
setApiKey(tempKey);
|
79 |
+
|
80 |
+
// Save to cookies
|
81 |
+
const currentKeys = getApiKeysFromCookies();
|
82 |
+
const newKeys = { ...currentKeys, [provider.name]: tempKey };
|
83 |
+
Cookies.set('apiKeys', JSON.stringify(newKeys));
|
84 |
+
|
85 |
+
setIsEditing(false);
|
86 |
+
};
|
87 |
+
|
88 |
+
return (
|
89 |
+
<div className="flex items-center justify-between py-3 px-1">
|
90 |
+
<div className="flex items-center gap-2 flex-1">
|
91 |
+
<div className="flex items-center gap-2">
|
92 |
+
<span className="text-sm font-medium text-bolt-elements-textSecondary">{provider?.name} API Key:</span>
|
93 |
+
{!isEditing && (
|
94 |
+
<div className="flex items-center gap-2">
|
95 |
+
{apiKey ? (
|
96 |
+
<>
|
97 |
+
<div className="i-ph:check-circle-fill text-green-500 w-4 h-4" />
|
98 |
+
<span className="text-xs text-green-500">Set via UI</span>
|
99 |
+
</>
|
100 |
+
) : isEnvKeySet ? (
|
101 |
+
<>
|
102 |
+
<div className="i-ph:check-circle-fill text-green-500 w-4 h-4" />
|
103 |
+
<span className="text-xs text-green-500">Set via environment variable</span>
|
104 |
+
</>
|
105 |
+
) : (
|
106 |
+
<>
|
107 |
+
<div className="i-ph:x-circle-fill text-red-500 w-4 h-4" />
|
108 |
+
<span className="text-xs text-red-500">Not Set (Please set via UI or ENV_VAR)</span>
|
109 |
+
</>
|
110 |
+
)}
|
111 |
+
</div>
|
112 |
+
)}
|
113 |
+
</div>
|
114 |
+
</div>
|
115 |
+
|
116 |
+
<div className="flex items-center gap-2 shrink-0">
|
117 |
+
{isEditing ? (
|
118 |
+
<div className="flex items-center gap-2">
|
119 |
+
<input
|
120 |
+
type="password"
|
121 |
+
value={tempKey}
|
122 |
+
placeholder="Enter API Key"
|
123 |
+
onChange={(e) => setTempKey(e.target.value)}
|
124 |
+
className="w-[300px] px-3 py-1.5 text-sm rounded border border-bolt-elements-borderColor
|
125 |
+
bg-bolt-elements-prompt-background text-bolt-elements-textPrimary
|
126 |
+
focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus"
|
127 |
+
/>
|
128 |
+
<IconButton
|
129 |
+
onClick={handleSave}
|
130 |
+
title="Save API Key"
|
131 |
+
className="bg-green-500/10 hover:bg-green-500/20 text-green-500"
|
132 |
+
>
|
133 |
+
<div className="i-ph:check w-4 h-4" />
|
134 |
+
</IconButton>
|
135 |
+
<IconButton
|
136 |
+
onClick={() => setIsEditing(false)}
|
137 |
+
title="Cancel"
|
138 |
+
className="bg-red-500/10 hover:bg-red-500/20 text-red-500"
|
139 |
+
>
|
140 |
+
<div className="i-ph:x w-4 h-4" />
|
141 |
+
</IconButton>
|
142 |
+
</div>
|
143 |
+
) : (
|
144 |
+
<>
|
145 |
+
{
|
146 |
+
<IconButton
|
147 |
+
onClick={() => setIsEditing(true)}
|
148 |
+
title="Edit API Key"
|
149 |
+
className="bg-blue-500/10 hover:bg-blue-500/20 text-blue-500"
|
150 |
+
>
|
151 |
+
<div className="i-ph:pencil-simple w-4 h-4" />
|
152 |
+
</IconButton>
|
153 |
+
}
|
154 |
+
{provider?.getApiKeyLink && !apiKey && (
|
155 |
+
<IconButton
|
156 |
+
onClick={() => window.open(provider?.getApiKeyLink)}
|
157 |
+
title="Get API Key"
|
158 |
+
className="bg-purple-500/10 hover:bg-purple-500/20 text-purple-500 flex items-center gap-2"
|
159 |
+
>
|
160 |
+
<span className="text-xs whitespace-nowrap">{provider?.labelForGetApiKey || 'Get API Key'}</span>
|
161 |
+
<div className={`${provider?.icon || 'i-ph:key'} w-4 h-4`} />
|
162 |
+
</IconButton>
|
163 |
+
)}
|
164 |
+
</>
|
165 |
+
)}
|
166 |
+
</div>
|
167 |
+
</div>
|
168 |
+
);
|
169 |
+
};
|
AUTHORS
ADDED
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
Main contributors
|
2 |
+
=================
|
3 |
+
|
4 |
+
MagicStack Inc.:
|
5 |
+
Elvis Pranskevichus <elvis@magic.io>
|
6 |
+
Yury Selivanov <yury@magic.io>
|
Activate.ps1
ADDED
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<#
|
2 |
+
.Synopsis
|
3 |
+
Activate a Python virtual environment for the current PowerShell session.
|
4 |
+
|
5 |
+
.Description
|
6 |
+
Pushes the python executable for a virtual environment to the front of the
|
7 |
+
$Env:PATH environment variable and sets the prompt to signify that you are
|
8 |
+
in a Python virtual environment. Makes use of the command line switches as
|
9 |
+
well as the `pyvenv.cfg` file values present in the virtual environment.
|
10 |
+
|
11 |
+
.Parameter VenvDir
|
12 |
+
Path to the directory that contains the virtual environment to activate. The
|
13 |
+
default value for this is the parent of the directory that the Activate.ps1
|
14 |
+
script is located within.
|
15 |
+
|
16 |
+
.Parameter Prompt
|
17 |
+
The prompt prefix to display when this virtual environment is activated. By
|
18 |
+
default, this prompt is the name of the virtual environment folder (VenvDir)
|
19 |
+
surrounded by parentheses and followed by a single space (ie. '(.venv) ').
|
20 |
+
|
21 |
+
.Example
|
22 |
+
Activate.ps1
|
23 |
+
Activates the Python virtual environment that contains the Activate.ps1 script.
|
24 |
+
|
25 |
+
.Example
|
26 |
+
Activate.ps1 -Verbose
|
27 |
+
Activates the Python virtual environment that contains the Activate.ps1 script,
|
28 |
+
and shows extra information about the activation as it executes.
|
29 |
+
|
30 |
+
.Example
|
31 |
+
Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
|
32 |
+
Activates the Python virtual environment located in the specified location.
|
33 |
+
|
34 |
+
.Example
|
35 |
+
Activate.ps1 -Prompt "MyPython"
|
36 |
+
Activates the Python virtual environment that contains the Activate.ps1 script,
|
37 |
+
and prefixes the current prompt with the specified string (surrounded in
|
38 |
+
parentheses) while the virtual environment is active.
|
39 |
+
|
40 |
+
.Notes
|
41 |
+
On Windows, it may be required to enable this Activate.ps1 script by setting the
|
42 |
+
execution policy for the user. You can do this by issuing the following PowerShell
|
43 |
+
command:
|
44 |
+
|
45 |
+
PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
46 |
+
|
47 |
+
For more information on Execution Policies:
|
48 |
+
https://go.microsoft.com/fwlink/?LinkID=135170
|
49 |
+
|
50 |
+
#>
|
51 |
+
Param(
|
52 |
+
[Parameter(Mandatory = $false)]
|
53 |
+
[String]
|
54 |
+
$VenvDir,
|
55 |
+
[Parameter(Mandatory = $false)]
|
56 |
+
[String]
|
57 |
+
$Prompt
|
58 |
+
)
|
59 |
+
|
60 |
+
<# Function declarations --------------------------------------------------- #>
|
61 |
+
|
62 |
+
<#
|
63 |
+
.Synopsis
|
64 |
+
Remove all shell session elements added by the Activate script, including the
|
65 |
+
addition of the virtual environment's Python executable from the beginning of
|
66 |
+
the PATH variable.
|
67 |
+
|
68 |
+
.Parameter NonDestructive
|
69 |
+
If present, do not remove this function from the global namespace for the
|
70 |
+
session.
|
71 |
+
|
72 |
+
#>
|
73 |
+
function global:deactivate ([switch]$NonDestructive) {
|
74 |
+
# Revert to original values
|
75 |
+
|
76 |
+
# The prior prompt:
|
77 |
+
if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
|
78 |
+
Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
|
79 |
+
Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
|
80 |
+
}
|
81 |
+
|
82 |
+
# The prior PYTHONHOME:
|
83 |
+
if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
|
84 |
+
Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
|
85 |
+
Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
|
86 |
+
}
|
87 |
+
|
88 |
+
# The prior PATH:
|
89 |
+
if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
|
90 |
+
Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
|
91 |
+
Remove-Item -Path Env:_OLD_VIRTUAL_PATH
|
92 |
+
}
|
93 |
+
|
94 |
+
# Just remove the VIRTUAL_ENV altogether:
|
95 |
+
if (Test-Path -Path Env:VIRTUAL_ENV) {
|
96 |
+
Remove-Item -Path env:VIRTUAL_ENV
|
97 |
+
}
|
98 |
+
|
99 |
+
# Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
|
100 |
+
if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
|
101 |
+
Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
|
102 |
+
}
|
103 |
+
|
104 |
+
# Leave deactivate function in the global namespace if requested:
|
105 |
+
if (-not $NonDestructive) {
|
106 |
+
Remove-Item -Path function:deactivate
|
107 |
+
}
|
108 |
+
}
|
109 |
+
|
110 |
+
<#
|
111 |
+
.Description
|
112 |
+
Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
|
113 |
+
given folder, and returns them in a map.
|
114 |
+
|
115 |
+
For each line in the pyvenv.cfg file, if that line can be parsed into exactly
|
116 |
+
two strings separated by `=` (with any amount of whitespace surrounding the =)
|
117 |
+
then it is considered a `key = value` line. The left hand string is the key,
|
118 |
+
the right hand is the value.
|
119 |
+
|
120 |
+
If the value starts with a `'` or a `"` then the first and last character is
|
121 |
+
stripped from the value before being captured.
|
122 |
+
|
123 |
+
.Parameter ConfigDir
|
124 |
+
Path to the directory that contains the `pyvenv.cfg` file.
|
125 |
+
#>
|
126 |
+
function Get-PyVenvConfig(
|
127 |
+
[String]
|
128 |
+
$ConfigDir
|
129 |
+
) {
|
130 |
+
Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
|
131 |
+
|
132 |
+
# Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
|
133 |
+
$pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
|
134 |
+
|
135 |
+
# An empty map will be returned if no config file is found.
|
136 |
+
$pyvenvConfig = @{ }
|
137 |
+
|
138 |
+
if ($pyvenvConfigPath) {
|
139 |
+
|
140 |
+
Write-Verbose "File exists, parse `key = value` lines"
|
141 |
+
$pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
|
142 |
+
|
143 |
+
$pyvenvConfigContent | ForEach-Object {
|
144 |
+
$keyval = $PSItem -split "\s*=\s*", 2
|
145 |
+
if ($keyval[0] -and $keyval[1]) {
|
146 |
+
$val = $keyval[1]
|
147 |
+
|
148 |
+
# Remove extraneous quotations around a string value.
|
149 |
+
if ("'""".Contains($val.Substring(0, 1))) {
|
150 |
+
$val = $val.Substring(1, $val.Length - 2)
|
151 |
+
}
|
152 |
+
|
153 |
+
$pyvenvConfig[$keyval[0]] = $val
|
154 |
+
Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
|
155 |
+
}
|
156 |
+
}
|
157 |
+
}
|
158 |
+
return $pyvenvConfig
|
159 |
+
}
|
160 |
+
|
161 |
+
|
162 |
+
<# Begin Activate script --------------------------------------------------- #>
|
163 |
+
|
164 |
+
# Determine the containing directory of this script
|
165 |
+
$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
166 |
+
$VenvExecDir = Get-Item -Path $VenvExecPath
|
167 |
+
|
168 |
+
Write-Verbose "Activation script is located in path: '$VenvExecPath'"
|
169 |
+
Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
|
170 |
+
Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
|
171 |
+
|
172 |
+
# Set values required in priority: CmdLine, ConfigFile, Default
|
173 |
+
# First, get the location of the virtual environment, it might not be
|
174 |
+
# VenvExecDir if specified on the command line.
|
175 |
+
if ($VenvDir) {
|
176 |
+
Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
|
177 |
+
}
|
178 |
+
else {
|
179 |
+
Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
|
180 |
+
$VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
|
181 |
+
Write-Verbose "VenvDir=$VenvDir"
|
182 |
+
}
|
183 |
+
|
184 |
+
# Next, read the `pyvenv.cfg` file to determine any required value such
|
185 |
+
# as `prompt`.
|
186 |
+
$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
|
187 |
+
|
188 |
+
# Next, set the prompt from the command line, or the config file, or
|
189 |
+
# just use the name of the virtual environment folder.
|
190 |
+
if ($Prompt) {
|
191 |
+
Write-Verbose "Prompt specified as argument, using '$Prompt'"
|
192 |
+
}
|
193 |
+
else {
|
194 |
+
Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
|
195 |
+
if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
|
196 |
+
Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
|
197 |
+
$Prompt = $pyvenvCfg['prompt'];
|
198 |
+
}
|
199 |
+
else {
|
200 |
+
Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virutal environment)"
|
201 |
+
Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
|
202 |
+
$Prompt = Split-Path -Path $venvDir -Leaf
|
203 |
+
}
|
204 |
+
}
|
205 |
+
|
206 |
+
Write-Verbose "Prompt = '$Prompt'"
|
207 |
+
Write-Verbose "VenvDir='$VenvDir'"
|
208 |
+
|
209 |
+
# Deactivate any currently active virtual environment, but leave the
|
210 |
+
# deactivate function in place.
|
211 |
+
deactivate -nondestructive
|
212 |
+
|
213 |
+
# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
|
214 |
+
# that there is an activated venv.
|
215 |
+
$env:VIRTUAL_ENV = $VenvDir
|
216 |
+
|
217 |
+
if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
|
218 |
+
|
219 |
+
Write-Verbose "Setting prompt to '$Prompt'"
|
220 |
+
|
221 |
+
# Set the prompt to include the env name
|
222 |
+
# Make sure _OLD_VIRTUAL_PROMPT is global
|
223 |
+
function global:_OLD_VIRTUAL_PROMPT { "" }
|
224 |
+
Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
|
225 |
+
New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
|
226 |
+
|
227 |
+
function global:prompt {
|
228 |
+
Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
|
229 |
+
_OLD_VIRTUAL_PROMPT
|
230 |
+
}
|
231 |
+
}
|
232 |
+
|
233 |
+
# Clear PYTHONHOME
|
234 |
+
if (Test-Path -Path Env:PYTHONHOME) {
|
235 |
+
Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
|
236 |
+
Remove-Item -Path Env:PYTHONHOME
|
237 |
+
}
|
238 |
+
|
239 |
+
# Add the venv to the PATH
|
240 |
+
Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
|
241 |
+
$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"
|
Artifact.tsx
ADDED
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useStore } from '@nanostores/react';
|
2 |
+
import { AnimatePresence, motion } from 'framer-motion';
|
3 |
+
import { computed } from 'nanostores';
|
4 |
+
import { memo, useEffect, useRef, useState } from 'react';
|
5 |
+
import { createHighlighter, type BundledLanguage, type BundledTheme, type HighlighterGeneric } from 'shiki';
|
6 |
+
import type { ActionState } from '~/lib/runtime/action-runner';
|
7 |
+
import { workbenchStore } from '~/lib/stores/workbench';
|
8 |
+
import { classNames } from '~/utils/classNames';
|
9 |
+
import { cubicEasingFn } from '~/utils/easings';
|
10 |
+
import { WORK_DIR } from '~/utils/constants';
|
11 |
+
|
12 |
+
const highlighterOptions = {
|
13 |
+
langs: ['shell'],
|
14 |
+
themes: ['light-plus', 'dark-plus'],
|
15 |
+
};
|
16 |
+
|
17 |
+
const shellHighlighter: HighlighterGeneric<BundledLanguage, BundledTheme> =
|
18 |
+
import.meta.hot?.data.shellHighlighter ?? (await createHighlighter(highlighterOptions));
|
19 |
+
|
20 |
+
if (import.meta.hot) {
|
21 |
+
import.meta.hot.data.shellHighlighter = shellHighlighter;
|
22 |
+
}
|
23 |
+
|
24 |
+
interface ArtifactProps {
|
25 |
+
messageId: string;
|
26 |
+
}
|
27 |
+
|
28 |
+
export const Artifact = memo(({ messageId }: ArtifactProps) => {
|
29 |
+
const userToggledActions = useRef(false);
|
30 |
+
const [showActions, setShowActions] = useState(false);
|
31 |
+
const [allActionFinished, setAllActionFinished] = useState(false);
|
32 |
+
|
33 |
+
const artifacts = useStore(workbenchStore.artifacts);
|
34 |
+
const artifact = artifacts[messageId];
|
35 |
+
|
36 |
+
const actions = useStore(
|
37 |
+
computed(artifact.runner.actions, (actions) => {
|
38 |
+
return Object.values(actions);
|
39 |
+
}),
|
40 |
+
);
|
41 |
+
|
42 |
+
const toggleActions = () => {
|
43 |
+
userToggledActions.current = true;
|
44 |
+
setShowActions(!showActions);
|
45 |
+
};
|
46 |
+
|
47 |
+
useEffect(() => {
|
48 |
+
if (actions.length && !showActions && !userToggledActions.current) {
|
49 |
+
setShowActions(true);
|
50 |
+
}
|
51 |
+
|
52 |
+
if (actions.length !== 0 && artifact.type === 'bundled') {
|
53 |
+
const finished = !actions.find((action) => action.status !== 'complete');
|
54 |
+
|
55 |
+
if (allActionFinished !== finished) {
|
56 |
+
setAllActionFinished(finished);
|
57 |
+
}
|
58 |
+
}
|
59 |
+
}, [actions]);
|
60 |
+
|
61 |
+
return (
|
62 |
+
<div className="artifact border border-bolt-elements-borderColor flex flex-col overflow-hidden rounded-lg w-full transition-border duration-150">
|
63 |
+
<div className="flex">
|
64 |
+
<button
|
65 |
+
className="flex items-stretch bg-bolt-elements-artifacts-background hover:bg-bolt-elements-artifacts-backgroundHover w-full overflow-hidden"
|
66 |
+
onClick={() => {
|
67 |
+
const showWorkbench = workbenchStore.showWorkbench.get();
|
68 |
+
workbenchStore.showWorkbench.set(!showWorkbench);
|
69 |
+
}}
|
70 |
+
>
|
71 |
+
{artifact.type == 'bundled' && (
|
72 |
+
<>
|
73 |
+
<div className="p-4">
|
74 |
+
{allActionFinished ? (
|
75 |
+
<div className={'i-ph:files-light'} style={{ fontSize: '2rem' }}></div>
|
76 |
+
) : (
|
77 |
+
<div className={'i-svg-spinners:90-ring-with-bg'} style={{ fontSize: '2rem' }}></div>
|
78 |
+
)}
|
79 |
+
</div>
|
80 |
+
<div className="bg-bolt-elements-artifacts-borderColor w-[1px]" />
|
81 |
+
</>
|
82 |
+
)}
|
83 |
+
<div className="px-5 p-3.5 w-full text-left">
|
84 |
+
<div className="w-full text-bolt-elements-textPrimary font-medium leading-5 text-sm">{artifact?.title}</div>
|
85 |
+
<div className="w-full w-full text-bolt-elements-textSecondary text-xs mt-0.5">Click to open Workbench</div>
|
86 |
+
</div>
|
87 |
+
</button>
|
88 |
+
<div className="bg-bolt-elements-artifacts-borderColor w-[1px]" />
|
89 |
+
<AnimatePresence>
|
90 |
+
{actions.length && artifact.type !== 'bundled' && (
|
91 |
+
<motion.button
|
92 |
+
initial={{ width: 0 }}
|
93 |
+
animate={{ width: 'auto' }}
|
94 |
+
exit={{ width: 0 }}
|
95 |
+
transition={{ duration: 0.15, ease: cubicEasingFn }}
|
96 |
+
className="bg-bolt-elements-artifacts-background hover:bg-bolt-elements-artifacts-backgroundHover"
|
97 |
+
onClick={toggleActions}
|
98 |
+
>
|
99 |
+
<div className="p-4">
|
100 |
+
<div className={showActions ? 'i-ph:caret-up-bold' : 'i-ph:caret-down-bold'}></div>
|
101 |
+
</div>
|
102 |
+
</motion.button>
|
103 |
+
)}
|
104 |
+
</AnimatePresence>
|
105 |
+
</div>
|
106 |
+
<AnimatePresence>
|
107 |
+
{artifact.type !== 'bundled' && showActions && actions.length > 0 && (
|
108 |
+
<motion.div
|
109 |
+
className="actions"
|
110 |
+
initial={{ height: 0 }}
|
111 |
+
animate={{ height: 'auto' }}
|
112 |
+
exit={{ height: '0px' }}
|
113 |
+
transition={{ duration: 0.15 }}
|
114 |
+
>
|
115 |
+
<div className="bg-bolt-elements-artifacts-borderColor h-[1px]" />
|
116 |
+
|
117 |
+
<div className="p-5 text-left bg-bolt-elements-actions-background">
|
118 |
+
<ActionList actions={actions} />
|
119 |
+
</div>
|
120 |
+
</motion.div>
|
121 |
+
)}
|
122 |
+
</AnimatePresence>
|
123 |
+
</div>
|
124 |
+
);
|
125 |
+
});
|
126 |
+
|
127 |
+
interface ShellCodeBlockProps {
|
128 |
+
classsName?: string;
|
129 |
+
code: string;
|
130 |
+
}
|
131 |
+
|
132 |
+
function ShellCodeBlock({ classsName, code }: ShellCodeBlockProps) {
|
133 |
+
return (
|
134 |
+
<div
|
135 |
+
className={classNames('text-xs', classsName)}
|
136 |
+
dangerouslySetInnerHTML={{
|
137 |
+
__html: shellHighlighter.codeToHtml(code, {
|
138 |
+
lang: 'shell',
|
139 |
+
theme: 'dark-plus',
|
140 |
+
}),
|
141 |
+
}}
|
142 |
+
></div>
|
143 |
+
);
|
144 |
+
}
|
145 |
+
|
146 |
+
interface ActionListProps {
|
147 |
+
actions: ActionState[];
|
148 |
+
}
|
149 |
+
|
150 |
+
const actionVariants = {
|
151 |
+
hidden: { opacity: 0, y: 20 },
|
152 |
+
visible: { opacity: 1, y: 0 },
|
153 |
+
};
|
154 |
+
|
155 |
+
function openArtifactInWorkbench(filePath: any) {
|
156 |
+
if (workbenchStore.currentView.get() !== 'code') {
|
157 |
+
workbenchStore.currentView.set('code');
|
158 |
+
}
|
159 |
+
|
160 |
+
workbenchStore.setSelectedFile(`${WORK_DIR}/${filePath}`);
|
161 |
+
}
|
162 |
+
|
163 |
+
const ActionList = memo(({ actions }: ActionListProps) => {
|
164 |
+
return (
|
165 |
+
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
|
166 |
+
<ul className="list-none space-y-2.5">
|
167 |
+
{actions.map((action, index) => {
|
168 |
+
const { status, type, content } = action;
|
169 |
+
const isLast = index === actions.length - 1;
|
170 |
+
|
171 |
+
return (
|
172 |
+
<motion.li
|
173 |
+
key={index}
|
174 |
+
variants={actionVariants}
|
175 |
+
initial="hidden"
|
176 |
+
animate="visible"
|
177 |
+
transition={{
|
178 |
+
duration: 0.2,
|
179 |
+
ease: cubicEasingFn,
|
180 |
+
}}
|
181 |
+
>
|
182 |
+
<div className="flex items-center gap-1.5 text-sm">
|
183 |
+
<div className={classNames('text-lg', getIconColor(action.status))}>
|
184 |
+
{status === 'running' ? (
|
185 |
+
<>
|
186 |
+
{type !== 'start' ? (
|
187 |
+
<div className="i-svg-spinners:90-ring-with-bg"></div>
|
188 |
+
) : (
|
189 |
+
<div className="i-ph:terminal-window-duotone"></div>
|
190 |
+
)}
|
191 |
+
</>
|
192 |
+
) : status === 'pending' ? (
|
193 |
+
<div className="i-ph:circle-duotone"></div>
|
194 |
+
) : status === 'complete' ? (
|
195 |
+
<div className="i-ph:check"></div>
|
196 |
+
) : status === 'failed' || status === 'aborted' ? (
|
197 |
+
<div className="i-ph:x"></div>
|
198 |
+
) : null}
|
199 |
+
</div>
|
200 |
+
{type === 'file' ? (
|
201 |
+
<div>
|
202 |
+
Create{' '}
|
203 |
+
<code
|
204 |
+
className="bg-bolt-elements-artifacts-inlineCode-background text-bolt-elements-artifacts-inlineCode-text px-1.5 py-1 rounded-md text-bolt-elements-item-contentAccent hover:underline cursor-pointer"
|
205 |
+
onClick={() => openArtifactInWorkbench(action.filePath)}
|
206 |
+
>
|
207 |
+
{action.filePath}
|
208 |
+
</code>
|
209 |
+
</div>
|
210 |
+
) : type === 'shell' ? (
|
211 |
+
<div className="flex items-center w-full min-h-[28px]">
|
212 |
+
<span className="flex-1">Run command</span>
|
213 |
+
</div>
|
214 |
+
) : type === 'start' ? (
|
215 |
+
<a
|
216 |
+
onClick={(e) => {
|
217 |
+
e.preventDefault();
|
218 |
+
workbenchStore.currentView.set('preview');
|
219 |
+
}}
|
220 |
+
className="flex items-center w-full min-h-[28px]"
|
221 |
+
>
|
222 |
+
<span className="flex-1">Start Application</span>
|
223 |
+
</a>
|
224 |
+
) : null}
|
225 |
+
</div>
|
226 |
+
{(type === 'shell' || type === 'start') && (
|
227 |
+
<ShellCodeBlock
|
228 |
+
classsName={classNames('mt-1', {
|
229 |
+
'mb-3.5': !isLast,
|
230 |
+
})}
|
231 |
+
code={content}
|
232 |
+
/>
|
233 |
+
)}
|
234 |
+
</motion.li>
|
235 |
+
);
|
236 |
+
})}
|
237 |
+
</ul>
|
238 |
+
</motion.div>
|
239 |
+
);
|
240 |
+
});
|
241 |
+
|
242 |
+
function getIconColor(status: ActionState['status']) {
|
243 |
+
switch (status) {
|
244 |
+
case 'pending': {
|
245 |
+
return 'text-bolt-elements-textTertiary';
|
246 |
+
}
|
247 |
+
case 'running': {
|
248 |
+
return 'text-bolt-elements-loader-progress';
|
249 |
+
}
|
250 |
+
case 'complete': {
|
251 |
+
return 'text-bolt-elements-icon-success';
|
252 |
+
}
|
253 |
+
case 'aborted': {
|
254 |
+
return 'text-bolt-elements-textSecondary';
|
255 |
+
}
|
256 |
+
case 'failed': {
|
257 |
+
return 'text-bolt-elements-icon-error';
|
258 |
+
}
|
259 |
+
default: {
|
260 |
+
return undefined;
|
261 |
+
}
|
262 |
+
}
|
263 |
+
}
|
AssistantMessage.tsx
ADDED
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { memo } from 'react';
|
2 |
+
import { Markdown } from './Markdown';
|
3 |
+
import type { JSONValue } from 'ai';
|
4 |
+
import Popover from '~/components/ui/Popover';
|
5 |
+
import { workbenchStore } from '~/lib/stores/workbench';
|
6 |
+
import { WORK_DIR } from '~/utils/constants';
|
7 |
+
|
8 |
+
interface AssistantMessageProps {
|
9 |
+
content: string;
|
10 |
+
annotations?: JSONValue[];
|
11 |
+
}
|
12 |
+
|
13 |
+
function openArtifactInWorkbench(filePath: string) {
|
14 |
+
filePath = normalizedFilePath(filePath);
|
15 |
+
|
16 |
+
if (workbenchStore.currentView.get() !== 'code') {
|
17 |
+
workbenchStore.currentView.set('code');
|
18 |
+
}
|
19 |
+
|
20 |
+
workbenchStore.setSelectedFile(`${WORK_DIR}/${filePath}`);
|
21 |
+
}
|
22 |
+
|
23 |
+
function normalizedFilePath(path: string) {
|
24 |
+
let normalizedPath = path;
|
25 |
+
|
26 |
+
if (normalizedPath.startsWith(WORK_DIR)) {
|
27 |
+
normalizedPath = path.replace(WORK_DIR, '');
|
28 |
+
}
|
29 |
+
|
30 |
+
if (normalizedPath.startsWith('/')) {
|
31 |
+
normalizedPath = normalizedPath.slice(1);
|
32 |
+
}
|
33 |
+
|
34 |
+
return normalizedPath;
|
35 |
+
}
|
36 |
+
|
37 |
+
export const AssistantMessage = memo(({ content, annotations }: AssistantMessageProps) => {
|
38 |
+
const filteredAnnotations = (annotations?.filter(
|
39 |
+
(annotation: JSONValue) => annotation && typeof annotation === 'object' && Object.keys(annotation).includes('type'),
|
40 |
+
) || []) as { type: string; value: any } & { [key: string]: any }[];
|
41 |
+
|
42 |
+
let chatSummary: string | undefined = undefined;
|
43 |
+
|
44 |
+
if (filteredAnnotations.find((annotation) => annotation.type === 'chatSummary')) {
|
45 |
+
chatSummary = filteredAnnotations.find((annotation) => annotation.type === 'chatSummary')?.summary;
|
46 |
+
}
|
47 |
+
|
48 |
+
let codeContext: string[] | undefined = undefined;
|
49 |
+
|
50 |
+
if (filteredAnnotations.find((annotation) => annotation.type === 'codeContext')) {
|
51 |
+
codeContext = filteredAnnotations.find((annotation) => annotation.type === 'codeContext')?.files;
|
52 |
+
}
|
53 |
+
|
54 |
+
const usage: {
|
55 |
+
completionTokens: number;
|
56 |
+
promptTokens: number;
|
57 |
+
totalTokens: number;
|
58 |
+
} = filteredAnnotations.find((annotation) => annotation.type === 'usage')?.value;
|
59 |
+
|
60 |
+
return (
|
61 |
+
<div className="overflow-hidden w-full">
|
62 |
+
<>
|
63 |
+
<div className=" flex gap-2 items-center text-sm text-bolt-elements-textSecondary mb-2">
|
64 |
+
{(codeContext || chatSummary) && (
|
65 |
+
<Popover side="right" align="start" trigger={<div className="i-ph:info" />}>
|
66 |
+
{chatSummary && (
|
67 |
+
<div className="max-w-chat">
|
68 |
+
<div className="summary max-h-96 flex flex-col">
|
69 |
+
<h2 className="border border-bolt-elements-borderColor rounded-md p4">Summary</h2>
|
70 |
+
<div style={{ zoom: 0.7 }} className="overflow-y-auto m4">
|
71 |
+
<Markdown>{chatSummary}</Markdown>
|
72 |
+
</div>
|
73 |
+
</div>
|
74 |
+
{codeContext && (
|
75 |
+
<div className="code-context flex flex-col p4 border border-bolt-elements-borderColor rounded-md">
|
76 |
+
<h2>Context</h2>
|
77 |
+
<div className="flex gap-4 mt-4 bolt" style={{ zoom: 0.6 }}>
|
78 |
+
{codeContext.map((x) => {
|
79 |
+
const normalized = normalizedFilePath(x);
|
80 |
+
return (
|
81 |
+
<>
|
82 |
+
<code
|
83 |
+
className="bg-bolt-elements-artifacts-inlineCode-background text-bolt-elements-artifacts-inlineCode-text px-1.5 py-1 rounded-md text-bolt-elements-item-contentAccent hover:underline cursor-pointer"
|
84 |
+
onClick={(e) => {
|
85 |
+
e.preventDefault();
|
86 |
+
e.stopPropagation();
|
87 |
+
openArtifactInWorkbench(normalized);
|
88 |
+
}}
|
89 |
+
>
|
90 |
+
{normalized}
|
91 |
+
</code>
|
92 |
+
</>
|
93 |
+
);
|
94 |
+
})}
|
95 |
+
</div>
|
96 |
+
</div>
|
97 |
+
)}
|
98 |
+
</div>
|
99 |
+
)}
|
100 |
+
<div className="context"></div>
|
101 |
+
</Popover>
|
102 |
+
)}
|
103 |
+
{usage && (
|
104 |
+
<div>
|
105 |
+
Tokens: {usage.totalTokens} (prompt: {usage.promptTokens}, completion: {usage.completionTokens})
|
106 |
+
</div>
|
107 |
+
)}
|
108 |
+
</div>
|
109 |
+
</>
|
110 |
+
<Markdown html>{content}</Markdown>
|
111 |
+
</div>
|
112 |
+
);
|
113 |
+
});
|
AvatarDropdown.tsx
ADDED
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
2 |
+
import { motion } from 'framer-motion';
|
3 |
+
import { useStore } from '@nanostores/react';
|
4 |
+
import { classNames } from '~/utils/classNames';
|
5 |
+
import { profileStore } from '~/lib/stores/profile';
|
6 |
+
import type { TabType, Profile } from './types';
|
7 |
+
|
8 |
+
const BetaLabel = () => (
|
9 |
+
<span className="px-1.5 py-0.5 rounded-full bg-purple-500/10 dark:bg-purple-500/20 text-[10px] font-medium text-purple-600 dark:text-purple-400 ml-2">
|
10 |
+
BETA
|
11 |
+
</span>
|
12 |
+
);
|
13 |
+
|
14 |
+
interface AvatarDropdownProps {
|
15 |
+
onSelectTab: (tab: TabType) => void;
|
16 |
+
}
|
17 |
+
|
18 |
+
export const AvatarDropdown = ({ onSelectTab }: AvatarDropdownProps) => {
|
19 |
+
const profile = useStore(profileStore) as Profile;
|
20 |
+
|
21 |
+
return (
|
22 |
+
<DropdownMenu.Root>
|
23 |
+
<DropdownMenu.Trigger asChild>
|
24 |
+
<motion.button
|
25 |
+
className="w-10 h-10 rounded-full bg-transparent flex items-center justify-center focus:outline-none"
|
26 |
+
whileHover={{ scale: 1.02 }}
|
27 |
+
whileTap={{ scale: 0.98 }}
|
28 |
+
>
|
29 |
+
{profile?.avatar ? (
|
30 |
+
<img
|
31 |
+
src={profile.avatar}
|
32 |
+
alt={profile?.username || 'Profile'}
|
33 |
+
className="w-full h-full rounded-full object-cover"
|
34 |
+
loading="eager"
|
35 |
+
decoding="sync"
|
36 |
+
/>
|
37 |
+
) : (
|
38 |
+
<div className="w-full h-full rounded-full flex items-center justify-center bg-white dark:bg-gray-800 text-gray-400 dark:text-gray-500">
|
39 |
+
<div className="i-ph:question w-6 h-6" />
|
40 |
+
</div>
|
41 |
+
)}
|
42 |
+
</motion.button>
|
43 |
+
</DropdownMenu.Trigger>
|
44 |
+
|
45 |
+
<DropdownMenu.Portal>
|
46 |
+
<DropdownMenu.Content
|
47 |
+
className={classNames(
|
48 |
+
'min-w-[240px] z-[250]',
|
49 |
+
'bg-white dark:bg-[#141414]',
|
50 |
+
'rounded-lg shadow-lg',
|
51 |
+
'border border-gray-200/50 dark:border-gray-800/50',
|
52 |
+
'animate-in fade-in-0 zoom-in-95',
|
53 |
+
'py-1',
|
54 |
+
)}
|
55 |
+
sideOffset={5}
|
56 |
+
align="end"
|
57 |
+
>
|
58 |
+
<div
|
59 |
+
className={classNames(
|
60 |
+
'px-4 py-3 flex items-center gap-3',
|
61 |
+
'border-b border-gray-200/50 dark:border-gray-800/50',
|
62 |
+
)}
|
63 |
+
>
|
64 |
+
<div className="w-10 h-10 rounded-full overflow-hidden flex-shrink-0 bg-white dark:bg-gray-800 shadow-sm">
|
65 |
+
{profile?.avatar ? (
|
66 |
+
<img
|
67 |
+
src={profile.avatar}
|
68 |
+
alt={profile?.username || 'Profile'}
|
69 |
+
className={classNames('w-full h-full', 'object-cover', 'transform-gpu', 'image-rendering-crisp')}
|
70 |
+
loading="eager"
|
71 |
+
decoding="sync"
|
72 |
+
/>
|
73 |
+
) : (
|
74 |
+
<div className="w-full h-full flex items-center justify-center text-gray-400 dark:text-gray-500 font-medium text-lg">
|
75 |
+
<span className="relative -top-0.5">?</span>
|
76 |
+
</div>
|
77 |
+
)}
|
78 |
+
</div>
|
79 |
+
<div className="flex-1 min-w-0">
|
80 |
+
<div className="font-medium text-sm text-gray-900 dark:text-white truncate">
|
81 |
+
{profile?.username || 'Guest User'}
|
82 |
+
</div>
|
83 |
+
{profile?.bio && <div className="text-xs text-gray-500 dark:text-gray-400 truncate">{profile.bio}</div>}
|
84 |
+
</div>
|
85 |
+
</div>
|
86 |
+
|
87 |
+
<DropdownMenu.Item
|
88 |
+
className={classNames(
|
89 |
+
'flex items-center gap-2 px-4 py-2.5',
|
90 |
+
'text-sm text-gray-700 dark:text-gray-200',
|
91 |
+
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
|
92 |
+
'hover:text-purple-500 dark:hover:text-purple-400',
|
93 |
+
'cursor-pointer transition-all duration-200',
|
94 |
+
'outline-none',
|
95 |
+
'group',
|
96 |
+
)}
|
97 |
+
onClick={() => onSelectTab('profile')}
|
98 |
+
>
|
99 |
+
<div className="i-ph:user-circle w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
|
100 |
+
Edit Profile
|
101 |
+
</DropdownMenu.Item>
|
102 |
+
|
103 |
+
<DropdownMenu.Item
|
104 |
+
className={classNames(
|
105 |
+
'flex items-center gap-2 px-4 py-2.5',
|
106 |
+
'text-sm text-gray-700 dark:text-gray-200',
|
107 |
+
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
|
108 |
+
'hover:text-purple-500 dark:hover:text-purple-400',
|
109 |
+
'cursor-pointer transition-all duration-200',
|
110 |
+
'outline-none',
|
111 |
+
'group',
|
112 |
+
)}
|
113 |
+
onClick={() => onSelectTab('settings')}
|
114 |
+
>
|
115 |
+
<div className="i-ph:gear-six w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
|
116 |
+
Settings
|
117 |
+
</DropdownMenu.Item>
|
118 |
+
|
119 |
+
<div className="my-1 border-t border-gray-200/50 dark:border-gray-800/50" />
|
120 |
+
|
121 |
+
<DropdownMenu.Item
|
122 |
+
className={classNames(
|
123 |
+
'flex items-center gap-2 px-4 py-2.5',
|
124 |
+
'text-sm text-gray-700 dark:text-gray-200',
|
125 |
+
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
|
126 |
+
'hover:text-purple-500 dark:hover:text-purple-400',
|
127 |
+
'cursor-pointer transition-all duration-200',
|
128 |
+
'outline-none',
|
129 |
+
'group',
|
130 |
+
)}
|
131 |
+
onClick={() => onSelectTab('task-manager')}
|
132 |
+
>
|
133 |
+
<div className="i-ph:activity w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
|
134 |
+
Task Manager
|
135 |
+
<BetaLabel />
|
136 |
+
</DropdownMenu.Item>
|
137 |
+
|
138 |
+
<DropdownMenu.Item
|
139 |
+
className={classNames(
|
140 |
+
'flex items-center gap-2 px-4 py-2.5',
|
141 |
+
'text-sm text-gray-700 dark:text-gray-200',
|
142 |
+
'hover:bg-purple-50 dark:hover:bg-purple-500/10',
|
143 |
+
'hover:text-purple-500 dark:hover:text-purple-400',
|
144 |
+
'cursor-pointer transition-all duration-200',
|
145 |
+
'outline-none',
|
146 |
+
'group',
|
147 |
+
)}
|
148 |
+
onClick={() => onSelectTab('service-status')}
|
149 |
+
>
|
150 |
+
<div className="i-ph:heartbeat w-4 h-4 text-gray-400 group-hover:text-purple-500 dark:group-hover:text-purple-400 transition-colors" />
|
151 |
+
Service Status
|
152 |
+
<BetaLabel />
|
153 |
+
</DropdownMenu.Item>
|
154 |
+
</DropdownMenu.Content>
|
155 |
+
</DropdownMenu.Portal>
|
156 |
+
</DropdownMenu.Root>
|
157 |
+
);
|
158 |
+
};
|
BaseChat.module.scss
ADDED
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
.BaseChat {
|
2 |
+
&[data-chat-visible='false'] {
|
3 |
+
--workbench-inner-width: 100%;
|
4 |
+
--workbench-left: 0;
|
5 |
+
|
6 |
+
.Chat {
|
7 |
+
--at-apply: bolt-ease-cubic-bezier;
|
8 |
+
transition-property: transform, opacity;
|
9 |
+
transition-duration: 0.3s;
|
10 |
+
will-change: transform, opacity;
|
11 |
+
transform: translateX(-50%);
|
12 |
+
opacity: 0;
|
13 |
+
}
|
14 |
+
}
|
15 |
+
}
|
16 |
+
|
17 |
+
.Chat {
|
18 |
+
opacity: 1;
|
19 |
+
}
|
20 |
+
|
21 |
+
.PromptEffectContainer {
|
22 |
+
--prompt-container-offset: 50px;
|
23 |
+
--prompt-line-stroke-width: 1px;
|
24 |
+
position: absolute;
|
25 |
+
pointer-events: none;
|
26 |
+
inset: calc(var(--prompt-container-offset) / -2);
|
27 |
+
width: calc(100% + var(--prompt-container-offset));
|
28 |
+
height: calc(100% + var(--prompt-container-offset));
|
29 |
+
}
|
30 |
+
|
31 |
+
.PromptEffectLine {
|
32 |
+
width: calc(100% - var(--prompt-container-offset) + var(--prompt-line-stroke-width));
|
33 |
+
height: calc(100% - var(--prompt-container-offset) + var(--prompt-line-stroke-width));
|
34 |
+
x: calc(var(--prompt-container-offset) / 2 - var(--prompt-line-stroke-width) / 2);
|
35 |
+
y: calc(var(--prompt-container-offset) / 2 - var(--prompt-line-stroke-width) / 2);
|
36 |
+
rx: calc(8px - var(--prompt-line-stroke-width));
|
37 |
+
fill: transparent;
|
38 |
+
stroke-width: var(--prompt-line-stroke-width);
|
39 |
+
stroke: url(#line-gradient);
|
40 |
+
stroke-dasharray: 35px 65px;
|
41 |
+
stroke-dashoffset: 10;
|
42 |
+
}
|
43 |
+
|
44 |
+
.PromptShine {
|
45 |
+
fill: url(#shine-gradient);
|
46 |
+
mix-blend-mode: overlay;
|
47 |
+
}
|
BaseChat.tsx
ADDED
@@ -0,0 +1,630 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/*
|
2 |
+
* @ts-nocheck
|
3 |
+
* Preventing TS checks with files presented in the video for a better presentation.
|
4 |
+
*/
|
5 |
+
import type { JSONValue, Message } from 'ai';
|
6 |
+
import React, { type RefCallback, useEffect, useState } from 'react';
|
7 |
+
import { ClientOnly } from 'remix-utils/client-only';
|
8 |
+
import { Menu } from '~/components/sidebar/Menu.client';
|
9 |
+
import { IconButton } from '~/components/ui/IconButton';
|
10 |
+
import { Workbench } from '~/components/workbench/Workbench.client';
|
11 |
+
import { classNames } from '~/utils/classNames';
|
12 |
+
import { PROVIDER_LIST } from '~/utils/constants';
|
13 |
+
import { Messages } from './Messages.client';
|
14 |
+
import { SendButton } from './SendButton.client';
|
15 |
+
import { APIKeyManager, getApiKeysFromCookies } from './APIKeyManager';
|
16 |
+
import Cookies from 'js-cookie';
|
17 |
+
import * as Tooltip from '@radix-ui/react-tooltip';
|
18 |
+
|
19 |
+
import styles from './BaseChat.module.scss';
|
20 |
+
import { ExportChatButton } from '~/components/chat/chatExportAndImport/ExportChatButton';
|
21 |
+
import { ImportButtons } from '~/components/chat/chatExportAndImport/ImportButtons';
|
22 |
+
import { ExamplePrompts } from '~/components/chat/ExamplePrompts';
|
23 |
+
import GitCloneButton from './GitCloneButton';
|
24 |
+
|
25 |
+
import FilePreview from './FilePreview';
|
26 |
+
import { ModelSelector } from '~/components/chat/ModelSelector';
|
27 |
+
import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition';
|
28 |
+
import type { ProviderInfo } from '~/types/model';
|
29 |
+
import { ScreenshotStateManager } from './ScreenshotStateManager';
|
30 |
+
import { toast } from 'react-toastify';
|
31 |
+
import StarterTemplates from './StarterTemplates';
|
32 |
+
import type { ActionAlert } from '~/types/actions';
|
33 |
+
import ChatAlert from './ChatAlert';
|
34 |
+
import type { ModelInfo } from '~/lib/modules/llm/types';
|
35 |
+
import ProgressCompilation from './ProgressCompilation';
|
36 |
+
import type { ProgressAnnotation } from '~/types/context';
|
37 |
+
import type { ActionRunner } from '~/lib/runtime/action-runner';
|
38 |
+
import { LOCAL_PROVIDERS } from '~/lib/stores/settings';
|
39 |
+
|
40 |
+
const TEXTAREA_MIN_HEIGHT = 76;
|
41 |
+
|
42 |
+
interface BaseChatProps {
|
43 |
+
textareaRef?: React.RefObject<HTMLTextAreaElement> | undefined;
|
44 |
+
messageRef?: RefCallback<HTMLDivElement> | undefined;
|
45 |
+
scrollRef?: RefCallback<HTMLDivElement> | undefined;
|
46 |
+
showChat?: boolean;
|
47 |
+
chatStarted?: boolean;
|
48 |
+
isStreaming?: boolean;
|
49 |
+
onStreamingChange?: (streaming: boolean) => void;
|
50 |
+
messages?: Message[];
|
51 |
+
description?: string;
|
52 |
+
enhancingPrompt?: boolean;
|
53 |
+
promptEnhanced?: boolean;
|
54 |
+
input?: string;
|
55 |
+
model?: string;
|
56 |
+
setModel?: (model: string) => void;
|
57 |
+
provider?: ProviderInfo;
|
58 |
+
setProvider?: (provider: ProviderInfo) => void;
|
59 |
+
providerList?: ProviderInfo[];
|
60 |
+
handleStop?: () => void;
|
61 |
+
sendMessage?: (event: React.UIEvent, messageInput?: string) => void;
|
62 |
+
handleInputChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
63 |
+
enhancePrompt?: () => void;
|
64 |
+
importChat?: (description: string, messages: Message[]) => Promise<void>;
|
65 |
+
exportChat?: () => void;
|
66 |
+
uploadedFiles?: File[];
|
67 |
+
setUploadedFiles?: (files: File[]) => void;
|
68 |
+
imageDataList?: string[];
|
69 |
+
setImageDataList?: (dataList: string[]) => void;
|
70 |
+
actionAlert?: ActionAlert;
|
71 |
+
clearAlert?: () => void;
|
72 |
+
data?: JSONValue[] | undefined;
|
73 |
+
actionRunner?: ActionRunner;
|
74 |
+
}
|
75 |
+
|
76 |
+
export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
77 |
+
(
|
78 |
+
{
|
79 |
+
textareaRef,
|
80 |
+
messageRef,
|
81 |
+
scrollRef,
|
82 |
+
showChat = true,
|
83 |
+
chatStarted = false,
|
84 |
+
isStreaming = false,
|
85 |
+
onStreamingChange,
|
86 |
+
model,
|
87 |
+
setModel,
|
88 |
+
provider,
|
89 |
+
setProvider,
|
90 |
+
providerList,
|
91 |
+
input = '',
|
92 |
+
enhancingPrompt,
|
93 |
+
handleInputChange,
|
94 |
+
|
95 |
+
// promptEnhanced,
|
96 |
+
enhancePrompt,
|
97 |
+
sendMessage,
|
98 |
+
handleStop,
|
99 |
+
importChat,
|
100 |
+
exportChat,
|
101 |
+
uploadedFiles = [],
|
102 |
+
setUploadedFiles,
|
103 |
+
imageDataList = [],
|
104 |
+
setImageDataList,
|
105 |
+
messages,
|
106 |
+
actionAlert,
|
107 |
+
clearAlert,
|
108 |
+
data,
|
109 |
+
actionRunner,
|
110 |
+
},
|
111 |
+
ref,
|
112 |
+
) => {
|
113 |
+
const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
|
114 |
+
const [apiKeys, setApiKeys] = useState<Record<string, string>>(getApiKeysFromCookies());
|
115 |
+
const [modelList, setModelList] = useState<ModelInfo[]>([]);
|
116 |
+
const [isModelSettingsCollapsed, setIsModelSettingsCollapsed] = useState(false);
|
117 |
+
const [isListening, setIsListening] = useState(false);
|
118 |
+
const [recognition, setRecognition] = useState<SpeechRecognition | null>(null);
|
119 |
+
const [transcript, setTranscript] = useState('');
|
120 |
+
const [isModelLoading, setIsModelLoading] = useState<string | undefined>('all');
|
121 |
+
const [progressAnnotations, setProgressAnnotations] = useState<ProgressAnnotation[]>([]);
|
122 |
+
useEffect(() => {
|
123 |
+
if (data) {
|
124 |
+
const progressList = data.filter(
|
125 |
+
(x) => typeof x === 'object' && (x as any).type === 'progress',
|
126 |
+
) as ProgressAnnotation[];
|
127 |
+
setProgressAnnotations(progressList);
|
128 |
+
}
|
129 |
+
}, [data]);
|
130 |
+
useEffect(() => {
|
131 |
+
console.log(transcript);
|
132 |
+
}, [transcript]);
|
133 |
+
|
134 |
+
useEffect(() => {
|
135 |
+
onStreamingChange?.(isStreaming);
|
136 |
+
}, [isStreaming, onStreamingChange]);
|
137 |
+
|
138 |
+
useEffect(() => {
|
139 |
+
if (typeof window !== 'undefined' && ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)) {
|
140 |
+
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
141 |
+
const recognition = new SpeechRecognition();
|
142 |
+
recognition.continuous = true;
|
143 |
+
recognition.interimResults = true;
|
144 |
+
|
145 |
+
recognition.onresult = (event) => {
|
146 |
+
const transcript = Array.from(event.results)
|
147 |
+
.map((result) => result[0])
|
148 |
+
.map((result) => result.transcript)
|
149 |
+
.join('');
|
150 |
+
|
151 |
+
setTranscript(transcript);
|
152 |
+
|
153 |
+
if (handleInputChange) {
|
154 |
+
const syntheticEvent = {
|
155 |
+
target: { value: transcript },
|
156 |
+
} as React.ChangeEvent<HTMLTextAreaElement>;
|
157 |
+
handleInputChange(syntheticEvent);
|
158 |
+
}
|
159 |
+
};
|
160 |
+
|
161 |
+
recognition.onerror = (event) => {
|
162 |
+
console.error('Speech recognition error:', event.error);
|
163 |
+
setIsListening(false);
|
164 |
+
};
|
165 |
+
|
166 |
+
setRecognition(recognition);
|
167 |
+
}
|
168 |
+
}, []);
|
169 |
+
|
170 |
+
useEffect(() => {
|
171 |
+
if (typeof window !== 'undefined') {
|
172 |
+
let parsedApiKeys: Record<string, string> | undefined = {};
|
173 |
+
|
174 |
+
try {
|
175 |
+
parsedApiKeys = getApiKeysFromCookies();
|
176 |
+
setApiKeys(parsedApiKeys);
|
177 |
+
} catch (error) {
|
178 |
+
console.error('Error loading API keys from cookies:', error);
|
179 |
+
Cookies.remove('apiKeys');
|
180 |
+
}
|
181 |
+
|
182 |
+
setIsModelLoading('all');
|
183 |
+
fetch('/api/models')
|
184 |
+
.then((response) => response.json())
|
185 |
+
.then((data) => {
|
186 |
+
const typedData = data as { modelList: ModelInfo[] };
|
187 |
+
setModelList(typedData.modelList);
|
188 |
+
})
|
189 |
+
.catch((error) => {
|
190 |
+
console.error('Error fetching model list:', error);
|
191 |
+
})
|
192 |
+
.finally(() => {
|
193 |
+
setIsModelLoading(undefined);
|
194 |
+
});
|
195 |
+
}
|
196 |
+
}, [providerList, provider]);
|
197 |
+
|
198 |
+
const onApiKeysChange = async (providerName: string, apiKey: string) => {
|
199 |
+
const newApiKeys = { ...apiKeys, [providerName]: apiKey };
|
200 |
+
setApiKeys(newApiKeys);
|
201 |
+
Cookies.set('apiKeys', JSON.stringify(newApiKeys));
|
202 |
+
|
203 |
+
setIsModelLoading(providerName);
|
204 |
+
|
205 |
+
let providerModels: ModelInfo[] = [];
|
206 |
+
|
207 |
+
try {
|
208 |
+
const response = await fetch(`/api/models/${encodeURIComponent(providerName)}`);
|
209 |
+
const data = await response.json();
|
210 |
+
providerModels = (data as { modelList: ModelInfo[] }).modelList;
|
211 |
+
} catch (error) {
|
212 |
+
console.error('Error loading dynamic models for:', providerName, error);
|
213 |
+
}
|
214 |
+
|
215 |
+
// Only update models for the specific provider
|
216 |
+
setModelList((prevModels) => {
|
217 |
+
const otherModels = prevModels.filter((model) => model.provider !== providerName);
|
218 |
+
return [...otherModels, ...providerModels];
|
219 |
+
});
|
220 |
+
setIsModelLoading(undefined);
|
221 |
+
};
|
222 |
+
|
223 |
+
const startListening = () => {
|
224 |
+
if (recognition) {
|
225 |
+
recognition.start();
|
226 |
+
setIsListening(true);
|
227 |
+
}
|
228 |
+
};
|
229 |
+
|
230 |
+
const stopListening = () => {
|
231 |
+
if (recognition) {
|
232 |
+
recognition.stop();
|
233 |
+
setIsListening(false);
|
234 |
+
}
|
235 |
+
};
|
236 |
+
|
237 |
+
const handleSendMessage = (event: React.UIEvent, messageInput?: string) => {
|
238 |
+
if (sendMessage) {
|
239 |
+
sendMessage(event, messageInput);
|
240 |
+
|
241 |
+
if (recognition) {
|
242 |
+
recognition.abort(); // Stop current recognition
|
243 |
+
setTranscript(''); // Clear transcript
|
244 |
+
setIsListening(false);
|
245 |
+
|
246 |
+
// Clear the input by triggering handleInputChange with empty value
|
247 |
+
if (handleInputChange) {
|
248 |
+
const syntheticEvent = {
|
249 |
+
target: { value: '' },
|
250 |
+
} as React.ChangeEvent<HTMLTextAreaElement>;
|
251 |
+
handleInputChange(syntheticEvent);
|
252 |
+
}
|
253 |
+
}
|
254 |
+
}
|
255 |
+
};
|
256 |
+
|
257 |
+
const handleFileUpload = () => {
|
258 |
+
const input = document.createElement('input');
|
259 |
+
input.type = 'file';
|
260 |
+
input.accept = 'image/*';
|
261 |
+
|
262 |
+
input.onchange = async (e) => {
|
263 |
+
const file = (e.target as HTMLInputElement).files?.[0];
|
264 |
+
|
265 |
+
if (file) {
|
266 |
+
const reader = new FileReader();
|
267 |
+
|
268 |
+
reader.onload = (e) => {
|
269 |
+
const base64Image = e.target?.result as string;
|
270 |
+
setUploadedFiles?.([...uploadedFiles, file]);
|
271 |
+
setImageDataList?.([...imageDataList, base64Image]);
|
272 |
+
};
|
273 |
+
reader.readAsDataURL(file);
|
274 |
+
}
|
275 |
+
};
|
276 |
+
|
277 |
+
input.click();
|
278 |
+
};
|
279 |
+
|
280 |
+
const handlePaste = async (e: React.ClipboardEvent) => {
|
281 |
+
const items = e.clipboardData?.items;
|
282 |
+
|
283 |
+
if (!items) {
|
284 |
+
return;
|
285 |
+
}
|
286 |
+
|
287 |
+
for (const item of items) {
|
288 |
+
if (item.type.startsWith('image/')) {
|
289 |
+
e.preventDefault();
|
290 |
+
|
291 |
+
const file = item.getAsFile();
|
292 |
+
|
293 |
+
if (file) {
|
294 |
+
const reader = new FileReader();
|
295 |
+
|
296 |
+
reader.onload = (e) => {
|
297 |
+
const base64Image = e.target?.result as string;
|
298 |
+
setUploadedFiles?.([...uploadedFiles, file]);
|
299 |
+
setImageDataList?.([...imageDataList, base64Image]);
|
300 |
+
};
|
301 |
+
reader.readAsDataURL(file);
|
302 |
+
}
|
303 |
+
|
304 |
+
break;
|
305 |
+
}
|
306 |
+
}
|
307 |
+
};
|
308 |
+
|
309 |
+
const baseChat = (
|
310 |
+
<div
|
311 |
+
ref={ref}
|
312 |
+
className={classNames(styles.BaseChat, 'relative flex h-full w-full overflow-hidden')}
|
313 |
+
data-chat-visible={showChat}
|
314 |
+
>
|
315 |
+
<ClientOnly>{() => <Menu />}</ClientOnly>
|
316 |
+
<div ref={scrollRef} className="flex flex-col lg:flex-row overflow-y-auto w-full h-full">
|
317 |
+
<div className={classNames(styles.Chat, 'flex flex-col flex-grow lg:min-w-[var(--chat-min-width)] h-full')}>
|
318 |
+
{!chatStarted && (
|
319 |
+
<div id="intro" className="mt-[16vh] max-w-chat mx-auto text-center px-4 lg:px-0">
|
320 |
+
<h1 className="text-3xl lg:text-6xl font-bold text-bolt-elements-textPrimary mb-4 animate-fade-in">
|
321 |
+
Where ideas begin
|
322 |
+
</h1>
|
323 |
+
<p className="text-md lg:text-xl mb-8 text-bolt-elements-textSecondary animate-fade-in animation-delay-200">
|
324 |
+
Bring ideas to life in seconds or get help on existing projects.
|
325 |
+
</p>
|
326 |
+
</div>
|
327 |
+
)}
|
328 |
+
<div
|
329 |
+
className={classNames('pt-6 px-2 sm:px-6', {
|
330 |
+
'h-full flex flex-col': chatStarted,
|
331 |
+
})}
|
332 |
+
ref={scrollRef}
|
333 |
+
>
|
334 |
+
<ClientOnly>
|
335 |
+
{() => {
|
336 |
+
return chatStarted ? (
|
337 |
+
<Messages
|
338 |
+
ref={messageRef}
|
339 |
+
className="flex flex-col w-full flex-1 max-w-chat pb-6 mx-auto z-1"
|
340 |
+
messages={messages}
|
341 |
+
isStreaming={isStreaming}
|
342 |
+
/>
|
343 |
+
) : null;
|
344 |
+
}}
|
345 |
+
</ClientOnly>
|
346 |
+
<div
|
347 |
+
className={classNames('flex flex-col gap-4 w-full max-w-chat mx-auto z-prompt mb-6', {
|
348 |
+
'sticky bottom-2': chatStarted,
|
349 |
+
})}
|
350 |
+
>
|
351 |
+
<div className="bg-bolt-elements-background-depth-2">
|
352 |
+
{actionAlert && (
|
353 |
+
<ChatAlert
|
354 |
+
alert={actionAlert}
|
355 |
+
clearAlert={() => clearAlert?.()}
|
356 |
+
postMessage={(message) => {
|
357 |
+
sendMessage?.({} as any, message);
|
358 |
+
clearAlert?.();
|
359 |
+
}}
|
360 |
+
/>
|
361 |
+
)}
|
362 |
+
</div>
|
363 |
+
{progressAnnotations && <ProgressCompilation data={progressAnnotations} />}
|
364 |
+
<div
|
365 |
+
className={classNames(
|
366 |
+
'bg-bolt-elements-background-depth-2 p-3 rounded-lg border border-bolt-elements-borderColor relative w-full max-w-chat mx-auto z-prompt',
|
367 |
+
|
368 |
+
/*
|
369 |
+
* {
|
370 |
+
* 'sticky bottom-2': chatStarted,
|
371 |
+
* },
|
372 |
+
*/
|
373 |
+
)}
|
374 |
+
>
|
375 |
+
<svg className={classNames(styles.PromptEffectContainer)}>
|
376 |
+
<defs>
|
377 |
+
<linearGradient
|
378 |
+
id="line-gradient"
|
379 |
+
x1="20%"
|
380 |
+
y1="0%"
|
381 |
+
x2="-14%"
|
382 |
+
y2="10%"
|
383 |
+
gradientUnits="userSpaceOnUse"
|
384 |
+
gradientTransform="rotate(-45)"
|
385 |
+
>
|
386 |
+
<stop offset="0%" stopColor="#b44aff" stopOpacity="0%"></stop>
|
387 |
+
<stop offset="40%" stopColor="#b44aff" stopOpacity="80%"></stop>
|
388 |
+
<stop offset="50%" stopColor="#b44aff" stopOpacity="80%"></stop>
|
389 |
+
<stop offset="100%" stopColor="#b44aff" stopOpacity="0%"></stop>
|
390 |
+
</linearGradient>
|
391 |
+
<linearGradient id="shine-gradient">
|
392 |
+
<stop offset="0%" stopColor="white" stopOpacity="0%"></stop>
|
393 |
+
<stop offset="40%" stopColor="#ffffff" stopOpacity="80%"></stop>
|
394 |
+
<stop offset="50%" stopColor="#ffffff" stopOpacity="80%"></stop>
|
395 |
+
<stop offset="100%" stopColor="white" stopOpacity="0%"></stop>
|
396 |
+
</linearGradient>
|
397 |
+
</defs>
|
398 |
+
<rect className={classNames(styles.PromptEffectLine)} pathLength="100" strokeLinecap="round"></rect>
|
399 |
+
<rect className={classNames(styles.PromptShine)} x="48" y="24" width="70" height="1"></rect>
|
400 |
+
</svg>
|
401 |
+
<div>
|
402 |
+
<ClientOnly>
|
403 |
+
{() => (
|
404 |
+
<div className={isModelSettingsCollapsed ? 'hidden' : ''}>
|
405 |
+
<ModelSelector
|
406 |
+
key={provider?.name + ':' + modelList.length}
|
407 |
+
model={model}
|
408 |
+
setModel={setModel}
|
409 |
+
modelList={modelList}
|
410 |
+
provider={provider}
|
411 |
+
setProvider={setProvider}
|
412 |
+
providerList={providerList || (PROVIDER_LIST as ProviderInfo[])}
|
413 |
+
apiKeys={apiKeys}
|
414 |
+
modelLoading={isModelLoading}
|
415 |
+
/>
|
416 |
+
{(providerList || []).length > 0 && provider && !LOCAL_PROVIDERS.includes(provider.name) && (
|
417 |
+
<APIKeyManager
|
418 |
+
provider={provider}
|
419 |
+
apiKey={apiKeys[provider.name] || ''}
|
420 |
+
setApiKey={(key) => {
|
421 |
+
onApiKeysChange(provider.name, key);
|
422 |
+
}}
|
423 |
+
/>
|
424 |
+
)}
|
425 |
+
</div>
|
426 |
+
)}
|
427 |
+
</ClientOnly>
|
428 |
+
</div>
|
429 |
+
<FilePreview
|
430 |
+
files={uploadedFiles}
|
431 |
+
imageDataList={imageDataList}
|
432 |
+
onRemove={(index) => {
|
433 |
+
setUploadedFiles?.(uploadedFiles.filter((_, i) => i !== index));
|
434 |
+
setImageDataList?.(imageDataList.filter((_, i) => i !== index));
|
435 |
+
}}
|
436 |
+
/>
|
437 |
+
<ClientOnly>
|
438 |
+
{() => (
|
439 |
+
<ScreenshotStateManager
|
440 |
+
setUploadedFiles={setUploadedFiles}
|
441 |
+
setImageDataList={setImageDataList}
|
442 |
+
uploadedFiles={uploadedFiles}
|
443 |
+
imageDataList={imageDataList}
|
444 |
+
/>
|
445 |
+
)}
|
446 |
+
</ClientOnly>
|
447 |
+
<div
|
448 |
+
className={classNames(
|
449 |
+
'relative shadow-xs border border-bolt-elements-borderColor backdrop-blur rounded-lg',
|
450 |
+
)}
|
451 |
+
>
|
452 |
+
<textarea
|
453 |
+
ref={textareaRef}
|
454 |
+
className={classNames(
|
455 |
+
'w-full pl-4 pt-4 pr-16 outline-none resize-none text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary bg-transparent text-sm',
|
456 |
+
'transition-all duration-200',
|
457 |
+
'hover:border-bolt-elements-focus',
|
458 |
+
)}
|
459 |
+
onDragEnter={(e) => {
|
460 |
+
e.preventDefault();
|
461 |
+
e.currentTarget.style.border = '2px solid #1488fc';
|
462 |
+
}}
|
463 |
+
onDragOver={(e) => {
|
464 |
+
e.preventDefault();
|
465 |
+
e.currentTarget.style.border = '2px solid #1488fc';
|
466 |
+
}}
|
467 |
+
onDragLeave={(e) => {
|
468 |
+
e.preventDefault();
|
469 |
+
e.currentTarget.style.border = '1px solid var(--bolt-elements-borderColor)';
|
470 |
+
}}
|
471 |
+
onDrop={(e) => {
|
472 |
+
e.preventDefault();
|
473 |
+
e.currentTarget.style.border = '1px solid var(--bolt-elements-borderColor)';
|
474 |
+
|
475 |
+
const files = Array.from(e.dataTransfer.files);
|
476 |
+
files.forEach((file) => {
|
477 |
+
if (file.type.startsWith('image/')) {
|
478 |
+
const reader = new FileReader();
|
479 |
+
|
480 |
+
reader.onload = (e) => {
|
481 |
+
const base64Image = e.target?.result as string;
|
482 |
+
setUploadedFiles?.([...uploadedFiles, file]);
|
483 |
+
setImageDataList?.([...imageDataList, base64Image]);
|
484 |
+
};
|
485 |
+
reader.readAsDataURL(file);
|
486 |
+
}
|
487 |
+
});
|
488 |
+
}}
|
489 |
+
onKeyDown={(event) => {
|
490 |
+
if (event.key === 'Enter') {
|
491 |
+
if (event.shiftKey) {
|
492 |
+
return;
|
493 |
+
}
|
494 |
+
|
495 |
+
event.preventDefault();
|
496 |
+
|
497 |
+
if (isStreaming) {
|
498 |
+
handleStop?.();
|
499 |
+
return;
|
500 |
+
}
|
501 |
+
|
502 |
+
// ignore if using input method engine
|
503 |
+
if (event.nativeEvent.isComposing) {
|
504 |
+
return;
|
505 |
+
}
|
506 |
+
|
507 |
+
handleSendMessage?.(event);
|
508 |
+
}
|
509 |
+
}}
|
510 |
+
value={input}
|
511 |
+
onChange={(event) => {
|
512 |
+
handleInputChange?.(event);
|
513 |
+
}}
|
514 |
+
onPaste={handlePaste}
|
515 |
+
style={{
|
516 |
+
minHeight: TEXTAREA_MIN_HEIGHT,
|
517 |
+
maxHeight: TEXTAREA_MAX_HEIGHT,
|
518 |
+
}}
|
519 |
+
placeholder="How can Bolt help you today?"
|
520 |
+
translate="no"
|
521 |
+
/>
|
522 |
+
<ClientOnly>
|
523 |
+
{() => (
|
524 |
+
<SendButton
|
525 |
+
show={input.length > 0 || isStreaming || uploadedFiles.length > 0}
|
526 |
+
isStreaming={isStreaming}
|
527 |
+
disabled={!providerList || providerList.length === 0}
|
528 |
+
onClick={(event) => {
|
529 |
+
if (isStreaming) {
|
530 |
+
handleStop?.();
|
531 |
+
return;
|
532 |
+
}
|
533 |
+
|
534 |
+
if (input.length > 0 || uploadedFiles.length > 0) {
|
535 |
+
handleSendMessage?.(event);
|
536 |
+
}
|
537 |
+
}}
|
538 |
+
/>
|
539 |
+
)}
|
540 |
+
</ClientOnly>
|
541 |
+
<div className="flex justify-between items-center text-sm p-4 pt-2">
|
542 |
+
<div className="flex gap-1 items-center">
|
543 |
+
<IconButton title="Upload file" className="transition-all" onClick={() => handleFileUpload()}>
|
544 |
+
<div className="i-ph:paperclip text-xl"></div>
|
545 |
+
</IconButton>
|
546 |
+
<IconButton
|
547 |
+
title="Enhance prompt"
|
548 |
+
disabled={input.length === 0 || enhancingPrompt}
|
549 |
+
className={classNames('transition-all', enhancingPrompt ? 'opacity-100' : '')}
|
550 |
+
onClick={() => {
|
551 |
+
enhancePrompt?.();
|
552 |
+
toast.success('Prompt enhanced!');
|
553 |
+
}}
|
554 |
+
>
|
555 |
+
{enhancingPrompt ? (
|
556 |
+
<div className="i-svg-spinners:90-ring-with-bg text-bolt-elements-loader-progress text-xl animate-spin"></div>
|
557 |
+
) : (
|
558 |
+
<div className="i-bolt:stars text-xl"></div>
|
559 |
+
)}
|
560 |
+
</IconButton>
|
561 |
+
|
562 |
+
<SpeechRecognitionButton
|
563 |
+
isListening={isListening}
|
564 |
+
onStart={startListening}
|
565 |
+
onStop={stopListening}
|
566 |
+
disabled={isStreaming}
|
567 |
+
/>
|
568 |
+
{chatStarted && <ClientOnly>{() => <ExportChatButton exportChat={exportChat} />}</ClientOnly>}
|
569 |
+
<IconButton
|
570 |
+
title="Model Settings"
|
571 |
+
className={classNames('transition-all flex items-center gap-1', {
|
572 |
+
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent':
|
573 |
+
isModelSettingsCollapsed,
|
574 |
+
'bg-bolt-elements-item-backgroundDefault text-bolt-elements-item-contentDefault':
|
575 |
+
!isModelSettingsCollapsed,
|
576 |
+
})}
|
577 |
+
onClick={() => setIsModelSettingsCollapsed(!isModelSettingsCollapsed)}
|
578 |
+
disabled={!providerList || providerList.length === 0}
|
579 |
+
>
|
580 |
+
<div className={`i-ph:caret-${isModelSettingsCollapsed ? 'right' : 'down'} text-lg`} />
|
581 |
+
{isModelSettingsCollapsed ? <span className="text-xs">{model}</span> : <span />}
|
582 |
+
</IconButton>
|
583 |
+
</div>
|
584 |
+
{input.length > 3 ? (
|
585 |
+
<div className="text-xs text-bolt-elements-textTertiary">
|
586 |
+
Use <kbd className="kdb px-1.5 py-0.5 rounded bg-bolt-elements-background-depth-2">Shift</kbd>{' '}
|
587 |
+
+ <kbd className="kdb px-1.5 py-0.5 rounded bg-bolt-elements-background-depth-2">Return</kbd>{' '}
|
588 |
+
a new line
|
589 |
+
</div>
|
590 |
+
) : null}
|
591 |
+
</div>
|
592 |
+
</div>
|
593 |
+
</div>
|
594 |
+
</div>
|
595 |
+
</div>
|
596 |
+
<div className="flex flex-col justify-center gap-5">
|
597 |
+
{!chatStarted && (
|
598 |
+
<div className="flex justify-center gap-2">
|
599 |
+
{ImportButtons(importChat)}
|
600 |
+
<GitCloneButton importChat={importChat} />
|
601 |
+
</div>
|
602 |
+
)}
|
603 |
+
{!chatStarted &&
|
604 |
+
ExamplePrompts((event, messageInput) => {
|
605 |
+
if (isStreaming) {
|
606 |
+
handleStop?.();
|
607 |
+
return;
|
608 |
+
}
|
609 |
+
|
610 |
+
handleSendMessage?.(event, messageInput);
|
611 |
+
})}
|
612 |
+
{!chatStarted && <StarterTemplates />}
|
613 |
+
</div>
|
614 |
+
</div>
|
615 |
+
<ClientOnly>
|
616 |
+
{() => (
|
617 |
+
<Workbench
|
618 |
+
actionRunner={actionRunner ?? ({} as ActionRunner)}
|
619 |
+
chatStarted={chatStarted}
|
620 |
+
isStreaming={isStreaming}
|
621 |
+
/>
|
622 |
+
)}
|
623 |
+
</ClientOnly>
|
624 |
+
</div>
|
625 |
+
</div>
|
626 |
+
);
|
627 |
+
|
628 |
+
return <Tooltip.Provider delayDuration={200}>{baseChat}</Tooltip.Provider>;
|
629 |
+
},
|
630 |
+
);
|
CITATION.cff
ADDED
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# This CITATION.cff file was generated with cffinit.
|
2 |
+
# Visit https://bit.ly/cffinit to generate yours today!
|
3 |
+
|
4 |
+
cff-version: 1.2.0
|
5 |
+
title: AutoGPT
|
6 |
+
message: >-
|
7 |
+
If you use this software, please cite it using the
|
8 |
+
metadata from this file.
|
9 |
+
type: software
|
10 |
+
authors:
|
11 |
+
- name: Significant Gravitas
|
12 |
+
website: 'https://agpt.co'
|
13 |
+
repository-code: 'https://github.com/Significant-Gravitas/AutoGPT'
|
14 |
+
url: 'https://agpt.co'
|
15 |
+
abstract: >-
|
16 |
+
A collection of tools and experimental open-source attempts to make GPT-4 fully
|
17 |
+
autonomous.
|
18 |
+
keywords:
|
19 |
+
- AI
|
20 |
+
- Agent
|
21 |
+
license: MIT
|
CODE_OF_CONDUCT.md
ADDED
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Code of Conduct for AutoGPT
|
2 |
+
|
3 |
+
## 1. Purpose
|
4 |
+
|
5 |
+
The purpose of this Code of Conduct is to provide guidelines for contributors to the AutoGPT projects on GitHub. We aim to create a positive and inclusive environment where all participants can contribute and collaborate effectively. By participating in this project, you agree to abide by this Code of Conduct.
|
6 |
+
|
7 |
+
## 2. Scope
|
8 |
+
|
9 |
+
This Code of Conduct applies to all contributors, maintainers, and users of the AutoGPT project. It extends to all project spaces, including but not limited to issues, pull requests, code reviews, comments, and other forms of communication within the project.
|
10 |
+
|
11 |
+
## 3. Our Standards
|
12 |
+
|
13 |
+
We encourage the following behavior:
|
14 |
+
|
15 |
+
* Being respectful and considerate to others
|
16 |
+
* Actively seeking diverse perspectives
|
17 |
+
* Providing constructive feedback and assistance
|
18 |
+
* Demonstrating empathy and understanding
|
19 |
+
|
20 |
+
We discourage the following behavior:
|
21 |
+
|
22 |
+
* Harassment or discrimination of any kind
|
23 |
+
* Disrespectful, offensive, or inappropriate language or content
|
24 |
+
* Personal attacks or insults
|
25 |
+
* Unwarranted criticism or negativity
|
26 |
+
|
27 |
+
## 4. Reporting and Enforcement
|
28 |
+
|
29 |
+
If you witness or experience any violations of this Code of Conduct, please report them to the project maintainers by email or other appropriate means. The maintainers will investigate and take appropriate action, which may include warnings, temporary or permanent bans, or other measures as necessary.
|
30 |
+
|
31 |
+
Maintainers are responsible for ensuring compliance with this Code of Conduct and may take action to address any violations.
|
32 |
+
|
33 |
+
## 5. Acknowledgements
|
34 |
+
|
35 |
+
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/version/2/0/code_of_conduct.html).
|
36 |
+
|
37 |
+
## 6. Contact
|
38 |
+
|
39 |
+
If you have any questions or concerns, please contact the project maintainers on Discord:
|
40 |
+
https://discord.gg/autogpt
|
CONTRIBUTING.md
ADDED
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# AutoGPT Contribution Guide
|
2 |
+
If you are reading this, you are probably looking for the full **[contribution guide]**,
|
3 |
+
which is part of our [wiki].
|
4 |
+
|
5 |
+
[contribution guide]: https://github.com/Significant-Gravitas/AutoGPT/wiki/Contributing
|
6 |
+
[wiki]: https://github.com/Significant-Gravitas/AutoGPT/wiki
|
7 |
+
[roadmap]: https://github.com/Significant-Gravitas/AutoGPT/discussions/6971
|
8 |
+
[kanban board]: https://github.com/orgs/Significant-Gravitas/projects/1
|
9 |
+
|
10 |
+
## Contributing to the AutoGPT Platform Folder
|
11 |
+
All contributions to [the autogpt_platform folder](https://github.com/Significant-Gravitas/AutoGPT/blob/master/autogpt_platform) will be under our [Contribution License Agreement](https://github.com/Significant-Gravitas/AutoGPT/blob/master/autogpt_platform/Contributor%20License%20Agreement%20(CLA).md). By making a pull request contributing to this folder, you agree to the terms of our CLA for your contribution. All contributions to other folders will be under the MIT license.
|
12 |
+
|
13 |
+
## In short
|
14 |
+
1. Avoid duplicate work, issues, PRs etc.
|
15 |
+
2. We encourage you to collaborate with fellow community members on some of our bigger
|
16 |
+
[todo's][roadmap]!
|
17 |
+
* We highly recommend to post your idea and discuss it in the [dev channel].
|
18 |
+
3. Create a draft PR when starting work on bigger changes.
|
19 |
+
4. Adhere to the [Code Guidelines]
|
20 |
+
5. Clearly explain your changes when submitting a PR.
|
21 |
+
6. Don't submit broken code: test/validate your changes.
|
22 |
+
7. Avoid making unnecessary changes, especially if they're purely based on your personal
|
23 |
+
preferences. Doing so is the maintainers' job. ;-)
|
24 |
+
8. Please also consider contributing something other than code; see the
|
25 |
+
[contribution guide] for options.
|
26 |
+
|
27 |
+
[dev channel]: https://discord.com/channels/1092243196446249134/1095817829405704305
|
28 |
+
[code guidelines]: https://github.com/Significant-Gravitas/AutoGPT/wiki/Contributing#code-guidelines
|
29 |
+
|
30 |
+
If you wish to involve with the project (beyond just contributing PRs), please read the
|
31 |
+
wiki page about [Catalyzing](https://github.com/Significant-Gravitas/AutoGPT/wiki/Catalyzing).
|
32 |
+
|
33 |
+
In fact, why not just look through the whole wiki (it's only a few pages) and
|
34 |
+
hop on our Discord. See you there! :-)
|
35 |
+
|
36 |
+
❤️ & 🔆
|
37 |
+
The team @ AutoGPT
|
38 |
+
https://discord.gg/autogpt
|
CObjects.cpp
ADDED
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
#ifndef COBJECTS_CPP
|
2 |
+
#define COBJECTS_CPP
|
3 |
+
/*****************************************************************************
|
4 |
+
* C interface
|
5 |
+
*
|
6 |
+
* These are exported using the CObject API
|
7 |
+
*/
|
8 |
+
#ifdef __clang__
|
9 |
+
# pragma clang diagnostic push
|
10 |
+
# pragma clang diagnostic ignored "-Wunused-function"
|
11 |
+
#endif
|
12 |
+
|
13 |
+
#include "greenlet_exceptions.hpp"
|
14 |
+
|
15 |
+
#include "greenlet_internal.hpp"
|
16 |
+
#include "greenlet_refs.hpp"
|
17 |
+
|
18 |
+
|
19 |
+
#include "TThreadStateDestroy.cpp"
|
20 |
+
|
21 |
+
#include "PyGreenlet.hpp"
|
22 |
+
|
23 |
+
using greenlet::PyErrOccurred;
|
24 |
+
using greenlet::Require;
|
25 |
+
|
26 |
+
|
27 |
+
|
28 |
+
extern "C" {
|
29 |
+
static PyGreenlet*
|
30 |
+
PyGreenlet_GetCurrent(void)
|
31 |
+
{
|
32 |
+
return GET_THREAD_STATE().state().get_current().relinquish_ownership();
|
33 |
+
}
|
34 |
+
|
35 |
+
static int
|
36 |
+
PyGreenlet_SetParent(PyGreenlet* g, PyGreenlet* nparent)
|
37 |
+
{
|
38 |
+
return green_setparent((PyGreenlet*)g, (PyObject*)nparent, NULL);
|
39 |
+
}
|
40 |
+
|
41 |
+
static PyGreenlet*
|
42 |
+
PyGreenlet_New(PyObject* run, PyGreenlet* parent)
|
43 |
+
{
|
44 |
+
using greenlet::refs::NewDictReference;
|
45 |
+
// In the past, we didn't use green_new and green_init, but that
|
46 |
+
// was a maintenance issue because we duplicated code. This way is
|
47 |
+
// much safer, but slightly slower. If that's a problem, we could
|
48 |
+
// refactor green_init to separate argument parsing from initialization.
|
49 |
+
OwnedGreenlet g = OwnedGreenlet::consuming(green_new(&PyGreenlet_Type, nullptr, nullptr));
|
50 |
+
if (!g) {
|
51 |
+
return NULL;
|
52 |
+
}
|
53 |
+
|
54 |
+
try {
|
55 |
+
NewDictReference kwargs;
|
56 |
+
if (run) {
|
57 |
+
kwargs.SetItem(mod_globs->str_run, run);
|
58 |
+
}
|
59 |
+
if (parent) {
|
60 |
+
kwargs.SetItem("parent", (PyObject*)parent);
|
61 |
+
}
|
62 |
+
|
63 |
+
Require(green_init(g.borrow(), mod_globs->empty_tuple, kwargs.borrow()));
|
64 |
+
}
|
65 |
+
catch (const PyErrOccurred&) {
|
66 |
+
return nullptr;
|
67 |
+
}
|
68 |
+
|
69 |
+
return g.relinquish_ownership();
|
70 |
+
}
|
71 |
+
|
72 |
+
static PyObject*
|
73 |
+
PyGreenlet_Switch(PyGreenlet* self, PyObject* args, PyObject* kwargs)
|
74 |
+
{
|
75 |
+
if (!PyGreenlet_Check(self)) {
|
76 |
+
PyErr_BadArgument();
|
77 |
+
return NULL;
|
78 |
+
}
|
79 |
+
|
80 |
+
if (args == NULL) {
|
81 |
+
args = mod_globs->empty_tuple;
|
82 |
+
}
|
83 |
+
|
84 |
+
if (kwargs == NULL || !PyDict_Check(kwargs)) {
|
85 |
+
kwargs = NULL;
|
86 |
+
}
|
87 |
+
|
88 |
+
return green_switch(self, args, kwargs);
|
89 |
+
}
|
90 |
+
|
91 |
+
static PyObject*
|
92 |
+
PyGreenlet_Throw(PyGreenlet* self, PyObject* typ, PyObject* val, PyObject* tb)
|
93 |
+
{
|
94 |
+
if (!PyGreenlet_Check(self)) {
|
95 |
+
PyErr_BadArgument();
|
96 |
+
return nullptr;
|
97 |
+
}
|
98 |
+
try {
|
99 |
+
PyErrPieces err_pieces(typ, val, tb);
|
100 |
+
return internal_green_throw(self, err_pieces).relinquish_ownership();
|
101 |
+
}
|
102 |
+
catch (const PyErrOccurred&) {
|
103 |
+
return nullptr;
|
104 |
+
}
|
105 |
+
}
|
106 |
+
|
107 |
+
|
108 |
+
|
109 |
+
static int
|
110 |
+
Extern_PyGreenlet_MAIN(PyGreenlet* self)
|
111 |
+
{
|
112 |
+
if (!PyGreenlet_Check(self)) {
|
113 |
+
PyErr_BadArgument();
|
114 |
+
return -1;
|
115 |
+
}
|
116 |
+
return self->pimpl->main();
|
117 |
+
}
|
118 |
+
|
119 |
+
static int
|
120 |
+
Extern_PyGreenlet_ACTIVE(PyGreenlet* self)
|
121 |
+
{
|
122 |
+
if (!PyGreenlet_Check(self)) {
|
123 |
+
PyErr_BadArgument();
|
124 |
+
return -1;
|
125 |
+
}
|
126 |
+
return self->pimpl->active();
|
127 |
+
}
|
128 |
+
|
129 |
+
static int
|
130 |
+
Extern_PyGreenlet_STARTED(PyGreenlet* self)
|
131 |
+
{
|
132 |
+
if (!PyGreenlet_Check(self)) {
|
133 |
+
PyErr_BadArgument();
|
134 |
+
return -1;
|
135 |
+
}
|
136 |
+
return self->pimpl->started();
|
137 |
+
}
|
138 |
+
|
139 |
+
static PyGreenlet*
|
140 |
+
Extern_PyGreenlet_GET_PARENT(PyGreenlet* self)
|
141 |
+
{
|
142 |
+
if (!PyGreenlet_Check(self)) {
|
143 |
+
PyErr_BadArgument();
|
144 |
+
return NULL;
|
145 |
+
}
|
146 |
+
// This can return NULL even if there is no exception
|
147 |
+
return self->pimpl->parent().acquire();
|
148 |
+
}
|
149 |
+
} // extern C.
|
150 |
+
|
151 |
+
/** End C API ****************************************************************/
|
152 |
+
#ifdef __clang__
|
153 |
+
# pragma clang diagnostic pop
|
154 |
+
#endif
|
155 |
+
|
156 |
+
|
157 |
+
#endif
|
Chat.client.tsx
ADDED
@@ -0,0 +1,524 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/*
|
2 |
+
* @ts-nocheck
|
3 |
+
* Preventing TS checks with files presented in the video for a better presentation.
|
4 |
+
*/
|
5 |
+
import { useStore } from '@nanostores/react';
|
6 |
+
import type { Message } from 'ai';
|
7 |
+
import { useChat } from 'ai/react';
|
8 |
+
import { useAnimate } from 'framer-motion';
|
9 |
+
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
10 |
+
import { cssTransition, toast, ToastContainer } from 'react-toastify';
|
11 |
+
import { useMessageParser, usePromptEnhancer, useShortcuts, useSnapScroll } from '~/lib/hooks';
|
12 |
+
import { description, useChatHistory } from '~/lib/persistence';
|
13 |
+
import { chatStore } from '~/lib/stores/chat';
|
14 |
+
import { workbenchStore } from '~/lib/stores/workbench';
|
15 |
+
import { DEFAULT_MODEL, DEFAULT_PROVIDER, PROMPT_COOKIE_KEY, PROVIDER_LIST } from '~/utils/constants';
|
16 |
+
import { cubicEasingFn } from '~/utils/easings';
|
17 |
+
import { createScopedLogger, renderLogger } from '~/utils/logger';
|
18 |
+
import { BaseChat } from './BaseChat';
|
19 |
+
import Cookies from 'js-cookie';
|
20 |
+
import { debounce } from '~/utils/debounce';
|
21 |
+
import { useSettings } from '~/lib/hooks/useSettings';
|
22 |
+
import type { ProviderInfo } from '~/types/model';
|
23 |
+
import { useSearchParams } from '@remix-run/react';
|
24 |
+
import { createSampler } from '~/utils/sampler';
|
25 |
+
import { getTemplates, selectStarterTemplate } from '~/utils/selectStarterTemplate';
|
26 |
+
import { logStore } from '~/lib/stores/logs';
|
27 |
+
import { streamingState } from '~/lib/stores/streaming';
|
28 |
+
import { filesToArtifacts } from '~/utils/fileUtils';
|
29 |
+
|
30 |
+
const toastAnimation = cssTransition({
|
31 |
+
enter: 'animated fadeInRight',
|
32 |
+
exit: 'animated fadeOutRight',
|
33 |
+
});
|
34 |
+
|
35 |
+
const logger = createScopedLogger('Chat');
|
36 |
+
|
37 |
+
export function Chat() {
|
38 |
+
renderLogger.trace('Chat');
|
39 |
+
|
40 |
+
const { ready, initialMessages, storeMessageHistory, importChat, exportChat } = useChatHistory();
|
41 |
+
const title = useStore(description);
|
42 |
+
useEffect(() => {
|
43 |
+
workbenchStore.setReloadedMessages(initialMessages.map((m) => m.id));
|
44 |
+
}, [initialMessages]);
|
45 |
+
|
46 |
+
return (
|
47 |
+
<>
|
48 |
+
{ready && (
|
49 |
+
<ChatImpl
|
50 |
+
description={title}
|
51 |
+
initialMessages={initialMessages}
|
52 |
+
exportChat={exportChat}
|
53 |
+
storeMessageHistory={storeMessageHistory}
|
54 |
+
importChat={importChat}
|
55 |
+
/>
|
56 |
+
)}
|
57 |
+
<ToastContainer
|
58 |
+
closeButton={({ closeToast }) => {
|
59 |
+
return (
|
60 |
+
<button className="Toastify__close-button" onClick={closeToast}>
|
61 |
+
<div className="i-ph:x text-lg" />
|
62 |
+
</button>
|
63 |
+
);
|
64 |
+
}}
|
65 |
+
icon={({ type }) => {
|
66 |
+
/**
|
67 |
+
* @todo Handle more types if we need them. This may require extra color palettes.
|
68 |
+
*/
|
69 |
+
switch (type) {
|
70 |
+
case 'success': {
|
71 |
+
return <div className="i-ph:check-bold text-bolt-elements-icon-success text-2xl" />;
|
72 |
+
}
|
73 |
+
case 'error': {
|
74 |
+
return <div className="i-ph:warning-circle-bold text-bolt-elements-icon-error text-2xl" />;
|
75 |
+
}
|
76 |
+
}
|
77 |
+
|
78 |
+
return undefined;
|
79 |
+
}}
|
80 |
+
position="bottom-right"
|
81 |
+
pauseOnFocusLoss
|
82 |
+
transition={toastAnimation}
|
83 |
+
/>
|
84 |
+
</>
|
85 |
+
);
|
86 |
+
}
|
87 |
+
|
88 |
+
const processSampledMessages = createSampler(
|
89 |
+
(options: {
|
90 |
+
messages: Message[];
|
91 |
+
initialMessages: Message[];
|
92 |
+
isLoading: boolean;
|
93 |
+
parseMessages: (messages: Message[], isLoading: boolean) => void;
|
94 |
+
storeMessageHistory: (messages: Message[]) => Promise<void>;
|
95 |
+
}) => {
|
96 |
+
const { messages, initialMessages, isLoading, parseMessages, storeMessageHistory } = options;
|
97 |
+
parseMessages(messages, isLoading);
|
98 |
+
|
99 |
+
if (messages.length > initialMessages.length) {
|
100 |
+
storeMessageHistory(messages).catch((error) => toast.error(error.message));
|
101 |
+
}
|
102 |
+
},
|
103 |
+
50,
|
104 |
+
);
|
105 |
+
|
106 |
+
interface ChatProps {
|
107 |
+
initialMessages: Message[];
|
108 |
+
storeMessageHistory: (messages: Message[]) => Promise<void>;
|
109 |
+
importChat: (description: string, messages: Message[]) => Promise<void>;
|
110 |
+
exportChat: () => void;
|
111 |
+
description?: string;
|
112 |
+
}
|
113 |
+
|
114 |
+
export const ChatImpl = memo(
|
115 |
+
({ description, initialMessages, storeMessageHistory, importChat, exportChat }: ChatProps) => {
|
116 |
+
useShortcuts();
|
117 |
+
|
118 |
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
119 |
+
const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
|
120 |
+
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
|
121 |
+
const [imageDataList, setImageDataList] = useState<string[]>([]);
|
122 |
+
const [searchParams, setSearchParams] = useSearchParams();
|
123 |
+
const [fakeLoading, setFakeLoading] = useState(false);
|
124 |
+
const files = useStore(workbenchStore.files);
|
125 |
+
const actionAlert = useStore(workbenchStore.alert);
|
126 |
+
const { activeProviders, promptId, autoSelectTemplate, contextOptimizationEnabled } = useSettings();
|
127 |
+
|
128 |
+
const [model, setModel] = useState(() => {
|
129 |
+
const savedModel = Cookies.get('selectedModel');
|
130 |
+
return savedModel || DEFAULT_MODEL;
|
131 |
+
});
|
132 |
+
const [provider, setProvider] = useState(() => {
|
133 |
+
const savedProvider = Cookies.get('selectedProvider');
|
134 |
+
return (PROVIDER_LIST.find((p) => p.name === savedProvider) || DEFAULT_PROVIDER) as ProviderInfo;
|
135 |
+
});
|
136 |
+
|
137 |
+
const { showChat } = useStore(chatStore);
|
138 |
+
|
139 |
+
const [animationScope, animate] = useAnimate();
|
140 |
+
|
141 |
+
const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
|
142 |
+
|
143 |
+
const {
|
144 |
+
messages,
|
145 |
+
isLoading,
|
146 |
+
input,
|
147 |
+
handleInputChange,
|
148 |
+
setInput,
|
149 |
+
stop,
|
150 |
+
append,
|
151 |
+
setMessages,
|
152 |
+
reload,
|
153 |
+
error,
|
154 |
+
data: chatData,
|
155 |
+
setData,
|
156 |
+
} = useChat({
|
157 |
+
api: '/api/chat',
|
158 |
+
body: {
|
159 |
+
apiKeys,
|
160 |
+
files,
|
161 |
+
promptId,
|
162 |
+
contextOptimization: contextOptimizationEnabled,
|
163 |
+
},
|
164 |
+
sendExtraMessageFields: true,
|
165 |
+
onError: (e) => {
|
166 |
+
logger.error('Request failed\n\n', e, error);
|
167 |
+
logStore.logError('Chat request failed', e, {
|
168 |
+
component: 'Chat',
|
169 |
+
action: 'request',
|
170 |
+
error: e.message,
|
171 |
+
});
|
172 |
+
toast.error(
|
173 |
+
'There was an error processing your request: ' + (e.message ? e.message : 'No details were returned'),
|
174 |
+
);
|
175 |
+
},
|
176 |
+
onFinish: (message, response) => {
|
177 |
+
const usage = response.usage;
|
178 |
+
setData(undefined);
|
179 |
+
|
180 |
+
if (usage) {
|
181 |
+
console.log('Token usage:', usage);
|
182 |
+
logStore.logProvider('Chat response completed', {
|
183 |
+
component: 'Chat',
|
184 |
+
action: 'response',
|
185 |
+
model,
|
186 |
+
provider: provider.name,
|
187 |
+
usage,
|
188 |
+
messageLength: message.content.length,
|
189 |
+
});
|
190 |
+
}
|
191 |
+
|
192 |
+
logger.debug('Finished streaming');
|
193 |
+
},
|
194 |
+
initialMessages,
|
195 |
+
initialInput: Cookies.get(PROMPT_COOKIE_KEY) || '',
|
196 |
+
});
|
197 |
+
useEffect(() => {
|
198 |
+
const prompt = searchParams.get('prompt');
|
199 |
+
|
200 |
+
// console.log(prompt, searchParams, model, provider);
|
201 |
+
|
202 |
+
if (prompt) {
|
203 |
+
setSearchParams({});
|
204 |
+
runAnimation();
|
205 |
+
append({
|
206 |
+
role: 'user',
|
207 |
+
content: [
|
208 |
+
{
|
209 |
+
type: 'text',
|
210 |
+
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${prompt}`,
|
211 |
+
},
|
212 |
+
] as any, // Type assertion to bypass compiler check
|
213 |
+
});
|
214 |
+
}
|
215 |
+
}, [model, provider, searchParams]);
|
216 |
+
|
217 |
+
const { enhancingPrompt, promptEnhanced, enhancePrompt, resetEnhancer } = usePromptEnhancer();
|
218 |
+
const { parsedMessages, parseMessages } = useMessageParser();
|
219 |
+
|
220 |
+
const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
|
221 |
+
|
222 |
+
useEffect(() => {
|
223 |
+
chatStore.setKey('started', initialMessages.length > 0);
|
224 |
+
}, []);
|
225 |
+
|
226 |
+
useEffect(() => {
|
227 |
+
processSampledMessages({
|
228 |
+
messages,
|
229 |
+
initialMessages,
|
230 |
+
isLoading,
|
231 |
+
parseMessages,
|
232 |
+
storeMessageHistory,
|
233 |
+
});
|
234 |
+
}, [messages, isLoading, parseMessages]);
|
235 |
+
|
236 |
+
const scrollTextArea = () => {
|
237 |
+
const textarea = textareaRef.current;
|
238 |
+
|
239 |
+
if (textarea) {
|
240 |
+
textarea.scrollTop = textarea.scrollHeight;
|
241 |
+
}
|
242 |
+
};
|
243 |
+
|
244 |
+
const abort = () => {
|
245 |
+
stop();
|
246 |
+
chatStore.setKey('aborted', true);
|
247 |
+
workbenchStore.abortAllActions();
|
248 |
+
|
249 |
+
logStore.logProvider('Chat response aborted', {
|
250 |
+
component: 'Chat',
|
251 |
+
action: 'abort',
|
252 |
+
model,
|
253 |
+
provider: provider.name,
|
254 |
+
});
|
255 |
+
};
|
256 |
+
|
257 |
+
useEffect(() => {
|
258 |
+
const textarea = textareaRef.current;
|
259 |
+
|
260 |
+
if (textarea) {
|
261 |
+
textarea.style.height = 'auto';
|
262 |
+
|
263 |
+
const scrollHeight = textarea.scrollHeight;
|
264 |
+
|
265 |
+
textarea.style.height = `${Math.min(scrollHeight, TEXTAREA_MAX_HEIGHT)}px`;
|
266 |
+
textarea.style.overflowY = scrollHeight > TEXTAREA_MAX_HEIGHT ? 'auto' : 'hidden';
|
267 |
+
}
|
268 |
+
}, [input, textareaRef]);
|
269 |
+
|
270 |
+
const runAnimation = async () => {
|
271 |
+
if (chatStarted) {
|
272 |
+
return;
|
273 |
+
}
|
274 |
+
|
275 |
+
await Promise.all([
|
276 |
+
animate('#examples', { opacity: 0, display: 'none' }, { duration: 0.1 }),
|
277 |
+
animate('#intro', { opacity: 0, flex: 1 }, { duration: 0.2, ease: cubicEasingFn }),
|
278 |
+
]);
|
279 |
+
|
280 |
+
chatStore.setKey('started', true);
|
281 |
+
|
282 |
+
setChatStarted(true);
|
283 |
+
};
|
284 |
+
|
285 |
+
const sendMessage = async (_event: React.UIEvent, messageInput?: string) => {
|
286 |
+
const messageContent = messageInput || input;
|
287 |
+
|
288 |
+
if (!messageContent?.trim()) {
|
289 |
+
return;
|
290 |
+
}
|
291 |
+
|
292 |
+
if (isLoading) {
|
293 |
+
abort();
|
294 |
+
return;
|
295 |
+
}
|
296 |
+
|
297 |
+
runAnimation();
|
298 |
+
|
299 |
+
if (!chatStarted) {
|
300 |
+
setFakeLoading(true);
|
301 |
+
|
302 |
+
if (autoSelectTemplate) {
|
303 |
+
const { template, title } = await selectStarterTemplate({
|
304 |
+
message: messageContent,
|
305 |
+
model,
|
306 |
+
provider,
|
307 |
+
});
|
308 |
+
|
309 |
+
if (template !== 'blank') {
|
310 |
+
const temResp = await getTemplates(template, title).catch((e) => {
|
311 |
+
if (e.message.includes('rate limit')) {
|
312 |
+
toast.warning('Rate limit exceeded. Skipping starter template\n Continuing with blank template');
|
313 |
+
} else {
|
314 |
+
toast.warning('Failed to import starter template\n Continuing with blank template');
|
315 |
+
}
|
316 |
+
|
317 |
+
return null;
|
318 |
+
});
|
319 |
+
|
320 |
+
if (temResp) {
|
321 |
+
const { assistantMessage, userMessage } = temResp;
|
322 |
+
setMessages([
|
323 |
+
{
|
324 |
+
id: `1-${new Date().getTime()}`,
|
325 |
+
role: 'user',
|
326 |
+
content: messageContent,
|
327 |
+
},
|
328 |
+
{
|
329 |
+
id: `2-${new Date().getTime()}`,
|
330 |
+
role: 'assistant',
|
331 |
+
content: assistantMessage,
|
332 |
+
},
|
333 |
+
{
|
334 |
+
id: `3-${new Date().getTime()}`,
|
335 |
+
role: 'user',
|
336 |
+
content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${userMessage}`,
|
337 |
+
annotations: ['hidden'],
|
338 |
+
},
|
339 |
+
]);
|
340 |
+
reload();
|
341 |
+
setFakeLoading(false);
|
342 |
+
|
343 |
+
return;
|
344 |
+
}
|
345 |
+
}
|
346 |
+
}
|
347 |
+
|
348 |
+
// If autoSelectTemplate is disabled or template selection failed, proceed with normal message
|
349 |
+
setMessages([
|
350 |
+
{
|
351 |
+
id: `${new Date().getTime()}`,
|
352 |
+
role: 'user',
|
353 |
+
content: [
|
354 |
+
{
|
355 |
+
type: 'text',
|
356 |
+
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${messageContent}`,
|
357 |
+
},
|
358 |
+
...imageDataList.map((imageData) => ({
|
359 |
+
type: 'image',
|
360 |
+
image: imageData,
|
361 |
+
})),
|
362 |
+
] as any,
|
363 |
+
},
|
364 |
+
]);
|
365 |
+
reload();
|
366 |
+
setFakeLoading(false);
|
367 |
+
|
368 |
+
return;
|
369 |
+
}
|
370 |
+
|
371 |
+
if (error != null) {
|
372 |
+
setMessages(messages.slice(0, -1));
|
373 |
+
}
|
374 |
+
|
375 |
+
const modifiedFiles = workbenchStore.getModifiedFiles();
|
376 |
+
|
377 |
+
chatStore.setKey('aborted', false);
|
378 |
+
|
379 |
+
if (modifiedFiles !== undefined) {
|
380 |
+
const userUpdateArtifact = filesToArtifacts(modifiedFiles, `${Date.now()}`);
|
381 |
+
append({
|
382 |
+
role: 'user',
|
383 |
+
content: [
|
384 |
+
{
|
385 |
+
type: 'text',
|
386 |
+
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${userUpdateArtifact}${messageContent}`,
|
387 |
+
},
|
388 |
+
...imageDataList.map((imageData) => ({
|
389 |
+
type: 'image',
|
390 |
+
image: imageData,
|
391 |
+
})),
|
392 |
+
] as any,
|
393 |
+
});
|
394 |
+
|
395 |
+
workbenchStore.resetAllFileModifications();
|
396 |
+
} else {
|
397 |
+
append({
|
398 |
+
role: 'user',
|
399 |
+
content: [
|
400 |
+
{
|
401 |
+
type: 'text',
|
402 |
+
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${messageContent}`,
|
403 |
+
},
|
404 |
+
...imageDataList.map((imageData) => ({
|
405 |
+
type: 'image',
|
406 |
+
image: imageData,
|
407 |
+
})),
|
408 |
+
] as any,
|
409 |
+
});
|
410 |
+
}
|
411 |
+
|
412 |
+
setInput('');
|
413 |
+
Cookies.remove(PROMPT_COOKIE_KEY);
|
414 |
+
|
415 |
+
setUploadedFiles([]);
|
416 |
+
setImageDataList([]);
|
417 |
+
|
418 |
+
resetEnhancer();
|
419 |
+
|
420 |
+
textareaRef.current?.blur();
|
421 |
+
};
|
422 |
+
|
423 |
+
/**
|
424 |
+
* Handles the change event for the textarea and updates the input state.
|
425 |
+
* @param event - The change event from the textarea.
|
426 |
+
*/
|
427 |
+
const onTextareaChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
428 |
+
handleInputChange(event);
|
429 |
+
};
|
430 |
+
|
431 |
+
/**
|
432 |
+
* Debounced function to cache the prompt in cookies.
|
433 |
+
* Caches the trimmed value of the textarea input after a delay to optimize performance.
|
434 |
+
*/
|
435 |
+
const debouncedCachePrompt = useCallback(
|
436 |
+
debounce((event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
437 |
+
const trimmedValue = event.target.value.trim();
|
438 |
+
Cookies.set(PROMPT_COOKIE_KEY, trimmedValue, { expires: 30 });
|
439 |
+
}, 1000),
|
440 |
+
[],
|
441 |
+
);
|
442 |
+
|
443 |
+
const [messageRef, scrollRef] = useSnapScroll();
|
444 |
+
|
445 |
+
useEffect(() => {
|
446 |
+
const storedApiKeys = Cookies.get('apiKeys');
|
447 |
+
|
448 |
+
if (storedApiKeys) {
|
449 |
+
setApiKeys(JSON.parse(storedApiKeys));
|
450 |
+
}
|
451 |
+
}, []);
|
452 |
+
|
453 |
+
const handleModelChange = (newModel: string) => {
|
454 |
+
setModel(newModel);
|
455 |
+
Cookies.set('selectedModel', newModel, { expires: 30 });
|
456 |
+
};
|
457 |
+
|
458 |
+
const handleProviderChange = (newProvider: ProviderInfo) => {
|
459 |
+
setProvider(newProvider);
|
460 |
+
Cookies.set('selectedProvider', newProvider.name, { expires: 30 });
|
461 |
+
};
|
462 |
+
|
463 |
+
return (
|
464 |
+
<BaseChat
|
465 |
+
ref={animationScope}
|
466 |
+
textareaRef={textareaRef}
|
467 |
+
input={input}
|
468 |
+
showChat={showChat}
|
469 |
+
chatStarted={chatStarted}
|
470 |
+
isStreaming={isLoading || fakeLoading}
|
471 |
+
onStreamingChange={(streaming) => {
|
472 |
+
streamingState.set(streaming);
|
473 |
+
}}
|
474 |
+
enhancingPrompt={enhancingPrompt}
|
475 |
+
promptEnhanced={promptEnhanced}
|
476 |
+
sendMessage={sendMessage}
|
477 |
+
model={model}
|
478 |
+
setModel={handleModelChange}
|
479 |
+
provider={provider}
|
480 |
+
setProvider={handleProviderChange}
|
481 |
+
providerList={activeProviders}
|
482 |
+
messageRef={messageRef}
|
483 |
+
scrollRef={scrollRef}
|
484 |
+
handleInputChange={(e) => {
|
485 |
+
onTextareaChange(e);
|
486 |
+
debouncedCachePrompt(e);
|
487 |
+
}}
|
488 |
+
handleStop={abort}
|
489 |
+
description={description}
|
490 |
+
importChat={importChat}
|
491 |
+
exportChat={exportChat}
|
492 |
+
messages={messages.map((message, i) => {
|
493 |
+
if (message.role === 'user') {
|
494 |
+
return message;
|
495 |
+
}
|
496 |
+
|
497 |
+
return {
|
498 |
+
...message,
|
499 |
+
content: parsedMessages[i] || '',
|
500 |
+
};
|
501 |
+
})}
|
502 |
+
enhancePrompt={() => {
|
503 |
+
enhancePrompt(
|
504 |
+
input,
|
505 |
+
(input) => {
|
506 |
+
setInput(input);
|
507 |
+
scrollTextArea();
|
508 |
+
},
|
509 |
+
model,
|
510 |
+
provider,
|
511 |
+
apiKeys,
|
512 |
+
);
|
513 |
+
}}
|
514 |
+
uploadedFiles={uploadedFiles}
|
515 |
+
setUploadedFiles={setUploadedFiles}
|
516 |
+
imageDataList={imageDataList}
|
517 |
+
setImageDataList={setImageDataList}
|
518 |
+
actionAlert={actionAlert}
|
519 |
+
clearAlert={() => workbenchStore.clearAlert()}
|
520 |
+
data={chatData}
|
521 |
+
/>
|
522 |
+
);
|
523 |
+
},
|
524 |
+
);
|
ChatAlert.tsx
ADDED
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { AnimatePresence, motion } from 'framer-motion';
|
2 |
+
import type { ActionAlert } from '~/types/actions';
|
3 |
+
import { classNames } from '~/utils/classNames';
|
4 |
+
|
5 |
+
interface Props {
|
6 |
+
alert: ActionAlert;
|
7 |
+
clearAlert: () => void;
|
8 |
+
postMessage: (message: string) => void;
|
9 |
+
}
|
10 |
+
|
11 |
+
export default function ChatAlert({ alert, clearAlert, postMessage }: Props) {
|
12 |
+
const { description, content, source } = alert;
|
13 |
+
|
14 |
+
const isPreview = source === 'preview';
|
15 |
+
const title = isPreview ? 'Preview Error' : 'Terminal Error';
|
16 |
+
const message = isPreview
|
17 |
+
? 'We encountered an error while running the preview. Would you like Bolt to analyze and help resolve this issue?'
|
18 |
+
: 'We encountered an error while running terminal commands. Would you like Bolt to analyze and help resolve this issue?';
|
19 |
+
|
20 |
+
return (
|
21 |
+
<AnimatePresence>
|
22 |
+
<motion.div
|
23 |
+
initial={{ opacity: 0, y: -20 }}
|
24 |
+
animate={{ opacity: 1, y: 0 }}
|
25 |
+
exit={{ opacity: 0, y: -20 }}
|
26 |
+
transition={{ duration: 0.3 }}
|
27 |
+
className={`rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-background-depth-2 p-4 mb-2`}
|
28 |
+
>
|
29 |
+
<div className="flex items-start">
|
30 |
+
{/* Icon */}
|
31 |
+
<motion.div
|
32 |
+
className="flex-shrink-0"
|
33 |
+
initial={{ scale: 0 }}
|
34 |
+
animate={{ scale: 1 }}
|
35 |
+
transition={{ delay: 0.2 }}
|
36 |
+
>
|
37 |
+
<div className={`i-ph:warning-duotone text-xl text-bolt-elements-button-danger-text`}></div>
|
38 |
+
</motion.div>
|
39 |
+
{/* Content */}
|
40 |
+
<div className="ml-3 flex-1">
|
41 |
+
<motion.h3
|
42 |
+
initial={{ opacity: 0 }}
|
43 |
+
animate={{ opacity: 1 }}
|
44 |
+
transition={{ delay: 0.1 }}
|
45 |
+
className={`text-sm font-medium text-bolt-elements-textPrimary`}
|
46 |
+
>
|
47 |
+
{title}
|
48 |
+
</motion.h3>
|
49 |
+
<motion.div
|
50 |
+
initial={{ opacity: 0 }}
|
51 |
+
animate={{ opacity: 1 }}
|
52 |
+
transition={{ delay: 0.2 }}
|
53 |
+
className={`mt-2 text-sm text-bolt-elements-textSecondary`}
|
54 |
+
>
|
55 |
+
<p>{message}</p>
|
56 |
+
{description && (
|
57 |
+
<div className="text-xs text-bolt-elements-textSecondary p-2 bg-bolt-elements-background-depth-3 rounded mt-4 mb-4">
|
58 |
+
Error: {description}
|
59 |
+
</div>
|
60 |
+
)}
|
61 |
+
</motion.div>
|
62 |
+
|
63 |
+
{/* Actions */}
|
64 |
+
<motion.div
|
65 |
+
className="mt-4"
|
66 |
+
initial={{ opacity: 0, y: 10 }}
|
67 |
+
animate={{ opacity: 1, y: 0 }}
|
68 |
+
transition={{ delay: 0.3 }}
|
69 |
+
>
|
70 |
+
<div className={classNames(' flex gap-2')}>
|
71 |
+
<button
|
72 |
+
onClick={() =>
|
73 |
+
postMessage(
|
74 |
+
`*Fix this ${isPreview ? 'preview' : 'terminal'} error* \n\`\`\`${isPreview ? 'js' : 'sh'}\n${content}\n\`\`\`\n`,
|
75 |
+
)
|
76 |
+
}
|
77 |
+
className={classNames(
|
78 |
+
`px-2 py-1.5 rounded-md text-sm font-medium`,
|
79 |
+
'bg-bolt-elements-button-primary-background',
|
80 |
+
'hover:bg-bolt-elements-button-primary-backgroundHover',
|
81 |
+
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-bolt-elements-button-danger-background',
|
82 |
+
'text-bolt-elements-button-primary-text',
|
83 |
+
'flex items-center gap-1.5',
|
84 |
+
)}
|
85 |
+
>
|
86 |
+
<div className="i-ph:chat-circle-duotone"></div>
|
87 |
+
Ask Bolt
|
88 |
+
</button>
|
89 |
+
<button
|
90 |
+
onClick={clearAlert}
|
91 |
+
className={classNames(
|
92 |
+
`px-2 py-1.5 rounded-md text-sm font-medium`,
|
93 |
+
'bg-bolt-elements-button-secondary-background',
|
94 |
+
'hover:bg-bolt-elements-button-secondary-backgroundHover',
|
95 |
+
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-bolt-elements-button-secondary-background',
|
96 |
+
'text-bolt-elements-button-secondary-text',
|
97 |
+
)}
|
98 |
+
>
|
99 |
+
Dismiss
|
100 |
+
</button>
|
101 |
+
</div>
|
102 |
+
</motion.div>
|
103 |
+
</div>
|
104 |
+
</div>
|
105 |
+
</motion.div>
|
106 |
+
</AnimatePresence>
|
107 |
+
);
|
108 |
+
}
|
ChatInterface.tsx
ADDED
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// /home/ubuntu/visionos-frontend/src/components/ChatInterface.tsx
|
2 |
+
import React from 'react';
|
3 |
+
|
4 |
+
const ChatInterface: React.FC = () => {
|
5 |
+
return (
|
6 |
+
<div className="flex flex-col h-full border rounded-lg shadow-md">
|
7 |
+
{/* Message Display Area */}
|
8 |
+
<div className="flex-grow p-4 overflow-y-auto bg-gray-50">
|
9 |
+
{/* Placeholder for messages */}
|
10 |
+
<p className="text-gray-500">Chat messages will appear here...</p>
|
11 |
+
</div>
|
12 |
+
|
13 |
+
{/* Input Area */}
|
14 |
+
<div className="p-4 border-t bg-white">
|
15 |
+
<input
|
16 |
+
type="text"
|
17 |
+
placeholder="Type your message..."
|
18 |
+
className="w-full p-2 border rounded"
|
19 |
+
/>
|
20 |
+
{/* Placeholder for Send Button */}
|
21 |
+
<button className="mt-2 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">
|
22 |
+
Send
|
23 |
+
</button>
|
24 |
+
</div>
|
25 |
+
</div>
|
26 |
+
);
|
27 |
+
};
|
28 |
+
|
29 |
+
export default ChatInterface;
|
30 |
+
|
CloudProvidersTab.tsx
ADDED
@@ -0,0 +1,305 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useEffect, useState, useCallback } from 'react';
|
2 |
+
import { Switch } from '~/components/ui/Switch';
|
3 |
+
import { useSettings } from '~/lib/hooks/useSettings';
|
4 |
+
import { URL_CONFIGURABLE_PROVIDERS } from '~/lib/stores/settings';
|
5 |
+
import type { IProviderConfig } from '~/types/model';
|
6 |
+
import { logStore } from '~/lib/stores/logs';
|
7 |
+
import { motion } from 'framer-motion';
|
8 |
+
import { classNames } from '~/utils/classNames';
|
9 |
+
import { toast } from 'react-toastify';
|
10 |
+
import { providerBaseUrlEnvKeys } from '~/utils/constants';
|
11 |
+
import { SiAmazon, SiGoogle, SiHuggingface, SiPerplexity, SiOpenai } from 'react-icons/si';
|
12 |
+
import { BsRobot, BsCloud } from 'react-icons/bs';
|
13 |
+
import { TbBrain, TbCloudComputing } from 'react-icons/tb';
|
14 |
+
import { BiCodeBlock, BiChip } from 'react-icons/bi';
|
15 |
+
import { FaCloud, FaBrain } from 'react-icons/fa';
|
16 |
+
import type { IconType } from 'react-icons';
|
17 |
+
|
18 |
+
// Add type for provider names to ensure type safety
|
19 |
+
type ProviderName =
|
20 |
+
| 'AmazonBedrock'
|
21 |
+
| 'Anthropic'
|
22 |
+
| 'Cohere'
|
23 |
+
| 'Deepseek'
|
24 |
+
| 'Google'
|
25 |
+
| 'Groq'
|
26 |
+
| 'HuggingFace'
|
27 |
+
| 'Hyperbolic'
|
28 |
+
| 'Mistral'
|
29 |
+
| 'OpenAI'
|
30 |
+
| 'OpenRouter'
|
31 |
+
| 'Perplexity'
|
32 |
+
| 'Together'
|
33 |
+
| 'XAI';
|
34 |
+
|
35 |
+
// Update the PROVIDER_ICONS type to use the ProviderName type
|
36 |
+
const PROVIDER_ICONS: Record<ProviderName, IconType> = {
|
37 |
+
AmazonBedrock: SiAmazon,
|
38 |
+
Anthropic: FaBrain,
|
39 |
+
Cohere: BiChip,
|
40 |
+
Deepseek: BiCodeBlock,
|
41 |
+
Google: SiGoogle,
|
42 |
+
Groq: BsCloud,
|
43 |
+
HuggingFace: SiHuggingface,
|
44 |
+
Hyperbolic: TbCloudComputing,
|
45 |
+
Mistral: TbBrain,
|
46 |
+
OpenAI: SiOpenai,
|
47 |
+
OpenRouter: FaCloud,
|
48 |
+
Perplexity: SiPerplexity,
|
49 |
+
Together: BsCloud,
|
50 |
+
XAI: BsRobot,
|
51 |
+
};
|
52 |
+
|
53 |
+
// Update PROVIDER_DESCRIPTIONS to use the same type
|
54 |
+
const PROVIDER_DESCRIPTIONS: Partial<Record<ProviderName, string>> = {
|
55 |
+
Anthropic: 'Access Claude and other Anthropic models',
|
56 |
+
OpenAI: 'Use GPT-4, GPT-3.5, and other OpenAI models',
|
57 |
+
};
|
58 |
+
|
59 |
+
const CloudProvidersTab = () => {
|
60 |
+
const settings = useSettings();
|
61 |
+
const [editingProvider, setEditingProvider] = useState<string | null>(null);
|
62 |
+
const [filteredProviders, setFilteredProviders] = useState<IProviderConfig[]>([]);
|
63 |
+
const [categoryEnabled, setCategoryEnabled] = useState<boolean>(false);
|
64 |
+
|
65 |
+
// Load and filter providers
|
66 |
+
useEffect(() => {
|
67 |
+
const newFilteredProviders = Object.entries(settings.providers || {})
|
68 |
+
.filter(([key]) => !['Ollama', 'LMStudio', 'OpenAILike'].includes(key))
|
69 |
+
.map(([key, value]) => ({
|
70 |
+
name: key,
|
71 |
+
settings: value.settings,
|
72 |
+
staticModels: value.staticModels || [],
|
73 |
+
getDynamicModels: value.getDynamicModels,
|
74 |
+
getApiKeyLink: value.getApiKeyLink,
|
75 |
+
labelForGetApiKey: value.labelForGetApiKey,
|
76 |
+
icon: value.icon,
|
77 |
+
}));
|
78 |
+
|
79 |
+
const sorted = newFilteredProviders.sort((a, b) => a.name.localeCompare(b.name));
|
80 |
+
setFilteredProviders(sorted);
|
81 |
+
|
82 |
+
// Update category enabled state
|
83 |
+
const allEnabled = newFilteredProviders.every((p) => p.settings.enabled);
|
84 |
+
setCategoryEnabled(allEnabled);
|
85 |
+
}, [settings.providers]);
|
86 |
+
|
87 |
+
const handleToggleCategory = useCallback(
|
88 |
+
(enabled: boolean) => {
|
89 |
+
// Update all providers
|
90 |
+
filteredProviders.forEach((provider) => {
|
91 |
+
settings.updateProviderSettings(provider.name, { ...provider.settings, enabled });
|
92 |
+
});
|
93 |
+
|
94 |
+
setCategoryEnabled(enabled);
|
95 |
+
toast.success(enabled ? 'All cloud providers enabled' : 'All cloud providers disabled');
|
96 |
+
},
|
97 |
+
[filteredProviders, settings],
|
98 |
+
);
|
99 |
+
|
100 |
+
const handleToggleProvider = useCallback(
|
101 |
+
(provider: IProviderConfig, enabled: boolean) => {
|
102 |
+
// Update the provider settings in the store
|
103 |
+
settings.updateProviderSettings(provider.name, { ...provider.settings, enabled });
|
104 |
+
|
105 |
+
if (enabled) {
|
106 |
+
logStore.logProvider(`Provider ${provider.name} enabled`, { provider: provider.name });
|
107 |
+
toast.success(`${provider.name} enabled`);
|
108 |
+
} else {
|
109 |
+
logStore.logProvider(`Provider ${provider.name} disabled`, { provider: provider.name });
|
110 |
+
toast.success(`${provider.name} disabled`);
|
111 |
+
}
|
112 |
+
},
|
113 |
+
[settings],
|
114 |
+
);
|
115 |
+
|
116 |
+
const handleUpdateBaseUrl = useCallback(
|
117 |
+
(provider: IProviderConfig, baseUrl: string) => {
|
118 |
+
const newBaseUrl: string | undefined = baseUrl.trim() || undefined;
|
119 |
+
|
120 |
+
// Update the provider settings in the store
|
121 |
+
settings.updateProviderSettings(provider.name, { ...provider.settings, baseUrl: newBaseUrl });
|
122 |
+
|
123 |
+
logStore.logProvider(`Base URL updated for ${provider.name}`, {
|
124 |
+
provider: provider.name,
|
125 |
+
baseUrl: newBaseUrl,
|
126 |
+
});
|
127 |
+
toast.success(`${provider.name} base URL updated`);
|
128 |
+
setEditingProvider(null);
|
129 |
+
},
|
130 |
+
[settings],
|
131 |
+
);
|
132 |
+
|
133 |
+
return (
|
134 |
+
<div className="space-y-6">
|
135 |
+
<motion.div
|
136 |
+
className="space-y-4"
|
137 |
+
initial={{ opacity: 0, y: 20 }}
|
138 |
+
animate={{ opacity: 1, y: 0 }}
|
139 |
+
transition={{ duration: 0.3 }}
|
140 |
+
>
|
141 |
+
<div className="flex items-center justify-between gap-4 mt-8 mb-4">
|
142 |
+
<div className="flex items-center gap-2">
|
143 |
+
<div
|
144 |
+
className={classNames(
|
145 |
+
'w-8 h-8 flex items-center justify-center rounded-lg',
|
146 |
+
'bg-bolt-elements-background-depth-3',
|
147 |
+
'text-purple-500',
|
148 |
+
)}
|
149 |
+
>
|
150 |
+
<TbCloudComputing className="w-5 h-5" />
|
151 |
+
</div>
|
152 |
+
<div>
|
153 |
+
<h4 className="text-md font-medium text-bolt-elements-textPrimary">Cloud Providers</h4>
|
154 |
+
<p className="text-sm text-bolt-elements-textSecondary">Connect to cloud-based AI models and services</p>
|
155 |
+
</div>
|
156 |
+
</div>
|
157 |
+
|
158 |
+
<div className="flex items-center gap-2">
|
159 |
+
<span className="text-sm text-bolt-elements-textSecondary">Enable All Cloud</span>
|
160 |
+
<Switch checked={categoryEnabled} onCheckedChange={handleToggleCategory} />
|
161 |
+
</div>
|
162 |
+
</div>
|
163 |
+
|
164 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
165 |
+
{filteredProviders.map((provider, index) => (
|
166 |
+
<motion.div
|
167 |
+
key={provider.name}
|
168 |
+
className={classNames(
|
169 |
+
'rounded-lg border bg-bolt-elements-background text-bolt-elements-textPrimary shadow-sm',
|
170 |
+
'bg-bolt-elements-background-depth-2',
|
171 |
+
'hover:bg-bolt-elements-background-depth-3',
|
172 |
+
'transition-all duration-200',
|
173 |
+
'relative overflow-hidden group',
|
174 |
+
'flex flex-col',
|
175 |
+
)}
|
176 |
+
initial={{ opacity: 0, y: 20 }}
|
177 |
+
animate={{ opacity: 1, y: 0 }}
|
178 |
+
transition={{ delay: index * 0.1 }}
|
179 |
+
whileHover={{ scale: 1.02 }}
|
180 |
+
>
|
181 |
+
<div className="absolute top-0 right-0 p-2 flex gap-1">
|
182 |
+
{URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
|
183 |
+
<motion.span
|
184 |
+
className="px-2 py-0.5 text-xs rounded-full bg-purple-500/10 text-purple-500 font-medium"
|
185 |
+
whileHover={{ scale: 1.05 }}
|
186 |
+
whileTap={{ scale: 0.95 }}
|
187 |
+
>
|
188 |
+
Configurable
|
189 |
+
</motion.span>
|
190 |
+
)}
|
191 |
+
</div>
|
192 |
+
|
193 |
+
<div className="flex items-start gap-4 p-4">
|
194 |
+
<motion.div
|
195 |
+
className={classNames(
|
196 |
+
'w-10 h-10 flex items-center justify-center rounded-xl',
|
197 |
+
'bg-bolt-elements-background-depth-3 group-hover:bg-bolt-elements-background-depth-4',
|
198 |
+
'transition-all duration-200',
|
199 |
+
provider.settings.enabled ? 'text-purple-500' : 'text-bolt-elements-textSecondary',
|
200 |
+
)}
|
201 |
+
whileHover={{ scale: 1.1 }}
|
202 |
+
whileTap={{ scale: 0.9 }}
|
203 |
+
>
|
204 |
+
<div className={classNames('w-6 h-6', 'transition-transform duration-200', 'group-hover:rotate-12')}>
|
205 |
+
{React.createElement(PROVIDER_ICONS[provider.name as ProviderName] || BsRobot, {
|
206 |
+
className: 'w-full h-full',
|
207 |
+
'aria-label': `${provider.name} logo`,
|
208 |
+
})}
|
209 |
+
</div>
|
210 |
+
</motion.div>
|
211 |
+
|
212 |
+
<div className="flex-1 min-w-0">
|
213 |
+
<div className="flex items-center justify-between gap-4 mb-2">
|
214 |
+
<div>
|
215 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-purple-500 transition-colors">
|
216 |
+
{provider.name}
|
217 |
+
</h4>
|
218 |
+
<p className="text-xs text-bolt-elements-textSecondary mt-0.5">
|
219 |
+
{PROVIDER_DESCRIPTIONS[provider.name as keyof typeof PROVIDER_DESCRIPTIONS] ||
|
220 |
+
(URL_CONFIGURABLE_PROVIDERS.includes(provider.name)
|
221 |
+
? 'Configure custom endpoint for this provider'
|
222 |
+
: 'Standard AI provider integration')}
|
223 |
+
</p>
|
224 |
+
</div>
|
225 |
+
<Switch
|
226 |
+
checked={provider.settings.enabled}
|
227 |
+
onCheckedChange={(checked) => handleToggleProvider(provider, checked)}
|
228 |
+
/>
|
229 |
+
</div>
|
230 |
+
|
231 |
+
{provider.settings.enabled && URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
|
232 |
+
<motion.div
|
233 |
+
initial={{ opacity: 0, height: 0 }}
|
234 |
+
animate={{ opacity: 1, height: 'auto' }}
|
235 |
+
exit={{ opacity: 0, height: 0 }}
|
236 |
+
transition={{ duration: 0.2 }}
|
237 |
+
>
|
238 |
+
<div className="flex items-center gap-2 mt-4">
|
239 |
+
{editingProvider === provider.name ? (
|
240 |
+
<input
|
241 |
+
type="text"
|
242 |
+
defaultValue={provider.settings.baseUrl}
|
243 |
+
placeholder={`Enter ${provider.name} base URL`}
|
244 |
+
className={classNames(
|
245 |
+
'flex-1 px-3 py-1.5 rounded-lg text-sm',
|
246 |
+
'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
|
247 |
+
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
248 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
|
249 |
+
'transition-all duration-200',
|
250 |
+
)}
|
251 |
+
onKeyDown={(e) => {
|
252 |
+
if (e.key === 'Enter') {
|
253 |
+
handleUpdateBaseUrl(provider, e.currentTarget.value);
|
254 |
+
} else if (e.key === 'Escape') {
|
255 |
+
setEditingProvider(null);
|
256 |
+
}
|
257 |
+
}}
|
258 |
+
onBlur={(e) => handleUpdateBaseUrl(provider, e.target.value)}
|
259 |
+
autoFocus
|
260 |
+
/>
|
261 |
+
) : (
|
262 |
+
<div
|
263 |
+
className="flex-1 px-3 py-1.5 rounded-lg text-sm cursor-pointer group/url"
|
264 |
+
onClick={() => setEditingProvider(provider.name)}
|
265 |
+
>
|
266 |
+
<div className="flex items-center gap-2 text-bolt-elements-textSecondary">
|
267 |
+
<div className="i-ph:link text-sm" />
|
268 |
+
<span className="group-hover/url:text-purple-500 transition-colors">
|
269 |
+
{provider.settings.baseUrl || 'Click to set base URL'}
|
270 |
+
</span>
|
271 |
+
</div>
|
272 |
+
</div>
|
273 |
+
)}
|
274 |
+
</div>
|
275 |
+
|
276 |
+
{providerBaseUrlEnvKeys[provider.name]?.baseUrlKey && (
|
277 |
+
<div className="mt-2 text-xs text-green-500">
|
278 |
+
<div className="flex items-center gap-1">
|
279 |
+
<div className="i-ph:info" />
|
280 |
+
<span>Environment URL set in .env file</span>
|
281 |
+
</div>
|
282 |
+
</div>
|
283 |
+
)}
|
284 |
+
</motion.div>
|
285 |
+
)}
|
286 |
+
</div>
|
287 |
+
</div>
|
288 |
+
|
289 |
+
<motion.div
|
290 |
+
className="absolute inset-0 border-2 border-purple-500/0 rounded-lg pointer-events-none"
|
291 |
+
animate={{
|
292 |
+
borderColor: provider.settings.enabled ? 'rgba(168, 85, 247, 0.2)' : 'rgba(168, 85, 247, 0)',
|
293 |
+
scale: provider.settings.enabled ? 1 : 0.98,
|
294 |
+
}}
|
295 |
+
transition={{ duration: 0.2 }}
|
296 |
+
/>
|
297 |
+
</motion.div>
|
298 |
+
))}
|
299 |
+
</div>
|
300 |
+
</motion.div>
|
301 |
+
</div>
|
302 |
+
);
|
303 |
+
};
|
304 |
+
|
305 |
+
export default CloudProvidersTab;
|
CodeBlock.module.scss
ADDED
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
.CopyButtonContainer {
|
2 |
+
button:before {
|
3 |
+
content: 'Copied';
|
4 |
+
font-size: 12px;
|
5 |
+
position: absolute;
|
6 |
+
left: -53px;
|
7 |
+
padding: 2px 6px;
|
8 |
+
height: 30px;
|
9 |
+
}
|
10 |
+
}
|
CodeBlock.tsx
ADDED
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { memo, useEffect, useState } from 'react';
|
2 |
+
import { bundledLanguages, codeToHtml, isSpecialLang, type BundledLanguage, type SpecialLanguage } from 'shiki';
|
3 |
+
import { classNames } from '~/utils/classNames';
|
4 |
+
import { createScopedLogger } from '~/utils/logger';
|
5 |
+
|
6 |
+
import styles from './CodeBlock.module.scss';
|
7 |
+
|
8 |
+
const logger = createScopedLogger('CodeBlock');
|
9 |
+
|
10 |
+
interface CodeBlockProps {
|
11 |
+
className?: string;
|
12 |
+
code: string;
|
13 |
+
language?: BundledLanguage | SpecialLanguage;
|
14 |
+
theme?: 'light-plus' | 'dark-plus';
|
15 |
+
disableCopy?: boolean;
|
16 |
+
}
|
17 |
+
|
18 |
+
export const CodeBlock = memo(
|
19 |
+
({ className, code, language = 'plaintext', theme = 'dark-plus', disableCopy = false }: CodeBlockProps) => {
|
20 |
+
const [html, setHTML] = useState<string | undefined>(undefined);
|
21 |
+
const [copied, setCopied] = useState(false);
|
22 |
+
|
23 |
+
const copyToClipboard = () => {
|
24 |
+
if (copied) {
|
25 |
+
return;
|
26 |
+
}
|
27 |
+
|
28 |
+
navigator.clipboard.writeText(code);
|
29 |
+
|
30 |
+
setCopied(true);
|
31 |
+
|
32 |
+
setTimeout(() => {
|
33 |
+
setCopied(false);
|
34 |
+
}, 2000);
|
35 |
+
};
|
36 |
+
|
37 |
+
useEffect(() => {
|
38 |
+
if (language && !isSpecialLang(language) && !(language in bundledLanguages)) {
|
39 |
+
logger.warn(`Unsupported language '${language}'`);
|
40 |
+
}
|
41 |
+
|
42 |
+
logger.trace(`Language = ${language}`);
|
43 |
+
|
44 |
+
const processCode = async () => {
|
45 |
+
setHTML(await codeToHtml(code, { lang: language, theme }));
|
46 |
+
};
|
47 |
+
|
48 |
+
processCode();
|
49 |
+
}, [code]);
|
50 |
+
|
51 |
+
return (
|
52 |
+
<div className={classNames('relative group text-left', className)}>
|
53 |
+
<div
|
54 |
+
className={classNames(
|
55 |
+
styles.CopyButtonContainer,
|
56 |
+
'bg-transparant absolute top-[10px] right-[10px] rounded-md z-10 text-lg flex items-center justify-center opacity-0 group-hover:opacity-100',
|
57 |
+
{
|
58 |
+
'rounded-l-0 opacity-100': copied,
|
59 |
+
},
|
60 |
+
)}
|
61 |
+
>
|
62 |
+
{!disableCopy && (
|
63 |
+
<button
|
64 |
+
className={classNames(
|
65 |
+
'flex items-center bg-accent-500 p-[6px] justify-center before:bg-white before:rounded-l-md before:text-gray-500 before:border-r before:border-gray-300 rounded-md transition-theme',
|
66 |
+
{
|
67 |
+
'before:opacity-0': !copied,
|
68 |
+
'before:opacity-100': copied,
|
69 |
+
},
|
70 |
+
)}
|
71 |
+
title="Copy Code"
|
72 |
+
onClick={() => copyToClipboard()}
|
73 |
+
>
|
74 |
+
<div className="i-ph:clipboard-text-duotone"></div>
|
75 |
+
</button>
|
76 |
+
)}
|
77 |
+
</div>
|
78 |
+
<div dangerouslySetInnerHTML={{ __html: html ?? '' }}></div>
|
79 |
+
</div>
|
80 |
+
);
|
81 |
+
},
|
82 |
+
);
|
ConnectionForm.tsx
ADDED
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useEffect } from 'react';
|
2 |
+
import { classNames } from '~/utils/classNames';
|
3 |
+
import type { GitHubAuthState } from '~/components/@settings/tabs/connections/types/GitHub';
|
4 |
+
import Cookies from 'js-cookie';
|
5 |
+
import { getLocalStorage } from '~/lib/persistence';
|
6 |
+
|
7 |
+
const GITHUB_TOKEN_KEY = 'github_token';
|
8 |
+
|
9 |
+
interface ConnectionFormProps {
|
10 |
+
authState: GitHubAuthState;
|
11 |
+
setAuthState: React.Dispatch<React.SetStateAction<GitHubAuthState>>;
|
12 |
+
onSave: (e: React.FormEvent) => void;
|
13 |
+
onDisconnect: () => void;
|
14 |
+
}
|
15 |
+
|
16 |
+
export function ConnectionForm({ authState, setAuthState, onSave, onDisconnect }: ConnectionFormProps) {
|
17 |
+
// Check for saved token on mount
|
18 |
+
useEffect(() => {
|
19 |
+
const savedToken = Cookies.get(GITHUB_TOKEN_KEY) || getLocalStorage(GITHUB_TOKEN_KEY);
|
20 |
+
|
21 |
+
if (savedToken && !authState.tokenInfo?.token) {
|
22 |
+
setAuthState((prev: GitHubAuthState) => ({
|
23 |
+
...prev,
|
24 |
+
tokenInfo: {
|
25 |
+
token: savedToken,
|
26 |
+
scope: [],
|
27 |
+
avatar_url: '',
|
28 |
+
name: null,
|
29 |
+
created_at: new Date().toISOString(),
|
30 |
+
followers: 0,
|
31 |
+
},
|
32 |
+
}));
|
33 |
+
}
|
34 |
+
}, []);
|
35 |
+
|
36 |
+
return (
|
37 |
+
<div className="rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] overflow-hidden">
|
38 |
+
<div className="p-6">
|
39 |
+
<div className="flex items-center justify-between mb-6">
|
40 |
+
<div className="flex items-center gap-3">
|
41 |
+
<div className="p-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
|
42 |
+
<div className="i-ph:plug-fill text-bolt-elements-textTertiary" />
|
43 |
+
</div>
|
44 |
+
<div>
|
45 |
+
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">Connection Settings</h3>
|
46 |
+
<p className="text-sm text-bolt-elements-textSecondary">Configure your GitHub connection</p>
|
47 |
+
</div>
|
48 |
+
</div>
|
49 |
+
</div>
|
50 |
+
|
51 |
+
<form onSubmit={onSave} className="space-y-4">
|
52 |
+
<div>
|
53 |
+
<label htmlFor="username" className="block text-sm font-medium text-bolt-elements-textSecondary mb-2">
|
54 |
+
GitHub Username
|
55 |
+
</label>
|
56 |
+
<input
|
57 |
+
id="username"
|
58 |
+
type="text"
|
59 |
+
value={authState.username}
|
60 |
+
onChange={(e) => setAuthState((prev: GitHubAuthState) => ({ ...prev, username: e.target.value }))}
|
61 |
+
className={classNames(
|
62 |
+
'w-full px-4 py-2.5 bg-[#F5F5F5] dark:bg-[#1A1A1A] border rounded-lg',
|
63 |
+
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary text-base',
|
64 |
+
'border-[#E5E5E5] dark:border-[#1A1A1A]',
|
65 |
+
'focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500',
|
66 |
+
'transition-all duration-200',
|
67 |
+
)}
|
68 |
+
placeholder="e.g., octocat"
|
69 |
+
/>
|
70 |
+
</div>
|
71 |
+
|
72 |
+
<div>
|
73 |
+
<div className="flex items-center justify-between mb-2">
|
74 |
+
<label htmlFor="token" className="block text-sm font-medium text-bolt-elements-textSecondary">
|
75 |
+
Personal Access Token
|
76 |
+
</label>
|
77 |
+
<a
|
78 |
+
href="https://github.com/settings/tokens/new?scopes=repo,user,read:org,workflow,delete_repo,write:packages,read:packages"
|
79 |
+
target="_blank"
|
80 |
+
rel="noopener noreferrer"
|
81 |
+
className={classNames(
|
82 |
+
'inline-flex items-center gap-1.5 text-xs',
|
83 |
+
'text-purple-500 hover:text-purple-600 dark:text-purple-400 dark:hover:text-purple-300',
|
84 |
+
'transition-colors duration-200',
|
85 |
+
)}
|
86 |
+
>
|
87 |
+
<span>Generate new token</span>
|
88 |
+
<div className="i-ph:plus-circle" />
|
89 |
+
</a>
|
90 |
+
</div>
|
91 |
+
<input
|
92 |
+
id="token"
|
93 |
+
type="password"
|
94 |
+
value={authState.tokenInfo?.token || ''}
|
95 |
+
onChange={(e) =>
|
96 |
+
setAuthState((prev: GitHubAuthState) => ({
|
97 |
+
...prev,
|
98 |
+
tokenInfo: {
|
99 |
+
token: e.target.value,
|
100 |
+
scope: [],
|
101 |
+
avatar_url: '',
|
102 |
+
name: null,
|
103 |
+
created_at: new Date().toISOString(),
|
104 |
+
followers: 0,
|
105 |
+
},
|
106 |
+
username: '',
|
107 |
+
isConnected: false,
|
108 |
+
isVerifying: false,
|
109 |
+
isLoadingRepos: false,
|
110 |
+
}))
|
111 |
+
}
|
112 |
+
className={classNames(
|
113 |
+
'w-full px-4 py-2.5 bg-[#F5F5F5] dark:bg-[#1A1A1A] border rounded-lg',
|
114 |
+
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary text-base',
|
115 |
+
'border-[#E5E5E5] dark:border-[#1A1A1A]',
|
116 |
+
'focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500',
|
117 |
+
'transition-all duration-200',
|
118 |
+
)}
|
119 |
+
placeholder="ghp_xxxxxxxxxxxx"
|
120 |
+
/>
|
121 |
+
</div>
|
122 |
+
|
123 |
+
<div className="flex items-center justify-between pt-4 border-t border-[#E5E5E5] dark:border-[#1A1A1A]">
|
124 |
+
<div className="flex items-center gap-4">
|
125 |
+
{!authState.isConnected ? (
|
126 |
+
<button
|
127 |
+
type="submit"
|
128 |
+
disabled={authState.isVerifying || !authState.username || !authState.tokenInfo?.token}
|
129 |
+
className={classNames(
|
130 |
+
'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
|
131 |
+
'bg-purple-500 hover:bg-purple-600',
|
132 |
+
'text-white',
|
133 |
+
'disabled:opacity-50 disabled:cursor-not-allowed',
|
134 |
+
)}
|
135 |
+
>
|
136 |
+
{authState.isVerifying ? (
|
137 |
+
<>
|
138 |
+
<div className="i-ph:spinner animate-spin" />
|
139 |
+
<span>Verifying...</span>
|
140 |
+
</>
|
141 |
+
) : (
|
142 |
+
<>
|
143 |
+
<div className="i-ph:plug-fill" />
|
144 |
+
<span>Connect</span>
|
145 |
+
</>
|
146 |
+
)}
|
147 |
+
</button>
|
148 |
+
) : (
|
149 |
+
<>
|
150 |
+
<button
|
151 |
+
onClick={onDisconnect}
|
152 |
+
className={classNames(
|
153 |
+
'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
|
154 |
+
'bg-[#F5F5F5] hover:bg-red-500/10 hover:text-red-500',
|
155 |
+
'dark:bg-[#1A1A1A] dark:hover:bg-red-500/20 dark:hover:text-red-500',
|
156 |
+
'text-bolt-elements-textPrimary',
|
157 |
+
)}
|
158 |
+
>
|
159 |
+
<div className="i-ph:plug-fill" />
|
160 |
+
<span>Disconnect</span>
|
161 |
+
</button>
|
162 |
+
<span className="inline-flex items-center gap-2 px-3 py-1.5 text-sm text-green-600 dark:text-green-400 bg-green-500/5 rounded-lg border border-green-500/20">
|
163 |
+
<div className="i-ph:check-circle-fill" />
|
164 |
+
<span>Connected</span>
|
165 |
+
</span>
|
166 |
+
</>
|
167 |
+
)}
|
168 |
+
</div>
|
169 |
+
{authState.rateLimits && (
|
170 |
+
<div className="flex items-center gap-2 text-sm text-bolt-elements-textTertiary">
|
171 |
+
<div className="i-ph:clock-countdown opacity-60" />
|
172 |
+
<span>Rate limit resets at {authState.rateLimits.reset.toLocaleTimeString()}</span>
|
173 |
+
</div>
|
174 |
+
)}
|
175 |
+
</div>
|
176 |
+
</form>
|
177 |
+
</div>
|
178 |
+
</div>
|
179 |
+
);
|
180 |
+
}
|
ConnectionsTab.tsx
ADDED
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { motion } from 'framer-motion';
|
2 |
+
import { GithubConnection } from './GithubConnection';
|
3 |
+
import { NetlifyConnection } from './NetlifyConnection';
|
4 |
+
|
5 |
+
export default function ConnectionsTab() {
|
6 |
+
return (
|
7 |
+
<div className="space-y-4">
|
8 |
+
{/* Header */}
|
9 |
+
<motion.div
|
10 |
+
className="flex items-center gap-2 mb-2"
|
11 |
+
initial={{ opacity: 0, y: 20 }}
|
12 |
+
animate={{ opacity: 1, y: 0 }}
|
13 |
+
transition={{ delay: 0.1 }}
|
14 |
+
>
|
15 |
+
<div className="i-ph:plugs-connected w-5 h-5 text-purple-500" />
|
16 |
+
<h2 className="text-lg font-medium text-bolt-elements-textPrimary">Connection Settings</h2>
|
17 |
+
</motion.div>
|
18 |
+
<p className="text-sm text-bolt-elements-textSecondary mb-6">
|
19 |
+
Manage your external service connections and integrations
|
20 |
+
</p>
|
21 |
+
|
22 |
+
<div className="grid grid-cols-1 gap-4">
|
23 |
+
<GithubConnection />
|
24 |
+
<NetlifyConnection />
|
25 |
+
</div>
|
26 |
+
</div>
|
27 |
+
);
|
28 |
+
}
|
ControlPanel.tsx
ADDED
@@ -0,0 +1,555 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useState, useEffect, useMemo } from 'react';
|
2 |
+
import { motion, AnimatePresence } from 'framer-motion';
|
3 |
+
import { useStore } from '@nanostores/react';
|
4 |
+
import { Switch } from '@radix-ui/react-switch';
|
5 |
+
import * as RadixDialog from '@radix-ui/react-dialog';
|
6 |
+
import { classNames } from '~/utils/classNames';
|
7 |
+
import { TabManagement } from '~/components/@settings/shared/components/TabManagement';
|
8 |
+
import { TabTile } from '~/components/@settings/shared/components/TabTile';
|
9 |
+
import { useUpdateCheck } from '~/lib/hooks/useUpdateCheck';
|
10 |
+
import { useFeatures } from '~/lib/hooks/useFeatures';
|
11 |
+
import { useNotifications } from '~/lib/hooks/useNotifications';
|
12 |
+
import { useConnectionStatus } from '~/lib/hooks/useConnectionStatus';
|
13 |
+
import { useDebugStatus } from '~/lib/hooks/useDebugStatus';
|
14 |
+
import {
|
15 |
+
tabConfigurationStore,
|
16 |
+
developerModeStore,
|
17 |
+
setDeveloperMode,
|
18 |
+
resetTabConfiguration,
|
19 |
+
} from '~/lib/stores/settings';
|
20 |
+
import { profileStore } from '~/lib/stores/profile';
|
21 |
+
import type { TabType, TabVisibilityConfig, Profile } from './types';
|
22 |
+
import { TAB_LABELS, DEFAULT_TAB_CONFIG } from './constants';
|
23 |
+
import { DialogTitle } from '~/components/ui/Dialog';
|
24 |
+
import { AvatarDropdown } from './AvatarDropdown';
|
25 |
+
import BackgroundRays from '~/components/ui/BackgroundRays';
|
26 |
+
|
27 |
+
// Import all tab components
|
28 |
+
import ProfileTab from '~/components/@settings/tabs/profile/ProfileTab';
|
29 |
+
import SettingsTab from '~/components/@settings/tabs/settings/SettingsTab';
|
30 |
+
import NotificationsTab from '~/components/@settings/tabs/notifications/NotificationsTab';
|
31 |
+
import FeaturesTab from '~/components/@settings/tabs/features/FeaturesTab';
|
32 |
+
import DataTab from '~/components/@settings/tabs/data/DataTab';
|
33 |
+
import DebugTab from '~/components/@settings/tabs/debug/DebugTab';
|
34 |
+
import { EventLogsTab } from '~/components/@settings/tabs/event-logs/EventLogsTab';
|
35 |
+
import UpdateTab from '~/components/@settings/tabs/update/UpdateTab';
|
36 |
+
import ConnectionsTab from '~/components/@settings/tabs/connections/ConnectionsTab';
|
37 |
+
import CloudProvidersTab from '~/components/@settings/tabs/providers/cloud/CloudProvidersTab';
|
38 |
+
import ServiceStatusTab from '~/components/@settings/tabs/providers/status/ServiceStatusTab';
|
39 |
+
import LocalProvidersTab from '~/components/@settings/tabs/providers/local/LocalProvidersTab';
|
40 |
+
import TaskManagerTab from '~/components/@settings/tabs/task-manager/TaskManagerTab';
|
41 |
+
|
42 |
+
interface ControlPanelProps {
|
43 |
+
open: boolean;
|
44 |
+
onClose: () => void;
|
45 |
+
}
|
46 |
+
|
47 |
+
interface TabWithDevType extends TabVisibilityConfig {
|
48 |
+
isExtraDevTab?: boolean;
|
49 |
+
}
|
50 |
+
|
51 |
+
interface ExtendedTabConfig extends TabVisibilityConfig {
|
52 |
+
isExtraDevTab?: boolean;
|
53 |
+
}
|
54 |
+
|
55 |
+
interface BaseTabConfig {
|
56 |
+
id: TabType;
|
57 |
+
visible: boolean;
|
58 |
+
window: 'user' | 'developer';
|
59 |
+
order: number;
|
60 |
+
}
|
61 |
+
|
62 |
+
interface AnimatedSwitchProps {
|
63 |
+
checked: boolean;
|
64 |
+
onCheckedChange: (checked: boolean) => void;
|
65 |
+
id: string;
|
66 |
+
label: string;
|
67 |
+
}
|
68 |
+
|
69 |
+
const TAB_DESCRIPTIONS: Record<TabType, string> = {
|
70 |
+
profile: 'Manage your profile and account settings',
|
71 |
+
settings: 'Configure application preferences',
|
72 |
+
notifications: 'View and manage your notifications',
|
73 |
+
features: 'Explore new and upcoming features',
|
74 |
+
data: 'Manage your data and storage',
|
75 |
+
'cloud-providers': 'Configure cloud AI providers and models',
|
76 |
+
'local-providers': 'Configure local AI providers and models',
|
77 |
+
'service-status': 'Monitor cloud LLM service status',
|
78 |
+
connection: 'Check connection status and settings',
|
79 |
+
debug: 'Debug tools and system information',
|
80 |
+
'event-logs': 'View system events and logs',
|
81 |
+
update: 'Check for updates and release notes',
|
82 |
+
'task-manager': 'Monitor system resources and processes',
|
83 |
+
'tab-management': 'Configure visible tabs and their order',
|
84 |
+
};
|
85 |
+
|
86 |
+
// Beta status for experimental features
|
87 |
+
const BETA_TABS = new Set<TabType>(['task-manager', 'service-status', 'update', 'local-providers']);
|
88 |
+
|
89 |
+
const BetaLabel = () => (
|
90 |
+
<div className="absolute top-2 right-2 px-1.5 py-0.5 rounded-full bg-purple-500/10 dark:bg-purple-500/20">
|
91 |
+
<span className="text-[10px] font-medium text-purple-600 dark:text-purple-400">BETA</span>
|
92 |
+
</div>
|
93 |
+
);
|
94 |
+
|
95 |
+
const AnimatedSwitch = ({ checked, onCheckedChange, id, label }: AnimatedSwitchProps) => {
|
96 |
+
return (
|
97 |
+
<div className="flex items-center gap-2">
|
98 |
+
<Switch
|
99 |
+
id={id}
|
100 |
+
checked={checked}
|
101 |
+
onCheckedChange={onCheckedChange}
|
102 |
+
className={classNames(
|
103 |
+
'relative inline-flex h-6 w-11 items-center rounded-full',
|
104 |
+
'transition-all duration-300 ease-[cubic-bezier(0.87,_0,_0.13,_1)]',
|
105 |
+
'bg-gray-200 dark:bg-gray-700',
|
106 |
+
'data-[state=checked]:bg-purple-500',
|
107 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/20',
|
108 |
+
'cursor-pointer',
|
109 |
+
'group',
|
110 |
+
)}
|
111 |
+
>
|
112 |
+
<motion.span
|
113 |
+
className={classNames(
|
114 |
+
'absolute left-[2px] top-[2px]',
|
115 |
+
'inline-block h-5 w-5 rounded-full',
|
116 |
+
'bg-white shadow-lg',
|
117 |
+
'transition-shadow duration-300',
|
118 |
+
'group-hover:shadow-md group-active:shadow-sm',
|
119 |
+
'group-hover:scale-95 group-active:scale-90',
|
120 |
+
)}
|
121 |
+
initial={false}
|
122 |
+
transition={{
|
123 |
+
type: 'spring',
|
124 |
+
stiffness: 500,
|
125 |
+
damping: 30,
|
126 |
+
duration: 0.2,
|
127 |
+
}}
|
128 |
+
animate={{
|
129 |
+
x: checked ? '1.25rem' : '0rem',
|
130 |
+
}}
|
131 |
+
>
|
132 |
+
<motion.div
|
133 |
+
className="absolute inset-0 rounded-full bg-white"
|
134 |
+
initial={false}
|
135 |
+
animate={{
|
136 |
+
scale: checked ? 1 : 0.8,
|
137 |
+
}}
|
138 |
+
transition={{ duration: 0.2 }}
|
139 |
+
/>
|
140 |
+
</motion.span>
|
141 |
+
<span className="sr-only">Toggle {label}</span>
|
142 |
+
</Switch>
|
143 |
+
<div className="flex items-center gap-2">
|
144 |
+
<label
|
145 |
+
htmlFor={id}
|
146 |
+
className="text-sm text-gray-500 dark:text-gray-400 select-none cursor-pointer whitespace-nowrap w-[88px]"
|
147 |
+
>
|
148 |
+
{label}
|
149 |
+
</label>
|
150 |
+
</div>
|
151 |
+
</div>
|
152 |
+
);
|
153 |
+
};
|
154 |
+
|
155 |
+
export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
156 |
+
// State
|
157 |
+
const [activeTab, setActiveTab] = useState<TabType | null>(null);
|
158 |
+
const [loadingTab, setLoadingTab] = useState<TabType | null>(null);
|
159 |
+
const [showTabManagement, setShowTabManagement] = useState(false);
|
160 |
+
|
161 |
+
// Store values
|
162 |
+
const tabConfiguration = useStore(tabConfigurationStore);
|
163 |
+
const developerMode = useStore(developerModeStore);
|
164 |
+
const profile = useStore(profileStore) as Profile;
|
165 |
+
|
166 |
+
// Status hooks
|
167 |
+
const { hasUpdate, currentVersion, acknowledgeUpdate } = useUpdateCheck();
|
168 |
+
const { hasNewFeatures, unviewedFeatures, acknowledgeAllFeatures } = useFeatures();
|
169 |
+
const { hasUnreadNotifications, unreadNotifications, markAllAsRead } = useNotifications();
|
170 |
+
const { hasConnectionIssues, currentIssue, acknowledgeIssue } = useConnectionStatus();
|
171 |
+
const { hasActiveWarnings, activeIssues, acknowledgeAllIssues } = useDebugStatus();
|
172 |
+
|
173 |
+
// Memoize the base tab configurations to avoid recalculation
|
174 |
+
const baseTabConfig = useMemo(() => {
|
175 |
+
return new Map(DEFAULT_TAB_CONFIG.map((tab) => [tab.id, tab]));
|
176 |
+
}, []);
|
177 |
+
|
178 |
+
// Add visibleTabs logic using useMemo with optimized calculations
|
179 |
+
const visibleTabs = useMemo(() => {
|
180 |
+
if (!tabConfiguration?.userTabs || !Array.isArray(tabConfiguration.userTabs)) {
|
181 |
+
console.warn('Invalid tab configuration, resetting to defaults');
|
182 |
+
resetTabConfiguration();
|
183 |
+
|
184 |
+
return [];
|
185 |
+
}
|
186 |
+
|
187 |
+
const notificationsDisabled = profile?.preferences?.notifications === false;
|
188 |
+
|
189 |
+
// In developer mode, show ALL tabs without restrictions
|
190 |
+
if (developerMode) {
|
191 |
+
const seenTabs = new Set<TabType>();
|
192 |
+
const devTabs: ExtendedTabConfig[] = [];
|
193 |
+
|
194 |
+
// Process tabs in order of priority: developer, user, default
|
195 |
+
const processTab = (tab: BaseTabConfig) => {
|
196 |
+
if (!seenTabs.has(tab.id)) {
|
197 |
+
seenTabs.add(tab.id);
|
198 |
+
devTabs.push({
|
199 |
+
id: tab.id,
|
200 |
+
visible: true,
|
201 |
+
window: 'developer',
|
202 |
+
order: tab.order || devTabs.length,
|
203 |
+
});
|
204 |
+
}
|
205 |
+
};
|
206 |
+
|
207 |
+
// Process tabs in priority order
|
208 |
+
tabConfiguration.developerTabs?.forEach((tab) => processTab(tab as BaseTabConfig));
|
209 |
+
tabConfiguration.userTabs.forEach((tab) => processTab(tab as BaseTabConfig));
|
210 |
+
DEFAULT_TAB_CONFIG.forEach((tab) => processTab(tab as BaseTabConfig));
|
211 |
+
|
212 |
+
// Add Tab Management tile
|
213 |
+
devTabs.push({
|
214 |
+
id: 'tab-management' as TabType,
|
215 |
+
visible: true,
|
216 |
+
window: 'developer',
|
217 |
+
order: devTabs.length,
|
218 |
+
isExtraDevTab: true,
|
219 |
+
});
|
220 |
+
|
221 |
+
return devTabs.sort((a, b) => a.order - b.order);
|
222 |
+
}
|
223 |
+
|
224 |
+
// Optimize user mode tab filtering
|
225 |
+
return tabConfiguration.userTabs
|
226 |
+
.filter((tab) => {
|
227 |
+
if (!tab?.id) {
|
228 |
+
return false;
|
229 |
+
}
|
230 |
+
|
231 |
+
if (tab.id === 'notifications' && notificationsDisabled) {
|
232 |
+
return false;
|
233 |
+
}
|
234 |
+
|
235 |
+
return tab.visible && tab.window === 'user';
|
236 |
+
})
|
237 |
+
.sort((a, b) => a.order - b.order);
|
238 |
+
}, [tabConfiguration, developerMode, profile?.preferences?.notifications, baseTabConfig]);
|
239 |
+
|
240 |
+
// Optimize animation performance with layout animations
|
241 |
+
const gridLayoutVariants = {
|
242 |
+
hidden: { opacity: 0 },
|
243 |
+
visible: {
|
244 |
+
opacity: 1,
|
245 |
+
transition: {
|
246 |
+
staggerChildren: 0.05,
|
247 |
+
delayChildren: 0.1,
|
248 |
+
},
|
249 |
+
},
|
250 |
+
};
|
251 |
+
|
252 |
+
const itemVariants = {
|
253 |
+
hidden: { opacity: 0, scale: 0.8 },
|
254 |
+
visible: {
|
255 |
+
opacity: 1,
|
256 |
+
scale: 1,
|
257 |
+
transition: {
|
258 |
+
type: 'spring',
|
259 |
+
stiffness: 200,
|
260 |
+
damping: 20,
|
261 |
+
mass: 0.6,
|
262 |
+
},
|
263 |
+
},
|
264 |
+
};
|
265 |
+
|
266 |
+
// Reset to default view when modal opens/closes
|
267 |
+
useEffect(() => {
|
268 |
+
if (!open) {
|
269 |
+
// Reset when closing
|
270 |
+
setActiveTab(null);
|
271 |
+
setLoadingTab(null);
|
272 |
+
setShowTabManagement(false);
|
273 |
+
} else {
|
274 |
+
// When opening, set to null to show the main view
|
275 |
+
setActiveTab(null);
|
276 |
+
}
|
277 |
+
}, [open]);
|
278 |
+
|
279 |
+
// Handle closing
|
280 |
+
const handleClose = () => {
|
281 |
+
setActiveTab(null);
|
282 |
+
setLoadingTab(null);
|
283 |
+
setShowTabManagement(false);
|
284 |
+
onClose();
|
285 |
+
};
|
286 |
+
|
287 |
+
// Handlers
|
288 |
+
const handleBack = () => {
|
289 |
+
if (showTabManagement) {
|
290 |
+
setShowTabManagement(false);
|
291 |
+
} else if (activeTab) {
|
292 |
+
setActiveTab(null);
|
293 |
+
}
|
294 |
+
};
|
295 |
+
|
296 |
+
const handleDeveloperModeChange = (checked: boolean) => {
|
297 |
+
console.log('Developer mode changed:', checked);
|
298 |
+
setDeveloperMode(checked);
|
299 |
+
};
|
300 |
+
|
301 |
+
// Add effect to log developer mode changes
|
302 |
+
useEffect(() => {
|
303 |
+
console.log('Current developer mode:', developerMode);
|
304 |
+
}, [developerMode]);
|
305 |
+
|
306 |
+
const getTabComponent = (tabId: TabType | 'tab-management') => {
|
307 |
+
if (tabId === 'tab-management') {
|
308 |
+
return <TabManagement />;
|
309 |
+
}
|
310 |
+
|
311 |
+
switch (tabId) {
|
312 |
+
case 'profile':
|
313 |
+
return <ProfileTab />;
|
314 |
+
case 'settings':
|
315 |
+
return <SettingsTab />;
|
316 |
+
case 'notifications':
|
317 |
+
return <NotificationsTab />;
|
318 |
+
case 'features':
|
319 |
+
return <FeaturesTab />;
|
320 |
+
case 'data':
|
321 |
+
return <DataTab />;
|
322 |
+
case 'cloud-providers':
|
323 |
+
return <CloudProvidersTab />;
|
324 |
+
case 'local-providers':
|
325 |
+
return <LocalProvidersTab />;
|
326 |
+
case 'connection':
|
327 |
+
return <ConnectionsTab />;
|
328 |
+
case 'debug':
|
329 |
+
return <DebugTab />;
|
330 |
+
case 'event-logs':
|
331 |
+
return <EventLogsTab />;
|
332 |
+
case 'update':
|
333 |
+
return <UpdateTab />;
|
334 |
+
case 'task-manager':
|
335 |
+
return <TaskManagerTab />;
|
336 |
+
case 'service-status':
|
337 |
+
return <ServiceStatusTab />;
|
338 |
+
default:
|
339 |
+
return null;
|
340 |
+
}
|
341 |
+
};
|
342 |
+
|
343 |
+
const getTabUpdateStatus = (tabId: TabType): boolean => {
|
344 |
+
switch (tabId) {
|
345 |
+
case 'update':
|
346 |
+
return hasUpdate;
|
347 |
+
case 'features':
|
348 |
+
return hasNewFeatures;
|
349 |
+
case 'notifications':
|
350 |
+
return hasUnreadNotifications;
|
351 |
+
case 'connection':
|
352 |
+
return hasConnectionIssues;
|
353 |
+
case 'debug':
|
354 |
+
return hasActiveWarnings;
|
355 |
+
default:
|
356 |
+
return false;
|
357 |
+
}
|
358 |
+
};
|
359 |
+
|
360 |
+
const getStatusMessage = (tabId: TabType): string => {
|
361 |
+
switch (tabId) {
|
362 |
+
case 'update':
|
363 |
+
return `New update available (v${currentVersion})`;
|
364 |
+
case 'features':
|
365 |
+
return `${unviewedFeatures.length} new feature${unviewedFeatures.length === 1 ? '' : 's'} to explore`;
|
366 |
+
case 'notifications':
|
367 |
+
return `${unreadNotifications.length} unread notification${unreadNotifications.length === 1 ? '' : 's'}`;
|
368 |
+
case 'connection':
|
369 |
+
return currentIssue === 'disconnected'
|
370 |
+
? 'Connection lost'
|
371 |
+
: currentIssue === 'high-latency'
|
372 |
+
? 'High latency detected'
|
373 |
+
: 'Connection issues detected';
|
374 |
+
case 'debug': {
|
375 |
+
const warnings = activeIssues.filter((i) => i.type === 'warning').length;
|
376 |
+
const errors = activeIssues.filter((i) => i.type === 'error').length;
|
377 |
+
|
378 |
+
return `${warnings} warning${warnings === 1 ? '' : 's'}, ${errors} error${errors === 1 ? '' : 's'}`;
|
379 |
+
}
|
380 |
+
default:
|
381 |
+
return '';
|
382 |
+
}
|
383 |
+
};
|
384 |
+
|
385 |
+
const handleTabClick = (tabId: TabType) => {
|
386 |
+
setLoadingTab(tabId);
|
387 |
+
setActiveTab(tabId);
|
388 |
+
setShowTabManagement(false);
|
389 |
+
|
390 |
+
// Acknowledge notifications based on tab
|
391 |
+
switch (tabId) {
|
392 |
+
case 'update':
|
393 |
+
acknowledgeUpdate();
|
394 |
+
break;
|
395 |
+
case 'features':
|
396 |
+
acknowledgeAllFeatures();
|
397 |
+
break;
|
398 |
+
case 'notifications':
|
399 |
+
markAllAsRead();
|
400 |
+
break;
|
401 |
+
case 'connection':
|
402 |
+
acknowledgeIssue();
|
403 |
+
break;
|
404 |
+
case 'debug':
|
405 |
+
acknowledgeAllIssues();
|
406 |
+
break;
|
407 |
+
}
|
408 |
+
|
409 |
+
// Clear loading state after a delay
|
410 |
+
setTimeout(() => setLoadingTab(null), 500);
|
411 |
+
};
|
412 |
+
|
413 |
+
return (
|
414 |
+
<RadixDialog.Root open={open}>
|
415 |
+
<RadixDialog.Portal>
|
416 |
+
<div className="fixed inset-0 flex items-center justify-center z-[100]">
|
417 |
+
<RadixDialog.Overlay asChild>
|
418 |
+
<motion.div
|
419 |
+
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
420 |
+
initial={{ opacity: 0 }}
|
421 |
+
animate={{ opacity: 1 }}
|
422 |
+
exit={{ opacity: 0 }}
|
423 |
+
transition={{ duration: 0.2 }}
|
424 |
+
/>
|
425 |
+
</RadixDialog.Overlay>
|
426 |
+
|
427 |
+
<RadixDialog.Content
|
428 |
+
aria-describedby={undefined}
|
429 |
+
onEscapeKeyDown={handleClose}
|
430 |
+
onPointerDownOutside={handleClose}
|
431 |
+
className="relative z-[101]"
|
432 |
+
>
|
433 |
+
<motion.div
|
434 |
+
className={classNames(
|
435 |
+
'w-[1200px] h-[90vh]',
|
436 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
437 |
+
'rounded-2xl shadow-2xl',
|
438 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
439 |
+
'flex flex-col overflow-hidden',
|
440 |
+
'relative',
|
441 |
+
)}
|
442 |
+
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
443 |
+
animate={{ opacity: 1, scale: 1, y: 0 }}
|
444 |
+
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
445 |
+
transition={{ duration: 0.2 }}
|
446 |
+
>
|
447 |
+
<div className="absolute inset-0 overflow-hidden rounded-2xl">
|
448 |
+
<BackgroundRays />
|
449 |
+
</div>
|
450 |
+
<div className="relative z-10 flex flex-col h-full">
|
451 |
+
{/* Header */}
|
452 |
+
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
453 |
+
<div className="flex items-center space-x-4">
|
454 |
+
{(activeTab || showTabManagement) && (
|
455 |
+
<button
|
456 |
+
onClick={handleBack}
|
457 |
+
className="flex items-center justify-center w-8 h-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
|
458 |
+
>
|
459 |
+
<div className="i-ph:arrow-left w-4 h-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
460 |
+
</button>
|
461 |
+
)}
|
462 |
+
<DialogTitle className="text-xl font-semibold text-gray-900 dark:text-white">
|
463 |
+
{showTabManagement ? 'Tab Management' : activeTab ? TAB_LABELS[activeTab] : 'Control Panel'}
|
464 |
+
</DialogTitle>
|
465 |
+
</div>
|
466 |
+
|
467 |
+
<div className="flex items-center gap-6">
|
468 |
+
{/* Mode Toggle */}
|
469 |
+
<div className="flex items-center gap-2 min-w-[140px] border-r border-gray-200 dark:border-gray-800 pr-6">
|
470 |
+
<AnimatedSwitch
|
471 |
+
id="developer-mode"
|
472 |
+
checked={developerMode}
|
473 |
+
onCheckedChange={handleDeveloperModeChange}
|
474 |
+
label={developerMode ? 'Developer Mode' : 'User Mode'}
|
475 |
+
/>
|
476 |
+
</div>
|
477 |
+
|
478 |
+
{/* Avatar and Dropdown */}
|
479 |
+
<div className="border-l border-gray-200 dark:border-gray-800 pl-6">
|
480 |
+
<AvatarDropdown onSelectTab={handleTabClick} />
|
481 |
+
</div>
|
482 |
+
|
483 |
+
{/* Close Button */}
|
484 |
+
<button
|
485 |
+
onClick={handleClose}
|
486 |
+
className="flex items-center justify-center w-8 h-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
|
487 |
+
>
|
488 |
+
<div className="i-ph:x w-4 h-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
489 |
+
</button>
|
490 |
+
</div>
|
491 |
+
</div>
|
492 |
+
|
493 |
+
{/* Content */}
|
494 |
+
<div
|
495 |
+
className={classNames(
|
496 |
+
'flex-1',
|
497 |
+
'overflow-y-auto',
|
498 |
+
'hover:overflow-y-auto',
|
499 |
+
'scrollbar scrollbar-w-2',
|
500 |
+
'scrollbar-track-transparent',
|
501 |
+
'scrollbar-thumb-[#E5E5E5] hover:scrollbar-thumb-[#CCCCCC]',
|
502 |
+
'dark:scrollbar-thumb-[#333333] dark:hover:scrollbar-thumb-[#444444]',
|
503 |
+
'will-change-scroll',
|
504 |
+
'touch-auto',
|
505 |
+
)}
|
506 |
+
>
|
507 |
+
<motion.div
|
508 |
+
key={activeTab || 'home'}
|
509 |
+
initial={{ opacity: 0 }}
|
510 |
+
animate={{ opacity: 1 }}
|
511 |
+
exit={{ opacity: 0 }}
|
512 |
+
transition={{ duration: 0.2 }}
|
513 |
+
className="p-6"
|
514 |
+
>
|
515 |
+
{showTabManagement ? (
|
516 |
+
<TabManagement />
|
517 |
+
) : activeTab ? (
|
518 |
+
getTabComponent(activeTab)
|
519 |
+
) : (
|
520 |
+
<motion.div
|
521 |
+
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 relative"
|
522 |
+
variants={gridLayoutVariants}
|
523 |
+
initial="hidden"
|
524 |
+
animate="visible"
|
525 |
+
>
|
526 |
+
<AnimatePresence mode="popLayout">
|
527 |
+
{(visibleTabs as TabWithDevType[]).map((tab: TabWithDevType) => (
|
528 |
+
<motion.div key={tab.id} layout variants={itemVariants} className="aspect-[1.5/1]">
|
529 |
+
<TabTile
|
530 |
+
tab={tab}
|
531 |
+
onClick={() => handleTabClick(tab.id as TabType)}
|
532 |
+
isActive={activeTab === tab.id}
|
533 |
+
hasUpdate={getTabUpdateStatus(tab.id)}
|
534 |
+
statusMessage={getStatusMessage(tab.id)}
|
535 |
+
description={TAB_DESCRIPTIONS[tab.id]}
|
536 |
+
isLoading={loadingTab === tab.id}
|
537 |
+
className="h-full relative"
|
538 |
+
>
|
539 |
+
{BETA_TABS.has(tab.id) && <BetaLabel />}
|
540 |
+
</TabTile>
|
541 |
+
</motion.div>
|
542 |
+
))}
|
543 |
+
</AnimatePresence>
|
544 |
+
</motion.div>
|
545 |
+
)}
|
546 |
+
</motion.div>
|
547 |
+
</div>
|
548 |
+
</div>
|
549 |
+
</motion.div>
|
550 |
+
</RadixDialog.Content>
|
551 |
+
</div>
|
552 |
+
</RadixDialog.Portal>
|
553 |
+
</RadixDialog.Root>
|
554 |
+
);
|
555 |
+
};
|
CreateBranchDialog.tsx
ADDED
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useState } from 'react';
|
2 |
+
import * as Dialog from '@radix-ui/react-dialog';
|
3 |
+
import { classNames } from '~/utils/classNames';
|
4 |
+
import type { GitHubRepoInfo } from '~/components/@settings/tabs/connections/types/GitHub';
|
5 |
+
import { GitBranch } from '@phosphor-icons/react';
|
6 |
+
|
7 |
+
interface GitHubBranch {
|
8 |
+
name: string;
|
9 |
+
default?: boolean;
|
10 |
+
}
|
11 |
+
|
12 |
+
interface CreateBranchDialogProps {
|
13 |
+
isOpen: boolean;
|
14 |
+
onClose: () => void;
|
15 |
+
onConfirm: (branchName: string, sourceBranch: string) => void;
|
16 |
+
repository: GitHubRepoInfo;
|
17 |
+
branches?: GitHubBranch[];
|
18 |
+
}
|
19 |
+
|
20 |
+
export function CreateBranchDialog({ isOpen, onClose, onConfirm, repository, branches }: CreateBranchDialogProps) {
|
21 |
+
const [branchName, setBranchName] = useState('');
|
22 |
+
const [sourceBranch, setSourceBranch] = useState(branches?.find((b) => b.default)?.name || 'main');
|
23 |
+
|
24 |
+
const handleSubmit = (e: React.FormEvent) => {
|
25 |
+
e.preventDefault();
|
26 |
+
onConfirm(branchName, sourceBranch);
|
27 |
+
setBranchName('');
|
28 |
+
onClose();
|
29 |
+
};
|
30 |
+
|
31 |
+
return (
|
32 |
+
<Dialog.Root open={isOpen} onOpenChange={onClose}>
|
33 |
+
<Dialog.Portal>
|
34 |
+
<Dialog.Overlay className="fixed inset-0 bg-black/50 dark:bg-black/80" />
|
35 |
+
<Dialog.Content
|
36 |
+
className={classNames(
|
37 |
+
'fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%]',
|
38 |
+
'w-full max-w-md p-6 rounded-xl shadow-lg',
|
39 |
+
'bg-white dark:bg-[#0A0A0A]',
|
40 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
41 |
+
)}
|
42 |
+
>
|
43 |
+
<Dialog.Title className="text-lg font-medium text-bolt-elements-textPrimary mb-4">
|
44 |
+
Create New Branch
|
45 |
+
</Dialog.Title>
|
46 |
+
|
47 |
+
<form onSubmit={handleSubmit}>
|
48 |
+
<div className="space-y-4">
|
49 |
+
<div>
|
50 |
+
<label htmlFor="branchName" className="block text-sm font-medium text-bolt-elements-textSecondary mb-2">
|
51 |
+
Branch Name
|
52 |
+
</label>
|
53 |
+
<input
|
54 |
+
id="branchName"
|
55 |
+
type="text"
|
56 |
+
value={branchName}
|
57 |
+
onChange={(e) => setBranchName(e.target.value)}
|
58 |
+
placeholder="feature/my-new-branch"
|
59 |
+
className={classNames(
|
60 |
+
'w-full px-3 py-2 rounded-lg',
|
61 |
+
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
|
62 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
63 |
+
'text-bolt-elements-textPrimary placeholder:text-bolt-elements-textTertiary',
|
64 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/50',
|
65 |
+
)}
|
66 |
+
required
|
67 |
+
/>
|
68 |
+
</div>
|
69 |
+
|
70 |
+
<div>
|
71 |
+
<label
|
72 |
+
htmlFor="sourceBranch"
|
73 |
+
className="block text-sm font-medium text-bolt-elements-textSecondary mb-2"
|
74 |
+
>
|
75 |
+
Source Branch
|
76 |
+
</label>
|
77 |
+
<select
|
78 |
+
id="sourceBranch"
|
79 |
+
value={sourceBranch}
|
80 |
+
onChange={(e) => setSourceBranch(e.target.value)}
|
81 |
+
className={classNames(
|
82 |
+
'w-full px-3 py-2 rounded-lg',
|
83 |
+
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
|
84 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
85 |
+
'text-bolt-elements-textPrimary',
|
86 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/50',
|
87 |
+
)}
|
88 |
+
>
|
89 |
+
{branches?.map((branch) => (
|
90 |
+
<option key={branch.name} value={branch.name}>
|
91 |
+
{branch.name} {branch.default ? '(default)' : ''}
|
92 |
+
</option>
|
93 |
+
))}
|
94 |
+
</select>
|
95 |
+
</div>
|
96 |
+
|
97 |
+
<div className="mt-4 p-3 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-lg">
|
98 |
+
<h4 className="text-sm font-medium text-bolt-elements-textSecondary mb-2">Branch Overview</h4>
|
99 |
+
<ul className="space-y-2 text-sm text-bolt-elements-textSecondary">
|
100 |
+
<li className="flex items-center gap-2">
|
101 |
+
<GitBranch className="text-lg" />
|
102 |
+
Repository: {repository.name}
|
103 |
+
</li>
|
104 |
+
{branchName && (
|
105 |
+
<li className="flex items-center gap-2">
|
106 |
+
<div className="i-ph:check-circle text-green-500" />
|
107 |
+
New branch will be created as: {branchName}
|
108 |
+
</li>
|
109 |
+
)}
|
110 |
+
<li className="flex items-center gap-2">
|
111 |
+
<div className="i-ph:check-circle text-green-500" />
|
112 |
+
Based on: {sourceBranch}
|
113 |
+
</li>
|
114 |
+
</ul>
|
115 |
+
</div>
|
116 |
+
</div>
|
117 |
+
|
118 |
+
<div className="mt-6 flex justify-end gap-3">
|
119 |
+
<button
|
120 |
+
type="button"
|
121 |
+
onClick={onClose}
|
122 |
+
className={classNames(
|
123 |
+
'px-4 py-2 rounded-lg text-sm font-medium',
|
124 |
+
'text-bolt-elements-textPrimary',
|
125 |
+
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
|
126 |
+
'hover:bg-purple-500/10 hover:text-purple-500',
|
127 |
+
'dark:hover:bg-purple-500/20 dark:hover:text-purple-500',
|
128 |
+
'transition-colors',
|
129 |
+
)}
|
130 |
+
>
|
131 |
+
Cancel
|
132 |
+
</button>
|
133 |
+
<button
|
134 |
+
type="submit"
|
135 |
+
className={classNames(
|
136 |
+
'px-4 py-2 rounded-lg text-sm font-medium',
|
137 |
+
'text-white bg-purple-500',
|
138 |
+
'hover:bg-purple-600',
|
139 |
+
'transition-colors',
|
140 |
+
)}
|
141 |
+
>
|
142 |
+
Create Branch
|
143 |
+
</button>
|
144 |
+
</div>
|
145 |
+
</form>
|
146 |
+
</Dialog.Content>
|
147 |
+
</Dialog.Portal>
|
148 |
+
</Dialog.Root>
|
149 |
+
);
|
150 |
+
}
|
DataTab.tsx
ADDED
@@ -0,0 +1,452 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useState, useRef } from 'react';
|
2 |
+
import { motion } from 'framer-motion';
|
3 |
+
import { toast } from 'react-toastify';
|
4 |
+
import { DialogRoot, DialogClose, Dialog, DialogTitle } from '~/components/ui/Dialog';
|
5 |
+
import { db, getAll, deleteById } from '~/lib/persistence';
|
6 |
+
|
7 |
+
export default function DataTab() {
|
8 |
+
const [isDownloadingTemplate, setIsDownloadingTemplate] = useState(false);
|
9 |
+
const [isImportingKeys, setIsImportingKeys] = useState(false);
|
10 |
+
const [isResetting, setIsResetting] = useState(false);
|
11 |
+
const [isDeleting, setIsDeleting] = useState(false);
|
12 |
+
const [showResetInlineConfirm, setShowResetInlineConfirm] = useState(false);
|
13 |
+
const [showDeleteInlineConfirm, setShowDeleteInlineConfirm] = useState(false);
|
14 |
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
15 |
+
const apiKeyFileInputRef = useRef<HTMLInputElement>(null);
|
16 |
+
|
17 |
+
const handleExportAllChats = async () => {
|
18 |
+
try {
|
19 |
+
if (!db) {
|
20 |
+
throw new Error('Database not initialized');
|
21 |
+
}
|
22 |
+
|
23 |
+
// Get all chats from IndexedDB
|
24 |
+
const allChats = await getAll(db);
|
25 |
+
const exportData = {
|
26 |
+
chats: allChats,
|
27 |
+
exportDate: new Date().toISOString(),
|
28 |
+
};
|
29 |
+
|
30 |
+
// Download as JSON
|
31 |
+
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
32 |
+
const url = URL.createObjectURL(blob);
|
33 |
+
const a = document.createElement('a');
|
34 |
+
a.href = url;
|
35 |
+
a.download = `bolt-chats-${new Date().toISOString()}.json`;
|
36 |
+
document.body.appendChild(a);
|
37 |
+
a.click();
|
38 |
+
document.body.removeChild(a);
|
39 |
+
URL.revokeObjectURL(url);
|
40 |
+
|
41 |
+
toast.success('Chats exported successfully');
|
42 |
+
} catch (error) {
|
43 |
+
console.error('Export error:', error);
|
44 |
+
toast.error('Failed to export chats');
|
45 |
+
}
|
46 |
+
};
|
47 |
+
|
48 |
+
const handleExportSettings = () => {
|
49 |
+
try {
|
50 |
+
const settings = {
|
51 |
+
userProfile: localStorage.getItem('bolt_user_profile'),
|
52 |
+
settings: localStorage.getItem('bolt_settings'),
|
53 |
+
exportDate: new Date().toISOString(),
|
54 |
+
};
|
55 |
+
|
56 |
+
const blob = new Blob([JSON.stringify(settings, null, 2)], { type: 'application/json' });
|
57 |
+
const url = URL.createObjectURL(blob);
|
58 |
+
const a = document.createElement('a');
|
59 |
+
a.href = url;
|
60 |
+
a.download = `bolt-settings-${new Date().toISOString()}.json`;
|
61 |
+
document.body.appendChild(a);
|
62 |
+
a.click();
|
63 |
+
document.body.removeChild(a);
|
64 |
+
URL.revokeObjectURL(url);
|
65 |
+
|
66 |
+
toast.success('Settings exported successfully');
|
67 |
+
} catch (error) {
|
68 |
+
console.error('Export error:', error);
|
69 |
+
toast.error('Failed to export settings');
|
70 |
+
}
|
71 |
+
};
|
72 |
+
|
73 |
+
const handleImportSettings = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
74 |
+
const file = event.target.files?.[0];
|
75 |
+
|
76 |
+
if (!file) {
|
77 |
+
return;
|
78 |
+
}
|
79 |
+
|
80 |
+
try {
|
81 |
+
const content = await file.text();
|
82 |
+
const settings = JSON.parse(content);
|
83 |
+
|
84 |
+
if (settings.userProfile) {
|
85 |
+
localStorage.setItem('bolt_user_profile', settings.userProfile);
|
86 |
+
}
|
87 |
+
|
88 |
+
if (settings.settings) {
|
89 |
+
localStorage.setItem('bolt_settings', settings.settings);
|
90 |
+
}
|
91 |
+
|
92 |
+
window.location.reload(); // Reload to apply settings
|
93 |
+
toast.success('Settings imported successfully');
|
94 |
+
} catch (error) {
|
95 |
+
console.error('Import error:', error);
|
96 |
+
toast.error('Failed to import settings');
|
97 |
+
}
|
98 |
+
};
|
99 |
+
|
100 |
+
const handleImportAPIKeys = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
101 |
+
const file = event.target.files?.[0];
|
102 |
+
|
103 |
+
if (!file) {
|
104 |
+
return;
|
105 |
+
}
|
106 |
+
|
107 |
+
setIsImportingKeys(true);
|
108 |
+
|
109 |
+
try {
|
110 |
+
const content = await file.text();
|
111 |
+
const keys = JSON.parse(content);
|
112 |
+
|
113 |
+
// Validate and save each key
|
114 |
+
Object.entries(keys).forEach(([key, value]) => {
|
115 |
+
if (typeof value !== 'string') {
|
116 |
+
throw new Error(`Invalid value for key: ${key}`);
|
117 |
+
}
|
118 |
+
|
119 |
+
localStorage.setItem(`bolt_${key.toLowerCase()}`, value);
|
120 |
+
});
|
121 |
+
|
122 |
+
toast.success('API keys imported successfully');
|
123 |
+
} catch (error) {
|
124 |
+
console.error('Error importing API keys:', error);
|
125 |
+
toast.error('Failed to import API keys');
|
126 |
+
} finally {
|
127 |
+
setIsImportingKeys(false);
|
128 |
+
|
129 |
+
if (apiKeyFileInputRef.current) {
|
130 |
+
apiKeyFileInputRef.current.value = '';
|
131 |
+
}
|
132 |
+
}
|
133 |
+
};
|
134 |
+
|
135 |
+
const handleDownloadTemplate = () => {
|
136 |
+
setIsDownloadingTemplate(true);
|
137 |
+
|
138 |
+
try {
|
139 |
+
const template = {
|
140 |
+
Anthropic_API_KEY: '',
|
141 |
+
OpenAI_API_KEY: '',
|
142 |
+
Google_API_KEY: '',
|
143 |
+
Groq_API_KEY: '',
|
144 |
+
HuggingFace_API_KEY: '',
|
145 |
+
OpenRouter_API_KEY: '',
|
146 |
+
Deepseek_API_KEY: '',
|
147 |
+
Mistral_API_KEY: '',
|
148 |
+
OpenAILike_API_KEY: '',
|
149 |
+
Together_API_KEY: '',
|
150 |
+
xAI_API_KEY: '',
|
151 |
+
Perplexity_API_KEY: '',
|
152 |
+
Cohere_API_KEY: '',
|
153 |
+
AzureOpenAI_API_KEY: '',
|
154 |
+
OPENAI_LIKE_API_BASE_URL: '',
|
155 |
+
LMSTUDIO_API_BASE_URL: '',
|
156 |
+
OLLAMA_API_BASE_URL: '',
|
157 |
+
TOGETHER_API_BASE_URL: '',
|
158 |
+
};
|
159 |
+
|
160 |
+
const blob = new Blob([JSON.stringify(template, null, 2)], { type: 'application/json' });
|
161 |
+
const url = URL.createObjectURL(blob);
|
162 |
+
const a = document.createElement('a');
|
163 |
+
a.href = url;
|
164 |
+
a.download = 'bolt-api-keys-template.json';
|
165 |
+
document.body.appendChild(a);
|
166 |
+
a.click();
|
167 |
+
document.body.removeChild(a);
|
168 |
+
URL.revokeObjectURL(url);
|
169 |
+
|
170 |
+
toast.success('Template downloaded successfully');
|
171 |
+
} catch (error) {
|
172 |
+
console.error('Error downloading template:', error);
|
173 |
+
toast.error('Failed to download template');
|
174 |
+
} finally {
|
175 |
+
setIsDownloadingTemplate(false);
|
176 |
+
}
|
177 |
+
};
|
178 |
+
|
179 |
+
const handleResetSettings = async () => {
|
180 |
+
setIsResetting(true);
|
181 |
+
|
182 |
+
try {
|
183 |
+
// Clear all stored settings from localStorage
|
184 |
+
localStorage.removeItem('bolt_user_profile');
|
185 |
+
localStorage.removeItem('bolt_settings');
|
186 |
+
localStorage.removeItem('bolt_chat_history');
|
187 |
+
|
188 |
+
// Clear all data from IndexedDB
|
189 |
+
if (!db) {
|
190 |
+
throw new Error('Database not initialized');
|
191 |
+
}
|
192 |
+
|
193 |
+
// Get all chats and delete them
|
194 |
+
const chats = await getAll(db as IDBDatabase);
|
195 |
+
const deletePromises = chats.map((chat) => deleteById(db as IDBDatabase, chat.id));
|
196 |
+
await Promise.all(deletePromises);
|
197 |
+
|
198 |
+
// Close the dialog first
|
199 |
+
setShowResetInlineConfirm(false);
|
200 |
+
|
201 |
+
// Then reload and show success message
|
202 |
+
window.location.reload();
|
203 |
+
toast.success('Settings reset successfully');
|
204 |
+
} catch (error) {
|
205 |
+
console.error('Reset error:', error);
|
206 |
+
setShowResetInlineConfirm(false);
|
207 |
+
toast.error('Failed to reset settings');
|
208 |
+
} finally {
|
209 |
+
setIsResetting(false);
|
210 |
+
}
|
211 |
+
};
|
212 |
+
|
213 |
+
const handleDeleteAllChats = async () => {
|
214 |
+
setIsDeleting(true);
|
215 |
+
|
216 |
+
try {
|
217 |
+
// Clear chat history from localStorage
|
218 |
+
localStorage.removeItem('bolt_chat_history');
|
219 |
+
|
220 |
+
// Clear chats from IndexedDB
|
221 |
+
if (!db) {
|
222 |
+
throw new Error('Database not initialized');
|
223 |
+
}
|
224 |
+
|
225 |
+
// Get all chats and delete them one by one
|
226 |
+
const chats = await getAll(db as IDBDatabase);
|
227 |
+
const deletePromises = chats.map((chat) => deleteById(db as IDBDatabase, chat.id));
|
228 |
+
await Promise.all(deletePromises);
|
229 |
+
|
230 |
+
// Close the dialog first
|
231 |
+
setShowDeleteInlineConfirm(false);
|
232 |
+
|
233 |
+
// Then show the success message
|
234 |
+
toast.success('Chat history deleted successfully');
|
235 |
+
} catch (error) {
|
236 |
+
console.error('Delete error:', error);
|
237 |
+
setShowDeleteInlineConfirm(false);
|
238 |
+
toast.error('Failed to delete chat history');
|
239 |
+
} finally {
|
240 |
+
setIsDeleting(false);
|
241 |
+
}
|
242 |
+
};
|
243 |
+
|
244 |
+
return (
|
245 |
+
<div className="space-y-6">
|
246 |
+
<input ref={fileInputRef} type="file" accept=".json" onChange={handleImportSettings} className="hidden" />
|
247 |
+
{/* Reset Settings Dialog */}
|
248 |
+
<DialogRoot open={showResetInlineConfirm} onOpenChange={setShowResetInlineConfirm}>
|
249 |
+
<Dialog showCloseButton={false} className="z-[1000]">
|
250 |
+
<div className="p-6">
|
251 |
+
<div className="flex items-center gap-3">
|
252 |
+
<div className="i-ph:warning-circle-fill w-5 h-5 text-yellow-500" />
|
253 |
+
<DialogTitle>Reset All Settings?</DialogTitle>
|
254 |
+
</div>
|
255 |
+
<p className="text-sm text-bolt-elements-textSecondary mt-2">
|
256 |
+
This will reset all your settings to their default values. This action cannot be undone.
|
257 |
+
</p>
|
258 |
+
<div className="flex justify-end items-center gap-3 mt-6">
|
259 |
+
<DialogClose asChild>
|
260 |
+
<button className="px-4 py-2 rounded-lg text-sm bg-[#F5F5F5] dark:bg-[#1A1A1A] text-[#666666] dark:text-[#999999] hover:text-[#333333] dark:hover:text-white">
|
261 |
+
Cancel
|
262 |
+
</button>
|
263 |
+
</DialogClose>
|
264 |
+
<motion.button
|
265 |
+
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm bg-white dark:bg-[#1A1A1A] text-yellow-600 dark:text-yellow-500 hover:bg-yellow-50 dark:hover:bg-yellow-500/10 border border-transparent hover:border-yellow-500/10 dark:hover:border-yellow-500/20"
|
266 |
+
onClick={handleResetSettings}
|
267 |
+
disabled={isResetting}
|
268 |
+
whileHover={{ scale: 1.02 }}
|
269 |
+
whileTap={{ scale: 0.98 }}
|
270 |
+
>
|
271 |
+
{isResetting ? (
|
272 |
+
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
273 |
+
) : (
|
274 |
+
<div className="i-ph:arrow-counter-clockwise w-4 h-4" />
|
275 |
+
)}
|
276 |
+
Reset Settings
|
277 |
+
</motion.button>
|
278 |
+
</div>
|
279 |
+
</div>
|
280 |
+
</Dialog>
|
281 |
+
</DialogRoot>
|
282 |
+
|
283 |
+
{/* Delete Confirmation Dialog */}
|
284 |
+
<DialogRoot open={showDeleteInlineConfirm} onOpenChange={setShowDeleteInlineConfirm}>
|
285 |
+
<Dialog showCloseButton={false} className="z-[1000]">
|
286 |
+
<div className="p-6">
|
287 |
+
<div className="flex items-center gap-3">
|
288 |
+
<div className="i-ph:warning-circle-fill w-5 h-5 text-red-500" />
|
289 |
+
<DialogTitle>Delete All Chats?</DialogTitle>
|
290 |
+
</div>
|
291 |
+
<p className="text-sm text-bolt-elements-textSecondary mt-2">
|
292 |
+
This will permanently delete all your chat history. This action cannot be undone.
|
293 |
+
</p>
|
294 |
+
<div className="flex justify-end items-center gap-3 mt-6">
|
295 |
+
<DialogClose asChild>
|
296 |
+
<button className="px-4 py-2 rounded-lg text-sm bg-[#F5F5F5] dark:bg-[#1A1A1A] text-[#666666] dark:text-[#999999] hover:text-[#333333] dark:hover:text-white">
|
297 |
+
Cancel
|
298 |
+
</button>
|
299 |
+
</DialogClose>
|
300 |
+
<motion.button
|
301 |
+
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm bg-white dark:bg-[#1A1A1A] text-red-500 dark:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 border border-transparent hover:border-red-500/10 dark:hover:border-red-500/20"
|
302 |
+
onClick={handleDeleteAllChats}
|
303 |
+
disabled={isDeleting}
|
304 |
+
whileHover={{ scale: 1.02 }}
|
305 |
+
whileTap={{ scale: 0.98 }}
|
306 |
+
>
|
307 |
+
{isDeleting ? (
|
308 |
+
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
309 |
+
) : (
|
310 |
+
<div className="i-ph:trash w-4 h-4" />
|
311 |
+
)}
|
312 |
+
Delete All
|
313 |
+
</motion.button>
|
314 |
+
</div>
|
315 |
+
</div>
|
316 |
+
</Dialog>
|
317 |
+
</DialogRoot>
|
318 |
+
|
319 |
+
{/* Chat History Section */}
|
320 |
+
<motion.div
|
321 |
+
className="bg-white dark:bg-[#0A0A0A] rounded-lg p-6 border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
322 |
+
initial={{ opacity: 0, y: 20 }}
|
323 |
+
animate={{ opacity: 1, y: 0 }}
|
324 |
+
transition={{ delay: 0.1 }}
|
325 |
+
>
|
326 |
+
<div className="flex items-center gap-2 mb-2">
|
327 |
+
<div className="i-ph:chat-circle-duotone w-5 h-5 text-purple-500" />
|
328 |
+
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Chat History</h3>
|
329 |
+
</div>
|
330 |
+
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">Export or delete all your chat history.</p>
|
331 |
+
<div className="flex gap-4">
|
332 |
+
<motion.button
|
333 |
+
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600"
|
334 |
+
whileHover={{ scale: 1.02 }}
|
335 |
+
whileTap={{ scale: 0.98 }}
|
336 |
+
onClick={handleExportAllChats}
|
337 |
+
>
|
338 |
+
<div className="i-ph:download-simple w-4 h-4" />
|
339 |
+
Export All Chats
|
340 |
+
</motion.button>
|
341 |
+
<motion.button
|
342 |
+
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-red-50 text-red-500 text-sm hover:bg-red-100 dark:bg-red-500/10 dark:hover:bg-red-500/20"
|
343 |
+
whileHover={{ scale: 1.02 }}
|
344 |
+
whileTap={{ scale: 0.98 }}
|
345 |
+
onClick={() => setShowDeleteInlineConfirm(true)}
|
346 |
+
>
|
347 |
+
<div className="i-ph:trash w-4 h-4" />
|
348 |
+
Delete All Chats
|
349 |
+
</motion.button>
|
350 |
+
</div>
|
351 |
+
</motion.div>
|
352 |
+
|
353 |
+
{/* Settings Backup Section */}
|
354 |
+
<motion.div
|
355 |
+
className="bg-white dark:bg-[#0A0A0A] rounded-lg p-6 border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
356 |
+
initial={{ opacity: 0, y: 20 }}
|
357 |
+
animate={{ opacity: 1, y: 0 }}
|
358 |
+
transition={{ delay: 0.2 }}
|
359 |
+
>
|
360 |
+
<div className="flex items-center gap-2 mb-2">
|
361 |
+
<div className="i-ph:gear-duotone w-5 h-5 text-purple-500" />
|
362 |
+
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Settings Backup</h3>
|
363 |
+
</div>
|
364 |
+
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
365 |
+
Export your settings to a JSON file or import settings from a previously exported file.
|
366 |
+
</p>
|
367 |
+
<div className="flex gap-4">
|
368 |
+
<motion.button
|
369 |
+
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600"
|
370 |
+
whileHover={{ scale: 1.02 }}
|
371 |
+
whileTap={{ scale: 0.98 }}
|
372 |
+
onClick={handleExportSettings}
|
373 |
+
>
|
374 |
+
<div className="i-ph:download-simple w-4 h-4" />
|
375 |
+
Export Settings
|
376 |
+
</motion.button>
|
377 |
+
<motion.button
|
378 |
+
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600"
|
379 |
+
whileHover={{ scale: 1.02 }}
|
380 |
+
whileTap={{ scale: 0.98 }}
|
381 |
+
onClick={() => fileInputRef.current?.click()}
|
382 |
+
>
|
383 |
+
<div className="i-ph:upload-simple w-4 h-4" />
|
384 |
+
Import Settings
|
385 |
+
</motion.button>
|
386 |
+
<motion.button
|
387 |
+
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-yellow-50 text-yellow-600 text-sm hover:bg-yellow-100 dark:bg-yellow-500/10 dark:hover:bg-yellow-500/20 dark:text-yellow-500"
|
388 |
+
whileHover={{ scale: 1.02 }}
|
389 |
+
whileTap={{ scale: 0.98 }}
|
390 |
+
onClick={() => setShowResetInlineConfirm(true)}
|
391 |
+
>
|
392 |
+
<div className="i-ph:arrow-counter-clockwise w-4 h-4" />
|
393 |
+
Reset Settings
|
394 |
+
</motion.button>
|
395 |
+
</div>
|
396 |
+
</motion.div>
|
397 |
+
|
398 |
+
{/* API Keys Management Section */}
|
399 |
+
<motion.div
|
400 |
+
className="bg-white dark:bg-[#0A0A0A] rounded-lg p-6 border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
401 |
+
initial={{ opacity: 0, y: 20 }}
|
402 |
+
animate={{ opacity: 1, y: 0 }}
|
403 |
+
transition={{ delay: 0.3 }}
|
404 |
+
>
|
405 |
+
<div className="flex items-center gap-2 mb-2">
|
406 |
+
<div className="i-ph:key-duotone w-5 h-5 text-purple-500" />
|
407 |
+
<h3 className="text-lg font-medium text-gray-900 dark:text-white">API Keys Management</h3>
|
408 |
+
</div>
|
409 |
+
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
410 |
+
Import API keys from a JSON file or download a template to fill in your keys.
|
411 |
+
</p>
|
412 |
+
<div className="flex gap-4">
|
413 |
+
<input
|
414 |
+
ref={apiKeyFileInputRef}
|
415 |
+
type="file"
|
416 |
+
accept=".json"
|
417 |
+
onChange={handleImportAPIKeys}
|
418 |
+
className="hidden"
|
419 |
+
/>
|
420 |
+
<motion.button
|
421 |
+
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600"
|
422 |
+
whileHover={{ scale: 1.02 }}
|
423 |
+
whileTap={{ scale: 0.98 }}
|
424 |
+
onClick={handleDownloadTemplate}
|
425 |
+
disabled={isDownloadingTemplate}
|
426 |
+
>
|
427 |
+
{isDownloadingTemplate ? (
|
428 |
+
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
429 |
+
) : (
|
430 |
+
<div className="i-ph:download-simple w-4 h-4" />
|
431 |
+
)}
|
432 |
+
Download Template
|
433 |
+
</motion.button>
|
434 |
+
<motion.button
|
435 |
+
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-500 text-white text-sm hover:bg-purple-600"
|
436 |
+
whileHover={{ scale: 1.02 }}
|
437 |
+
whileTap={{ scale: 0.98 }}
|
438 |
+
onClick={() => apiKeyFileInputRef.current?.click()}
|
439 |
+
disabled={isImportingKeys}
|
440 |
+
>
|
441 |
+
{isImportingKeys ? (
|
442 |
+
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
443 |
+
) : (
|
444 |
+
<div className="i-ph:upload-simple w-4 h-4" />
|
445 |
+
)}
|
446 |
+
Import API Keys
|
447 |
+
</motion.button>
|
448 |
+
</div>
|
449 |
+
</motion.div>
|
450 |
+
</div>
|
451 |
+
);
|
452 |
+
}
|
DebugTab.tsx
ADDED
@@ -0,0 +1,2045 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
2 |
+
import { toast } from 'react-toastify';
|
3 |
+
import { classNames } from '~/utils/classNames';
|
4 |
+
import { logStore, type LogEntry } from '~/lib/stores/logs';
|
5 |
+
import { useStore } from '@nanostores/react';
|
6 |
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '~/components/ui/Collapsible';
|
7 |
+
import { Progress } from '~/components/ui/Progress';
|
8 |
+
import { ScrollArea } from '~/components/ui/ScrollArea';
|
9 |
+
import { Badge } from '~/components/ui/Badge';
|
10 |
+
import { Dialog, DialogRoot, DialogTitle } from '~/components/ui/Dialog';
|
11 |
+
import { jsPDF } from 'jspdf';
|
12 |
+
import { useSettings } from '~/lib/hooks/useSettings';
|
13 |
+
|
14 |
+
interface SystemInfo {
|
15 |
+
os: string;
|
16 |
+
arch: string;
|
17 |
+
platform: string;
|
18 |
+
cpus: string;
|
19 |
+
memory: {
|
20 |
+
total: string;
|
21 |
+
free: string;
|
22 |
+
used: string;
|
23 |
+
percentage: number;
|
24 |
+
};
|
25 |
+
node: string;
|
26 |
+
browser: {
|
27 |
+
name: string;
|
28 |
+
version: string;
|
29 |
+
language: string;
|
30 |
+
userAgent: string;
|
31 |
+
cookiesEnabled: boolean;
|
32 |
+
online: boolean;
|
33 |
+
platform: string;
|
34 |
+
cores: number;
|
35 |
+
};
|
36 |
+
screen: {
|
37 |
+
width: number;
|
38 |
+
height: number;
|
39 |
+
colorDepth: number;
|
40 |
+
pixelRatio: number;
|
41 |
+
};
|
42 |
+
time: {
|
43 |
+
timezone: string;
|
44 |
+
offset: number;
|
45 |
+
locale: string;
|
46 |
+
};
|
47 |
+
performance: {
|
48 |
+
memory: {
|
49 |
+
jsHeapSizeLimit: number;
|
50 |
+
totalJSHeapSize: number;
|
51 |
+
usedJSHeapSize: number;
|
52 |
+
usagePercentage: number;
|
53 |
+
};
|
54 |
+
timing: {
|
55 |
+
loadTime: number;
|
56 |
+
domReadyTime: number;
|
57 |
+
readyStart: number;
|
58 |
+
redirectTime: number;
|
59 |
+
appcacheTime: number;
|
60 |
+
unloadEventTime: number;
|
61 |
+
lookupDomainTime: number;
|
62 |
+
connectTime: number;
|
63 |
+
requestTime: number;
|
64 |
+
initDomTreeTime: number;
|
65 |
+
loadEventTime: number;
|
66 |
+
};
|
67 |
+
navigation: {
|
68 |
+
type: number;
|
69 |
+
redirectCount: number;
|
70 |
+
};
|
71 |
+
};
|
72 |
+
network: {
|
73 |
+
downlink: number;
|
74 |
+
effectiveType: string;
|
75 |
+
rtt: number;
|
76 |
+
saveData: boolean;
|
77 |
+
type: string;
|
78 |
+
};
|
79 |
+
battery?: {
|
80 |
+
charging: boolean;
|
81 |
+
chargingTime: number;
|
82 |
+
dischargingTime: number;
|
83 |
+
level: number;
|
84 |
+
};
|
85 |
+
storage: {
|
86 |
+
quota: number;
|
87 |
+
usage: number;
|
88 |
+
persistent: boolean;
|
89 |
+
temporary: boolean;
|
90 |
+
};
|
91 |
+
}
|
92 |
+
|
93 |
+
interface GitHubRepoInfo {
|
94 |
+
fullName: string;
|
95 |
+
defaultBranch: string;
|
96 |
+
stars: number;
|
97 |
+
forks: number;
|
98 |
+
openIssues?: number;
|
99 |
+
}
|
100 |
+
|
101 |
+
interface GitInfo {
|
102 |
+
local: {
|
103 |
+
commitHash: string;
|
104 |
+
branch: string;
|
105 |
+
commitTime: string;
|
106 |
+
author: string;
|
107 |
+
email: string;
|
108 |
+
remoteUrl: string;
|
109 |
+
repoName: string;
|
110 |
+
};
|
111 |
+
github?: {
|
112 |
+
currentRepo: GitHubRepoInfo;
|
113 |
+
upstream?: GitHubRepoInfo;
|
114 |
+
};
|
115 |
+
isForked?: boolean;
|
116 |
+
}
|
117 |
+
|
118 |
+
interface WebAppInfo {
|
119 |
+
name: string;
|
120 |
+
version: string;
|
121 |
+
description: string;
|
122 |
+
license: string;
|
123 |
+
environment: string;
|
124 |
+
timestamp: string;
|
125 |
+
runtimeInfo: {
|
126 |
+
nodeVersion: string;
|
127 |
+
};
|
128 |
+
dependencies: {
|
129 |
+
production: Array<{ name: string; version: string; type: string }>;
|
130 |
+
development: Array<{ name: string; version: string; type: string }>;
|
131 |
+
peer: Array<{ name: string; version: string; type: string }>;
|
132 |
+
optional: Array<{ name: string; version: string; type: string }>;
|
133 |
+
};
|
134 |
+
gitInfo: GitInfo;
|
135 |
+
}
|
136 |
+
|
137 |
+
// Add Ollama service status interface
|
138 |
+
interface OllamaServiceStatus {
|
139 |
+
isRunning: boolean;
|
140 |
+
lastChecked: Date;
|
141 |
+
error?: string;
|
142 |
+
models?: Array<{
|
143 |
+
name: string;
|
144 |
+
size: string;
|
145 |
+
quantization: string;
|
146 |
+
}>;
|
147 |
+
}
|
148 |
+
|
149 |
+
interface ExportFormat {
|
150 |
+
id: string;
|
151 |
+
label: string;
|
152 |
+
icon: string;
|
153 |
+
handler: () => void;
|
154 |
+
}
|
155 |
+
|
156 |
+
const DependencySection = ({
|
157 |
+
title,
|
158 |
+
deps,
|
159 |
+
}: {
|
160 |
+
title: string;
|
161 |
+
deps: Array<{ name: string; version: string; type: string }>;
|
162 |
+
}) => {
|
163 |
+
const [isOpen, setIsOpen] = useState(false);
|
164 |
+
|
165 |
+
if (deps.length === 0) {
|
166 |
+
return null;
|
167 |
+
}
|
168 |
+
|
169 |
+
return (
|
170 |
+
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
171 |
+
<CollapsibleTrigger
|
172 |
+
className={classNames(
|
173 |
+
'flex w-full items-center justify-between p-4',
|
174 |
+
'bg-white dark:bg-[#0A0A0A]',
|
175 |
+
'hover:bg-purple-50/50 dark:hover:bg-[#1a1a1a]',
|
176 |
+
'border-b border-[#E5E5E5] dark:border-[#1A1A1A]',
|
177 |
+
'transition-colors duration-200',
|
178 |
+
'first:rounded-t-lg last:rounded-b-lg',
|
179 |
+
{ 'hover:rounded-lg': !isOpen },
|
180 |
+
)}
|
181 |
+
>
|
182 |
+
<div className="flex items-center gap-3">
|
183 |
+
<div className="i-ph:package text-bolt-elements-textSecondary w-4 h-4" />
|
184 |
+
<span className="text-base text-bolt-elements-textPrimary">
|
185 |
+
{title} Dependencies ({deps.length})
|
186 |
+
</span>
|
187 |
+
</div>
|
188 |
+
<div className="flex items-center gap-2">
|
189 |
+
<span className="text-sm text-bolt-elements-textSecondary">{isOpen ? 'Hide' : 'Show'}</span>
|
190 |
+
<div
|
191 |
+
className={classNames(
|
192 |
+
'i-ph:caret-down w-4 h-4 transform transition-transform duration-200 text-bolt-elements-textSecondary',
|
193 |
+
isOpen ? 'rotate-180' : '',
|
194 |
+
)}
|
195 |
+
/>
|
196 |
+
</div>
|
197 |
+
</CollapsibleTrigger>
|
198 |
+
<CollapsibleContent>
|
199 |
+
<ScrollArea
|
200 |
+
className={classNames(
|
201 |
+
'h-[200px] w-full',
|
202 |
+
'bg-white dark:bg-[#0A0A0A]',
|
203 |
+
'border-b border-[#E5E5E5] dark:border-[#1A1A1A]',
|
204 |
+
'last:rounded-b-lg last:border-b-0',
|
205 |
+
)}
|
206 |
+
>
|
207 |
+
<div className="space-y-2 p-4">
|
208 |
+
{deps.map((dep) => (
|
209 |
+
<div key={dep.name} className="flex items-center justify-between text-sm">
|
210 |
+
<span className="text-bolt-elements-textPrimary">{dep.name}</span>
|
211 |
+
<span className="text-bolt-elements-textSecondary">{dep.version}</span>
|
212 |
+
</div>
|
213 |
+
))}
|
214 |
+
</div>
|
215 |
+
</ScrollArea>
|
216 |
+
</CollapsibleContent>
|
217 |
+
</Collapsible>
|
218 |
+
);
|
219 |
+
};
|
220 |
+
|
221 |
+
export default function DebugTab() {
|
222 |
+
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null);
|
223 |
+
const [webAppInfo, setWebAppInfo] = useState<WebAppInfo | null>(null);
|
224 |
+
const [ollamaStatus, setOllamaStatus] = useState<OllamaServiceStatus>({
|
225 |
+
isRunning: false,
|
226 |
+
lastChecked: new Date(),
|
227 |
+
});
|
228 |
+
const [loading, setLoading] = useState({
|
229 |
+
systemInfo: false,
|
230 |
+
webAppInfo: false,
|
231 |
+
errors: false,
|
232 |
+
performance: false,
|
233 |
+
});
|
234 |
+
const [openSections, setOpenSections] = useState({
|
235 |
+
system: false,
|
236 |
+
webapp: false,
|
237 |
+
errors: false,
|
238 |
+
performance: false,
|
239 |
+
});
|
240 |
+
|
241 |
+
const { providers } = useSettings();
|
242 |
+
|
243 |
+
// Subscribe to logStore updates
|
244 |
+
const logs = useStore(logStore.logs);
|
245 |
+
const errorLogs = useMemo(() => {
|
246 |
+
return Object.values(logs).filter(
|
247 |
+
(log): log is LogEntry => typeof log === 'object' && log !== null && 'level' in log && log.level === 'error',
|
248 |
+
);
|
249 |
+
}, [logs]);
|
250 |
+
|
251 |
+
// Set up error listeners when component mounts
|
252 |
+
useEffect(() => {
|
253 |
+
const handleError = (event: ErrorEvent) => {
|
254 |
+
logStore.logError(event.message, event.error, {
|
255 |
+
filename: event.filename,
|
256 |
+
lineNumber: event.lineno,
|
257 |
+
columnNumber: event.colno,
|
258 |
+
});
|
259 |
+
};
|
260 |
+
|
261 |
+
const handleRejection = (event: PromiseRejectionEvent) => {
|
262 |
+
logStore.logError('Unhandled Promise Rejection', event.reason);
|
263 |
+
};
|
264 |
+
|
265 |
+
window.addEventListener('error', handleError);
|
266 |
+
window.addEventListener('unhandledrejection', handleRejection);
|
267 |
+
|
268 |
+
return () => {
|
269 |
+
window.removeEventListener('error', handleError);
|
270 |
+
window.removeEventListener('unhandledrejection', handleRejection);
|
271 |
+
};
|
272 |
+
}, []);
|
273 |
+
|
274 |
+
// Check for errors when the errors section is opened
|
275 |
+
useEffect(() => {
|
276 |
+
if (openSections.errors) {
|
277 |
+
checkErrors();
|
278 |
+
}
|
279 |
+
}, [openSections.errors]);
|
280 |
+
|
281 |
+
// Load initial data when component mounts
|
282 |
+
useEffect(() => {
|
283 |
+
const loadInitialData = async () => {
|
284 |
+
await Promise.all([getSystemInfo(), getWebAppInfo()]);
|
285 |
+
};
|
286 |
+
|
287 |
+
loadInitialData();
|
288 |
+
}, []);
|
289 |
+
|
290 |
+
// Refresh data when sections are opened
|
291 |
+
useEffect(() => {
|
292 |
+
if (openSections.system) {
|
293 |
+
getSystemInfo();
|
294 |
+
}
|
295 |
+
|
296 |
+
if (openSections.webapp) {
|
297 |
+
getWebAppInfo();
|
298 |
+
}
|
299 |
+
}, [openSections.system, openSections.webapp]);
|
300 |
+
|
301 |
+
// Add periodic refresh of git info
|
302 |
+
useEffect(() => {
|
303 |
+
if (!openSections.webapp) {
|
304 |
+
return undefined;
|
305 |
+
}
|
306 |
+
|
307 |
+
// Initial fetch
|
308 |
+
const fetchGitInfo = async () => {
|
309 |
+
try {
|
310 |
+
const response = await fetch('/api/system/git-info');
|
311 |
+
const updatedGitInfo = (await response.json()) as GitInfo;
|
312 |
+
|
313 |
+
setWebAppInfo((prev) => {
|
314 |
+
if (!prev) {
|
315 |
+
return null;
|
316 |
+
}
|
317 |
+
|
318 |
+
// Only update if the data has changed
|
319 |
+
if (JSON.stringify(prev.gitInfo) === JSON.stringify(updatedGitInfo)) {
|
320 |
+
return prev;
|
321 |
+
}
|
322 |
+
|
323 |
+
return {
|
324 |
+
...prev,
|
325 |
+
gitInfo: updatedGitInfo,
|
326 |
+
};
|
327 |
+
});
|
328 |
+
} catch (error) {
|
329 |
+
console.error('Failed to fetch git info:', error);
|
330 |
+
}
|
331 |
+
};
|
332 |
+
|
333 |
+
fetchGitInfo();
|
334 |
+
|
335 |
+
// Refresh every 5 minutes instead of every second
|
336 |
+
const interval = setInterval(fetchGitInfo, 5 * 60 * 1000);
|
337 |
+
|
338 |
+
return () => clearInterval(interval);
|
339 |
+
}, [openSections.webapp]);
|
340 |
+
|
341 |
+
const getSystemInfo = async () => {
|
342 |
+
try {
|
343 |
+
setLoading((prev) => ({ ...prev, systemInfo: true }));
|
344 |
+
|
345 |
+
// Get browser info
|
346 |
+
const ua = navigator.userAgent;
|
347 |
+
const browserName = ua.includes('Firefox')
|
348 |
+
? 'Firefox'
|
349 |
+
: ua.includes('Chrome')
|
350 |
+
? 'Chrome'
|
351 |
+
: ua.includes('Safari')
|
352 |
+
? 'Safari'
|
353 |
+
: ua.includes('Edge')
|
354 |
+
? 'Edge'
|
355 |
+
: 'Unknown';
|
356 |
+
const browserVersion = ua.match(/(Firefox|Chrome|Safari|Edge)\/([0-9.]+)/)?.[2] || 'Unknown';
|
357 |
+
|
358 |
+
// Get performance metrics
|
359 |
+
const memory = (performance as any).memory || {};
|
360 |
+
const timing = performance.timing;
|
361 |
+
const navigation = performance.navigation;
|
362 |
+
const connection = (navigator as any).connection;
|
363 |
+
|
364 |
+
// Get battery info
|
365 |
+
let batteryInfo;
|
366 |
+
|
367 |
+
try {
|
368 |
+
const battery = await (navigator as any).getBattery();
|
369 |
+
batteryInfo = {
|
370 |
+
charging: battery.charging,
|
371 |
+
chargingTime: battery.chargingTime,
|
372 |
+
dischargingTime: battery.dischargingTime,
|
373 |
+
level: battery.level * 100,
|
374 |
+
};
|
375 |
+
} catch {
|
376 |
+
console.log('Battery API not supported');
|
377 |
+
}
|
378 |
+
|
379 |
+
// Get storage info
|
380 |
+
let storageInfo = {
|
381 |
+
quota: 0,
|
382 |
+
usage: 0,
|
383 |
+
persistent: false,
|
384 |
+
temporary: false,
|
385 |
+
};
|
386 |
+
|
387 |
+
try {
|
388 |
+
const storage = await navigator.storage.estimate();
|
389 |
+
const persistent = await navigator.storage.persist();
|
390 |
+
storageInfo = {
|
391 |
+
quota: storage.quota || 0,
|
392 |
+
usage: storage.usage || 0,
|
393 |
+
persistent,
|
394 |
+
temporary: !persistent,
|
395 |
+
};
|
396 |
+
} catch {
|
397 |
+
console.log('Storage API not supported');
|
398 |
+
}
|
399 |
+
|
400 |
+
// Get memory info from browser performance API
|
401 |
+
const performanceMemory = (performance as any).memory || {};
|
402 |
+
const totalMemory = performanceMemory.jsHeapSizeLimit || 0;
|
403 |
+
const usedMemory = performanceMemory.usedJSHeapSize || 0;
|
404 |
+
const freeMemory = totalMemory - usedMemory;
|
405 |
+
const memoryPercentage = totalMemory ? (usedMemory / totalMemory) * 100 : 0;
|
406 |
+
|
407 |
+
const systemInfo: SystemInfo = {
|
408 |
+
os: navigator.platform,
|
409 |
+
arch: navigator.userAgent.includes('x64') ? 'x64' : navigator.userAgent.includes('arm') ? 'arm' : 'unknown',
|
410 |
+
platform: navigator.platform,
|
411 |
+
cpus: navigator.hardwareConcurrency + ' cores',
|
412 |
+
memory: {
|
413 |
+
total: formatBytes(totalMemory),
|
414 |
+
free: formatBytes(freeMemory),
|
415 |
+
used: formatBytes(usedMemory),
|
416 |
+
percentage: Math.round(memoryPercentage),
|
417 |
+
},
|
418 |
+
node: 'browser',
|
419 |
+
browser: {
|
420 |
+
name: browserName,
|
421 |
+
version: browserVersion,
|
422 |
+
language: navigator.language,
|
423 |
+
userAgent: navigator.userAgent,
|
424 |
+
cookiesEnabled: navigator.cookieEnabled,
|
425 |
+
online: navigator.onLine,
|
426 |
+
platform: navigator.platform,
|
427 |
+
cores: navigator.hardwareConcurrency,
|
428 |
+
},
|
429 |
+
screen: {
|
430 |
+
width: window.screen.width,
|
431 |
+
height: window.screen.height,
|
432 |
+
colorDepth: window.screen.colorDepth,
|
433 |
+
pixelRatio: window.devicePixelRatio,
|
434 |
+
},
|
435 |
+
time: {
|
436 |
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
437 |
+
offset: new Date().getTimezoneOffset(),
|
438 |
+
locale: navigator.language,
|
439 |
+
},
|
440 |
+
performance: {
|
441 |
+
memory: {
|
442 |
+
jsHeapSizeLimit: memory.jsHeapSizeLimit || 0,
|
443 |
+
totalJSHeapSize: memory.totalJSHeapSize || 0,
|
444 |
+
usedJSHeapSize: memory.usedJSHeapSize || 0,
|
445 |
+
usagePercentage: memory.totalJSHeapSize ? (memory.usedJSHeapSize / memory.totalJSHeapSize) * 100 : 0,
|
446 |
+
},
|
447 |
+
timing: {
|
448 |
+
loadTime: timing.loadEventEnd - timing.navigationStart,
|
449 |
+
domReadyTime: timing.domContentLoadedEventEnd - timing.navigationStart,
|
450 |
+
readyStart: timing.fetchStart - timing.navigationStart,
|
451 |
+
redirectTime: timing.redirectEnd - timing.redirectStart,
|
452 |
+
appcacheTime: timing.domainLookupStart - timing.fetchStart,
|
453 |
+
unloadEventTime: timing.unloadEventEnd - timing.unloadEventStart,
|
454 |
+
lookupDomainTime: timing.domainLookupEnd - timing.domainLookupStart,
|
455 |
+
connectTime: timing.connectEnd - timing.connectStart,
|
456 |
+
requestTime: timing.responseEnd - timing.requestStart,
|
457 |
+
initDomTreeTime: timing.domInteractive - timing.responseEnd,
|
458 |
+
loadEventTime: timing.loadEventEnd - timing.loadEventStart,
|
459 |
+
},
|
460 |
+
navigation: {
|
461 |
+
type: navigation.type,
|
462 |
+
redirectCount: navigation.redirectCount,
|
463 |
+
},
|
464 |
+
},
|
465 |
+
network: {
|
466 |
+
downlink: connection?.downlink || 0,
|
467 |
+
effectiveType: connection?.effectiveType || 'unknown',
|
468 |
+
rtt: connection?.rtt || 0,
|
469 |
+
saveData: connection?.saveData || false,
|
470 |
+
type: connection?.type || 'unknown',
|
471 |
+
},
|
472 |
+
battery: batteryInfo,
|
473 |
+
storage: storageInfo,
|
474 |
+
};
|
475 |
+
|
476 |
+
setSystemInfo(systemInfo);
|
477 |
+
toast.success('System information updated');
|
478 |
+
} catch (error) {
|
479 |
+
toast.error('Failed to get system information');
|
480 |
+
console.error('Failed to get system information:', error);
|
481 |
+
} finally {
|
482 |
+
setLoading((prev) => ({ ...prev, systemInfo: false }));
|
483 |
+
}
|
484 |
+
};
|
485 |
+
|
486 |
+
const getWebAppInfo = async () => {
|
487 |
+
try {
|
488 |
+
setLoading((prev) => ({ ...prev, webAppInfo: true }));
|
489 |
+
|
490 |
+
const [appResponse, gitResponse] = await Promise.all([
|
491 |
+
fetch('/api/system/app-info'),
|
492 |
+
fetch('/api/system/git-info'),
|
493 |
+
]);
|
494 |
+
|
495 |
+
if (!appResponse.ok || !gitResponse.ok) {
|
496 |
+
throw new Error('Failed to fetch webapp info');
|
497 |
+
}
|
498 |
+
|
499 |
+
const appData = (await appResponse.json()) as Omit<WebAppInfo, 'gitInfo'>;
|
500 |
+
const gitData = (await gitResponse.json()) as GitInfo;
|
501 |
+
|
502 |
+
console.log('Git Info Response:', gitData); // Add logging to debug
|
503 |
+
|
504 |
+
setWebAppInfo({
|
505 |
+
...appData,
|
506 |
+
gitInfo: gitData,
|
507 |
+
});
|
508 |
+
|
509 |
+
toast.success('WebApp information updated');
|
510 |
+
|
511 |
+
return true;
|
512 |
+
} catch (error) {
|
513 |
+
console.error('Failed to fetch webapp info:', error);
|
514 |
+
toast.error('Failed to fetch webapp information');
|
515 |
+
setWebAppInfo(null);
|
516 |
+
|
517 |
+
return false;
|
518 |
+
} finally {
|
519 |
+
setLoading((prev) => ({ ...prev, webAppInfo: false }));
|
520 |
+
}
|
521 |
+
};
|
522 |
+
|
523 |
+
// Helper function to format bytes to human readable format
|
524 |
+
const formatBytes = (bytes: number) => {
|
525 |
+
const units = ['B', 'KB', 'MB', 'GB'];
|
526 |
+
let size = bytes;
|
527 |
+
let unitIndex = 0;
|
528 |
+
|
529 |
+
while (size >= 1024 && unitIndex < units.length - 1) {
|
530 |
+
size /= 1024;
|
531 |
+
unitIndex++;
|
532 |
+
}
|
533 |
+
|
534 |
+
return `${Math.round(size)} ${units[unitIndex]}`;
|
535 |
+
};
|
536 |
+
|
537 |
+
const handleLogPerformance = () => {
|
538 |
+
try {
|
539 |
+
setLoading((prev) => ({ ...prev, performance: true }));
|
540 |
+
|
541 |
+
// Get performance metrics using modern Performance API
|
542 |
+
const performanceEntries = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
|
543 |
+
const memory = (performance as any).memory;
|
544 |
+
|
545 |
+
// Calculate timing metrics
|
546 |
+
const timingMetrics = {
|
547 |
+
loadTime: performanceEntries.loadEventEnd - performanceEntries.startTime,
|
548 |
+
domReadyTime: performanceEntries.domContentLoadedEventEnd - performanceEntries.startTime,
|
549 |
+
fetchTime: performanceEntries.responseEnd - performanceEntries.fetchStart,
|
550 |
+
redirectTime: performanceEntries.redirectEnd - performanceEntries.redirectStart,
|
551 |
+
dnsTime: performanceEntries.domainLookupEnd - performanceEntries.domainLookupStart,
|
552 |
+
tcpTime: performanceEntries.connectEnd - performanceEntries.connectStart,
|
553 |
+
ttfb: performanceEntries.responseStart - performanceEntries.requestStart,
|
554 |
+
processingTime: performanceEntries.loadEventEnd - performanceEntries.responseEnd,
|
555 |
+
};
|
556 |
+
|
557 |
+
// Get resource timing data
|
558 |
+
const resourceEntries = performance.getEntriesByType('resource');
|
559 |
+
const resourceStats = {
|
560 |
+
totalResources: resourceEntries.length,
|
561 |
+
totalSize: resourceEntries.reduce((total, entry) => total + ((entry as any).transferSize || 0), 0),
|
562 |
+
totalTime: Math.max(...resourceEntries.map((entry) => entry.duration)),
|
563 |
+
};
|
564 |
+
|
565 |
+
// Get memory metrics
|
566 |
+
const memoryMetrics = memory
|
567 |
+
? {
|
568 |
+
jsHeapSizeLimit: memory.jsHeapSizeLimit,
|
569 |
+
totalJSHeapSize: memory.totalJSHeapSize,
|
570 |
+
usedJSHeapSize: memory.usedJSHeapSize,
|
571 |
+
heapUtilization: (memory.usedJSHeapSize / memory.totalJSHeapSize) * 100,
|
572 |
+
}
|
573 |
+
: null;
|
574 |
+
|
575 |
+
// Get frame rate metrics
|
576 |
+
let fps = 0;
|
577 |
+
|
578 |
+
if ('requestAnimationFrame' in window) {
|
579 |
+
const times: number[] = [];
|
580 |
+
|
581 |
+
function calculateFPS(now: number) {
|
582 |
+
times.push(now);
|
583 |
+
|
584 |
+
if (times.length > 10) {
|
585 |
+
const fps = Math.round((1000 * 10) / (now - times[0]));
|
586 |
+
times.shift();
|
587 |
+
|
588 |
+
return fps;
|
589 |
+
}
|
590 |
+
|
591 |
+
requestAnimationFrame(calculateFPS);
|
592 |
+
|
593 |
+
return 0;
|
594 |
+
}
|
595 |
+
|
596 |
+
fps = calculateFPS(performance.now());
|
597 |
+
}
|
598 |
+
|
599 |
+
// Log all performance metrics
|
600 |
+
logStore.logSystem('Performance Metrics', {
|
601 |
+
timing: timingMetrics,
|
602 |
+
resources: resourceStats,
|
603 |
+
memory: memoryMetrics,
|
604 |
+
fps,
|
605 |
+
timestamp: new Date().toISOString(),
|
606 |
+
navigationEntry: {
|
607 |
+
type: performanceEntries.type,
|
608 |
+
redirectCount: performanceEntries.redirectCount,
|
609 |
+
},
|
610 |
+
});
|
611 |
+
|
612 |
+
toast.success('Performance metrics logged');
|
613 |
+
} catch (error) {
|
614 |
+
toast.error('Failed to log performance metrics');
|
615 |
+
console.error('Failed to log performance metrics:', error);
|
616 |
+
} finally {
|
617 |
+
setLoading((prev) => ({ ...prev, performance: false }));
|
618 |
+
}
|
619 |
+
};
|
620 |
+
|
621 |
+
const checkErrors = async () => {
|
622 |
+
try {
|
623 |
+
setLoading((prev) => ({ ...prev, errors: true }));
|
624 |
+
|
625 |
+
// Get errors from log store
|
626 |
+
const storedErrors = errorLogs;
|
627 |
+
|
628 |
+
if (storedErrors.length === 0) {
|
629 |
+
toast.success('No errors found');
|
630 |
+
} else {
|
631 |
+
toast.warning(`Found ${storedErrors.length} error(s)`);
|
632 |
+
}
|
633 |
+
} catch (error) {
|
634 |
+
toast.error('Failed to check errors');
|
635 |
+
console.error('Failed to check errors:', error);
|
636 |
+
} finally {
|
637 |
+
setLoading((prev) => ({ ...prev, errors: false }));
|
638 |
+
}
|
639 |
+
};
|
640 |
+
|
641 |
+
const exportDebugInfo = () => {
|
642 |
+
try {
|
643 |
+
const debugData = {
|
644 |
+
timestamp: new Date().toISOString(),
|
645 |
+
system: systemInfo,
|
646 |
+
webApp: webAppInfo,
|
647 |
+
errors: logStore.getLogs().filter((log: LogEntry) => log.level === 'error'),
|
648 |
+
performance: {
|
649 |
+
memory: (performance as any).memory || {},
|
650 |
+
timing: performance.timing,
|
651 |
+
navigation: performance.navigation,
|
652 |
+
},
|
653 |
+
};
|
654 |
+
|
655 |
+
const blob = new Blob([JSON.stringify(debugData, null, 2)], { type: 'application/json' });
|
656 |
+
const url = window.URL.createObjectURL(blob);
|
657 |
+
const a = document.createElement('a');
|
658 |
+
a.href = url;
|
659 |
+
a.download = `bolt-debug-info-${new Date().toISOString()}.json`;
|
660 |
+
document.body.appendChild(a);
|
661 |
+
a.click();
|
662 |
+
window.URL.revokeObjectURL(url);
|
663 |
+
document.body.removeChild(a);
|
664 |
+
toast.success('Debug information exported successfully');
|
665 |
+
} catch (error) {
|
666 |
+
console.error('Failed to export debug info:', error);
|
667 |
+
toast.error('Failed to export debug information');
|
668 |
+
}
|
669 |
+
};
|
670 |
+
|
671 |
+
const exportAsCSV = () => {
|
672 |
+
try {
|
673 |
+
const debugData = {
|
674 |
+
system: systemInfo,
|
675 |
+
webApp: webAppInfo,
|
676 |
+
errors: logStore.getLogs().filter((log: LogEntry) => log.level === 'error'),
|
677 |
+
performance: {
|
678 |
+
memory: (performance as any).memory || {},
|
679 |
+
timing: performance.timing,
|
680 |
+
navigation: performance.navigation,
|
681 |
+
},
|
682 |
+
};
|
683 |
+
|
684 |
+
// Convert the data to CSV format
|
685 |
+
const csvData = [
|
686 |
+
['Category', 'Key', 'Value'],
|
687 |
+
...Object.entries(debugData).flatMap(([category, data]) =>
|
688 |
+
Object.entries(data || {}).map(([key, value]) => [
|
689 |
+
category,
|
690 |
+
key,
|
691 |
+
typeof value === 'object' ? JSON.stringify(value) : String(value),
|
692 |
+
]),
|
693 |
+
),
|
694 |
+
];
|
695 |
+
|
696 |
+
// Create CSV content
|
697 |
+
const csvContent = csvData.map((row) => row.join(',')).join('\n');
|
698 |
+
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
699 |
+
const url = window.URL.createObjectURL(blob);
|
700 |
+
const a = document.createElement('a');
|
701 |
+
a.href = url;
|
702 |
+
a.download = `bolt-debug-info-${new Date().toISOString()}.csv`;
|
703 |
+
document.body.appendChild(a);
|
704 |
+
a.click();
|
705 |
+
window.URL.revokeObjectURL(url);
|
706 |
+
document.body.removeChild(a);
|
707 |
+
toast.success('Debug information exported as CSV');
|
708 |
+
} catch (error) {
|
709 |
+
console.error('Failed to export CSV:', error);
|
710 |
+
toast.error('Failed to export debug information as CSV');
|
711 |
+
}
|
712 |
+
};
|
713 |
+
|
714 |
+
const exportAsPDF = () => {
|
715 |
+
try {
|
716 |
+
const debugData = {
|
717 |
+
system: systemInfo,
|
718 |
+
webApp: webAppInfo,
|
719 |
+
errors: logStore.getLogs().filter((log: LogEntry) => log.level === 'error'),
|
720 |
+
performance: {
|
721 |
+
memory: (performance as any).memory || {},
|
722 |
+
timing: performance.timing,
|
723 |
+
navigation: performance.navigation,
|
724 |
+
},
|
725 |
+
};
|
726 |
+
|
727 |
+
// Create new PDF document
|
728 |
+
const doc = new jsPDF();
|
729 |
+
const lineHeight = 7;
|
730 |
+
let yPos = 20;
|
731 |
+
const margin = 20;
|
732 |
+
const pageWidth = doc.internal.pageSize.getWidth();
|
733 |
+
const maxLineWidth = pageWidth - 2 * margin;
|
734 |
+
|
735 |
+
// Add key-value pair with better formatting
|
736 |
+
const addKeyValue = (key: string, value: any, indent = 0) => {
|
737 |
+
// Check if we need a new page
|
738 |
+
if (yPos > doc.internal.pageSize.getHeight() - 20) {
|
739 |
+
doc.addPage();
|
740 |
+
yPos = margin;
|
741 |
+
}
|
742 |
+
|
743 |
+
doc.setFontSize(10);
|
744 |
+
doc.setTextColor('#374151');
|
745 |
+
doc.setFont('helvetica', 'bold');
|
746 |
+
|
747 |
+
// Format the key with proper spacing
|
748 |
+
const formattedKey = key.replace(/([A-Z])/g, ' $1').trim();
|
749 |
+
doc.text(formattedKey + ':', margin + indent, yPos);
|
750 |
+
doc.setFont('helvetica', 'normal');
|
751 |
+
doc.setTextColor('#6B7280');
|
752 |
+
|
753 |
+
let valueText;
|
754 |
+
|
755 |
+
if (typeof value === 'object' && value !== null) {
|
756 |
+
// Skip rendering if value is empty object
|
757 |
+
if (Object.keys(value).length === 0) {
|
758 |
+
return;
|
759 |
+
}
|
760 |
+
|
761 |
+
yPos += lineHeight;
|
762 |
+
Object.entries(value).forEach(([subKey, subValue]) => {
|
763 |
+
// Check for page break before each sub-item
|
764 |
+
if (yPos > doc.internal.pageSize.getHeight() - 20) {
|
765 |
+
doc.addPage();
|
766 |
+
yPos = margin;
|
767 |
+
}
|
768 |
+
|
769 |
+
const formattedSubKey = subKey.replace(/([A-Z])/g, ' $1').trim();
|
770 |
+
addKeyValue(formattedSubKey, subValue, indent + 10);
|
771 |
+
});
|
772 |
+
|
773 |
+
return;
|
774 |
+
} else {
|
775 |
+
valueText = String(value);
|
776 |
+
}
|
777 |
+
|
778 |
+
const valueX = margin + indent + doc.getTextWidth(formattedKey + ': ');
|
779 |
+
const maxValueWidth = maxLineWidth - indent - doc.getTextWidth(formattedKey + ': ');
|
780 |
+
const lines = doc.splitTextToSize(valueText, maxValueWidth);
|
781 |
+
|
782 |
+
// Check if we need a new page for the value
|
783 |
+
if (yPos + lines.length * lineHeight > doc.internal.pageSize.getHeight() - 20) {
|
784 |
+
doc.addPage();
|
785 |
+
yPos = margin;
|
786 |
+
}
|
787 |
+
|
788 |
+
doc.text(lines, valueX, yPos);
|
789 |
+
yPos += lines.length * lineHeight;
|
790 |
+
};
|
791 |
+
|
792 |
+
// Add section header with page break check
|
793 |
+
const addSectionHeader = (title: string) => {
|
794 |
+
// Check if we need a new page
|
795 |
+
if (yPos + 20 > doc.internal.pageSize.getHeight() - 20) {
|
796 |
+
doc.addPage();
|
797 |
+
yPos = margin;
|
798 |
+
}
|
799 |
+
|
800 |
+
yPos += lineHeight;
|
801 |
+
doc.setFillColor('#F3F4F6');
|
802 |
+
doc.rect(margin - 2, yPos - 5, pageWidth - 2 * (margin - 2), lineHeight + 6, 'F');
|
803 |
+
doc.setFont('helvetica', 'bold');
|
804 |
+
doc.setTextColor('#111827');
|
805 |
+
doc.setFontSize(12);
|
806 |
+
doc.text(title.toUpperCase(), margin, yPos);
|
807 |
+
doc.setFont('helvetica', 'normal');
|
808 |
+
yPos += lineHeight * 1.5;
|
809 |
+
};
|
810 |
+
|
811 |
+
// Add horizontal line with page break check
|
812 |
+
const addHorizontalLine = () => {
|
813 |
+
// Check if we need a new page
|
814 |
+
if (yPos + 10 > doc.internal.pageSize.getHeight() - 20) {
|
815 |
+
doc.addPage();
|
816 |
+
yPos = margin;
|
817 |
+
|
818 |
+
return; // Skip drawing line if we just started a new page
|
819 |
+
}
|
820 |
+
|
821 |
+
doc.setDrawColor('#E5E5E5');
|
822 |
+
doc.line(margin, yPos, pageWidth - margin, yPos);
|
823 |
+
yPos += lineHeight;
|
824 |
+
};
|
825 |
+
|
826 |
+
// Helper function to add footer to all pages
|
827 |
+
const addFooters = () => {
|
828 |
+
const totalPages = doc.internal.pages.length - 1;
|
829 |
+
|
830 |
+
for (let i = 1; i <= totalPages; i++) {
|
831 |
+
doc.setPage(i);
|
832 |
+
doc.setFontSize(8);
|
833 |
+
doc.setTextColor('#9CA3AF');
|
834 |
+
doc.text(`Page ${i} of ${totalPages}`, pageWidth / 2, doc.internal.pageSize.getHeight() - 10, {
|
835 |
+
align: 'center',
|
836 |
+
});
|
837 |
+
}
|
838 |
+
};
|
839 |
+
|
840 |
+
// Title and Header (first page only)
|
841 |
+
doc.setFillColor('#6366F1');
|
842 |
+
doc.rect(0, 0, pageWidth, 40, 'F');
|
843 |
+
doc.setTextColor('#FFFFFF');
|
844 |
+
doc.setFontSize(24);
|
845 |
+
doc.setFont('helvetica', 'bold');
|
846 |
+
doc.text('Debug Information Report', margin, 25);
|
847 |
+
yPos = 50;
|
848 |
+
|
849 |
+
// Timestamp and metadata
|
850 |
+
doc.setTextColor('#6B7280');
|
851 |
+
doc.setFontSize(10);
|
852 |
+
doc.setFont('helvetica', 'normal');
|
853 |
+
|
854 |
+
const timestamp = new Date().toLocaleString(undefined, {
|
855 |
+
year: 'numeric',
|
856 |
+
month: '2-digit',
|
857 |
+
day: '2-digit',
|
858 |
+
hour: '2-digit',
|
859 |
+
minute: '2-digit',
|
860 |
+
second: '2-digit',
|
861 |
+
});
|
862 |
+
doc.text(`Generated: ${timestamp}`, margin, yPos);
|
863 |
+
yPos += lineHeight * 2;
|
864 |
+
|
865 |
+
// System Information Section
|
866 |
+
if (debugData.system) {
|
867 |
+
addSectionHeader('System Information');
|
868 |
+
|
869 |
+
// OS and Architecture
|
870 |
+
addKeyValue('Operating System', debugData.system.os);
|
871 |
+
addKeyValue('Architecture', debugData.system.arch);
|
872 |
+
addKeyValue('Platform', debugData.system.platform);
|
873 |
+
addKeyValue('CPU Cores', debugData.system.cpus);
|
874 |
+
|
875 |
+
// Memory
|
876 |
+
const memory = debugData.system.memory;
|
877 |
+
addKeyValue('Memory', {
|
878 |
+
'Total Memory': memory.total,
|
879 |
+
'Used Memory': memory.used,
|
880 |
+
'Free Memory': memory.free,
|
881 |
+
Usage: memory.percentage + '%',
|
882 |
+
});
|
883 |
+
|
884 |
+
// Browser Information
|
885 |
+
const browser = debugData.system.browser;
|
886 |
+
addKeyValue('Browser', {
|
887 |
+
Name: browser.name,
|
888 |
+
Version: browser.version,
|
889 |
+
Language: browser.language,
|
890 |
+
Platform: browser.platform,
|
891 |
+
'Cookies Enabled': browser.cookiesEnabled ? 'Yes' : 'No',
|
892 |
+
'Online Status': browser.online ? 'Online' : 'Offline',
|
893 |
+
});
|
894 |
+
|
895 |
+
// Screen Information
|
896 |
+
const screen = debugData.system.screen;
|
897 |
+
addKeyValue('Screen', {
|
898 |
+
Resolution: `${screen.width}x${screen.height}`,
|
899 |
+
'Color Depth': screen.colorDepth + ' bit',
|
900 |
+
'Pixel Ratio': screen.pixelRatio + 'x',
|
901 |
+
});
|
902 |
+
|
903 |
+
// Time Information
|
904 |
+
const time = debugData.system.time;
|
905 |
+
addKeyValue('Time Settings', {
|
906 |
+
Timezone: time.timezone,
|
907 |
+
'UTC Offset': time.offset / 60 + ' hours',
|
908 |
+
Locale: time.locale,
|
909 |
+
});
|
910 |
+
|
911 |
+
addHorizontalLine();
|
912 |
+
}
|
913 |
+
|
914 |
+
// Web App Information Section
|
915 |
+
if (debugData.webApp) {
|
916 |
+
addSectionHeader('Web App Information');
|
917 |
+
|
918 |
+
// Basic Info
|
919 |
+
addKeyValue('Application', {
|
920 |
+
Name: debugData.webApp.name,
|
921 |
+
Version: debugData.webApp.version,
|
922 |
+
Environment: debugData.webApp.environment,
|
923 |
+
'Node Version': debugData.webApp.runtimeInfo.nodeVersion,
|
924 |
+
});
|
925 |
+
|
926 |
+
// Git Information
|
927 |
+
if (debugData.webApp.gitInfo) {
|
928 |
+
const gitInfo = debugData.webApp.gitInfo.local;
|
929 |
+
addKeyValue('Git Information', {
|
930 |
+
Branch: gitInfo.branch,
|
931 |
+
Commit: gitInfo.commitHash,
|
932 |
+
Author: gitInfo.author,
|
933 |
+
'Commit Time': gitInfo.commitTime,
|
934 |
+
Repository: gitInfo.repoName,
|
935 |
+
});
|
936 |
+
|
937 |
+
if (debugData.webApp.gitInfo.github) {
|
938 |
+
const githubInfo = debugData.webApp.gitInfo.github.currentRepo;
|
939 |
+
addKeyValue('GitHub Information', {
|
940 |
+
Repository: githubInfo.fullName,
|
941 |
+
'Default Branch': githubInfo.defaultBranch,
|
942 |
+
Stars: githubInfo.stars,
|
943 |
+
Forks: githubInfo.forks,
|
944 |
+
'Open Issues': githubInfo.openIssues || 0,
|
945 |
+
});
|
946 |
+
}
|
947 |
+
}
|
948 |
+
|
949 |
+
addHorizontalLine();
|
950 |
+
}
|
951 |
+
|
952 |
+
// Performance Section
|
953 |
+
if (debugData.performance) {
|
954 |
+
addSectionHeader('Performance Metrics');
|
955 |
+
|
956 |
+
// Memory Usage
|
957 |
+
const memory = debugData.performance.memory || {};
|
958 |
+
const totalHeap = memory.totalJSHeapSize || 0;
|
959 |
+
const usedHeap = memory.usedJSHeapSize || 0;
|
960 |
+
const usagePercentage = memory.usagePercentage || 0;
|
961 |
+
|
962 |
+
addKeyValue('Memory Usage', {
|
963 |
+
'Total Heap Size': formatBytes(totalHeap),
|
964 |
+
'Used Heap Size': formatBytes(usedHeap),
|
965 |
+
Usage: usagePercentage.toFixed(1) + '%',
|
966 |
+
});
|
967 |
+
|
968 |
+
// Timing Metrics
|
969 |
+
const timing = debugData.performance.timing || {};
|
970 |
+
const navigationStart = timing.navigationStart || 0;
|
971 |
+
const loadEventEnd = timing.loadEventEnd || 0;
|
972 |
+
const domContentLoadedEventEnd = timing.domContentLoadedEventEnd || 0;
|
973 |
+
const responseEnd = timing.responseEnd || 0;
|
974 |
+
const requestStart = timing.requestStart || 0;
|
975 |
+
|
976 |
+
const loadTime = loadEventEnd > navigationStart ? loadEventEnd - navigationStart : 0;
|
977 |
+
const domReadyTime =
|
978 |
+
domContentLoadedEventEnd > navigationStart ? domContentLoadedEventEnd - navigationStart : 0;
|
979 |
+
const requestTime = responseEnd > requestStart ? responseEnd - requestStart : 0;
|
980 |
+
|
981 |
+
addKeyValue('Page Load Metrics', {
|
982 |
+
'Total Load Time': (loadTime / 1000).toFixed(2) + ' seconds',
|
983 |
+
'DOM Ready Time': (domReadyTime / 1000).toFixed(2) + ' seconds',
|
984 |
+
'Request Time': (requestTime / 1000).toFixed(2) + ' seconds',
|
985 |
+
});
|
986 |
+
|
987 |
+
// Network Information
|
988 |
+
if (debugData.system?.network) {
|
989 |
+
const network = debugData.system.network;
|
990 |
+
addKeyValue('Network Information', {
|
991 |
+
'Connection Type': network.type || 'Unknown',
|
992 |
+
'Effective Type': network.effectiveType || 'Unknown',
|
993 |
+
'Download Speed': (network.downlink || 0) + ' Mbps',
|
994 |
+
'Latency (RTT)': (network.rtt || 0) + ' ms',
|
995 |
+
'Data Saver': network.saveData ? 'Enabled' : 'Disabled',
|
996 |
+
});
|
997 |
+
}
|
998 |
+
|
999 |
+
addHorizontalLine();
|
1000 |
+
}
|
1001 |
+
|
1002 |
+
// Errors Section
|
1003 |
+
if (debugData.errors && debugData.errors.length > 0) {
|
1004 |
+
addSectionHeader('Error Log');
|
1005 |
+
|
1006 |
+
debugData.errors.forEach((error: LogEntry, index: number) => {
|
1007 |
+
doc.setTextColor('#DC2626');
|
1008 |
+
doc.setFontSize(10);
|
1009 |
+
doc.setFont('helvetica', 'bold');
|
1010 |
+
doc.text(`Error ${index + 1}:`, margin, yPos);
|
1011 |
+
yPos += lineHeight;
|
1012 |
+
|
1013 |
+
doc.setFont('helvetica', 'normal');
|
1014 |
+
doc.setTextColor('#6B7280');
|
1015 |
+
addKeyValue('Message', error.message, 10);
|
1016 |
+
|
1017 |
+
if (error.stack) {
|
1018 |
+
addKeyValue('Stack', error.stack, 10);
|
1019 |
+
}
|
1020 |
+
|
1021 |
+
if (error.source) {
|
1022 |
+
addKeyValue('Source', error.source, 10);
|
1023 |
+
}
|
1024 |
+
|
1025 |
+
yPos += lineHeight;
|
1026 |
+
});
|
1027 |
+
}
|
1028 |
+
|
1029 |
+
// Add footers to all pages at the end
|
1030 |
+
addFooters();
|
1031 |
+
|
1032 |
+
// Save the PDF
|
1033 |
+
doc.save(`bolt-debug-info-${new Date().toISOString()}.pdf`);
|
1034 |
+
toast.success('Debug information exported as PDF');
|
1035 |
+
} catch (error) {
|
1036 |
+
console.error('Failed to export PDF:', error);
|
1037 |
+
toast.error('Failed to export debug information as PDF');
|
1038 |
+
}
|
1039 |
+
};
|
1040 |
+
|
1041 |
+
const exportAsText = () => {
|
1042 |
+
try {
|
1043 |
+
const debugData = {
|
1044 |
+
system: systemInfo,
|
1045 |
+
webApp: webAppInfo,
|
1046 |
+
errors: logStore.getLogs().filter((log: LogEntry) => log.level === 'error'),
|
1047 |
+
performance: {
|
1048 |
+
memory: (performance as any).memory || {},
|
1049 |
+
timing: performance.timing,
|
1050 |
+
navigation: performance.navigation,
|
1051 |
+
},
|
1052 |
+
};
|
1053 |
+
|
1054 |
+
const textContent = Object.entries(debugData)
|
1055 |
+
.map(([category, data]) => {
|
1056 |
+
return `${category.toUpperCase()}\n${'-'.repeat(30)}\n${JSON.stringify(data, null, 2)}\n\n`;
|
1057 |
+
})
|
1058 |
+
.join('\n');
|
1059 |
+
|
1060 |
+
const blob = new Blob([textContent], { type: 'text/plain' });
|
1061 |
+
const url = window.URL.createObjectURL(blob);
|
1062 |
+
const a = document.createElement('a');
|
1063 |
+
a.href = url;
|
1064 |
+
a.download = `bolt-debug-info-${new Date().toISOString()}.txt`;
|
1065 |
+
document.body.appendChild(a);
|
1066 |
+
a.click();
|
1067 |
+
window.URL.revokeObjectURL(url);
|
1068 |
+
document.body.removeChild(a);
|
1069 |
+
toast.success('Debug information exported as text file');
|
1070 |
+
} catch (error) {
|
1071 |
+
console.error('Failed to export text file:', error);
|
1072 |
+
toast.error('Failed to export debug information as text file');
|
1073 |
+
}
|
1074 |
+
};
|
1075 |
+
|
1076 |
+
const exportFormats: ExportFormat[] = [
|
1077 |
+
{
|
1078 |
+
id: 'json',
|
1079 |
+
label: 'Export as JSON',
|
1080 |
+
icon: 'i-ph:file-json',
|
1081 |
+
handler: exportDebugInfo,
|
1082 |
+
},
|
1083 |
+
{
|
1084 |
+
id: 'csv',
|
1085 |
+
label: 'Export as CSV',
|
1086 |
+
icon: 'i-ph:file-csv',
|
1087 |
+
handler: exportAsCSV,
|
1088 |
+
},
|
1089 |
+
{
|
1090 |
+
id: 'pdf',
|
1091 |
+
label: 'Export as PDF',
|
1092 |
+
icon: 'i-ph:file-pdf',
|
1093 |
+
handler: exportAsPDF,
|
1094 |
+
},
|
1095 |
+
{
|
1096 |
+
id: 'txt',
|
1097 |
+
label: 'Export as Text',
|
1098 |
+
icon: 'i-ph:file-text',
|
1099 |
+
handler: exportAsText,
|
1100 |
+
},
|
1101 |
+
];
|
1102 |
+
|
1103 |
+
// Add Ollama health check function
|
1104 |
+
const checkOllamaStatus = useCallback(async () => {
|
1105 |
+
try {
|
1106 |
+
const ollamaProvider = providers?.Ollama;
|
1107 |
+
const baseUrl = ollamaProvider?.settings?.baseUrl || 'http://127.0.0.1:11434';
|
1108 |
+
|
1109 |
+
// First check if service is running
|
1110 |
+
const versionResponse = await fetch(`${baseUrl}/api/version`);
|
1111 |
+
|
1112 |
+
if (!versionResponse.ok) {
|
1113 |
+
throw new Error('Service not running');
|
1114 |
+
}
|
1115 |
+
|
1116 |
+
// Then fetch installed models
|
1117 |
+
const modelsResponse = await fetch(`${baseUrl}/api/tags`);
|
1118 |
+
|
1119 |
+
const modelsData = (await modelsResponse.json()) as {
|
1120 |
+
models: Array<{ name: string; size: string; quantization: string }>;
|
1121 |
+
};
|
1122 |
+
|
1123 |
+
setOllamaStatus({
|
1124 |
+
isRunning: true,
|
1125 |
+
lastChecked: new Date(),
|
1126 |
+
models: modelsData.models,
|
1127 |
+
});
|
1128 |
+
} catch {
|
1129 |
+
setOllamaStatus({
|
1130 |
+
isRunning: false,
|
1131 |
+
error: 'Connection failed',
|
1132 |
+
lastChecked: new Date(),
|
1133 |
+
models: undefined,
|
1134 |
+
});
|
1135 |
+
}
|
1136 |
+
}, [providers]);
|
1137 |
+
|
1138 |
+
// Monitor Ollama provider status and check periodically
|
1139 |
+
useEffect(() => {
|
1140 |
+
const ollamaProvider = providers?.Ollama;
|
1141 |
+
|
1142 |
+
if (ollamaProvider?.settings?.enabled) {
|
1143 |
+
// Check immediately when provider is enabled
|
1144 |
+
checkOllamaStatus();
|
1145 |
+
|
1146 |
+
// Set up periodic checks every 10 seconds
|
1147 |
+
const intervalId = setInterval(checkOllamaStatus, 10000);
|
1148 |
+
|
1149 |
+
return () => clearInterval(intervalId);
|
1150 |
+
}
|
1151 |
+
|
1152 |
+
return undefined;
|
1153 |
+
}, [providers, checkOllamaStatus]);
|
1154 |
+
|
1155 |
+
// Replace the existing export button with this new component
|
1156 |
+
const ExportButton = () => {
|
1157 |
+
const [isOpen, setIsOpen] = useState(false);
|
1158 |
+
|
1159 |
+
const handleOpenChange = useCallback((open: boolean) => {
|
1160 |
+
setIsOpen(open);
|
1161 |
+
}, []);
|
1162 |
+
|
1163 |
+
const handleFormatClick = useCallback((handler: () => void) => {
|
1164 |
+
handler();
|
1165 |
+
setIsOpen(false);
|
1166 |
+
}, []);
|
1167 |
+
|
1168 |
+
return (
|
1169 |
+
<DialogRoot open={isOpen} onOpenChange={handleOpenChange}>
|
1170 |
+
<button
|
1171 |
+
onClick={() => setIsOpen(true)}
|
1172 |
+
className={classNames(
|
1173 |
+
'group flex items-center gap-2',
|
1174 |
+
'rounded-lg px-3 py-1.5',
|
1175 |
+
'text-sm text-gray-900 dark:text-white',
|
1176 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
1177 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
1178 |
+
'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
|
1179 |
+
'transition-all duration-200',
|
1180 |
+
)}
|
1181 |
+
>
|
1182 |
+
<span className="i-ph:download text-lg text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
1183 |
+
Export
|
1184 |
+
</button>
|
1185 |
+
|
1186 |
+
<Dialog showCloseButton>
|
1187 |
+
<div className="p-6">
|
1188 |
+
<DialogTitle className="flex items-center gap-2">
|
1189 |
+
<div className="i-ph:download w-5 h-5" />
|
1190 |
+
Export Debug Information
|
1191 |
+
</DialogTitle>
|
1192 |
+
|
1193 |
+
<div className="mt-4 flex flex-col gap-2">
|
1194 |
+
{exportFormats.map((format) => (
|
1195 |
+
<button
|
1196 |
+
key={format.id}
|
1197 |
+
onClick={() => handleFormatClick(format.handler)}
|
1198 |
+
className={classNames(
|
1199 |
+
'flex items-center gap-3 px-4 py-3 text-sm rounded-lg transition-colors w-full text-left',
|
1200 |
+
'bg-white dark:bg-[#0A0A0A]',
|
1201 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
1202 |
+
'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
|
1203 |
+
'hover:border-purple-200 dark:hover:border-purple-900/30',
|
1204 |
+
'text-bolt-elements-textPrimary',
|
1205 |
+
)}
|
1206 |
+
>
|
1207 |
+
<div className={classNames(format.icon, 'w-5 h-5')} />
|
1208 |
+
<div>
|
1209 |
+
<div className="font-medium">{format.label}</div>
|
1210 |
+
<div className="text-xs text-bolt-elements-textSecondary mt-0.5">
|
1211 |
+
{format.id === 'json' && 'Export as a structured JSON file'}
|
1212 |
+
{format.id === 'csv' && 'Export as a CSV spreadsheet'}
|
1213 |
+
{format.id === 'pdf' && 'Export as a formatted PDF document'}
|
1214 |
+
{format.id === 'txt' && 'Export as a formatted text file'}
|
1215 |
+
</div>
|
1216 |
+
</div>
|
1217 |
+
</button>
|
1218 |
+
))}
|
1219 |
+
</div>
|
1220 |
+
</div>
|
1221 |
+
</Dialog>
|
1222 |
+
</DialogRoot>
|
1223 |
+
);
|
1224 |
+
};
|
1225 |
+
|
1226 |
+
// Add helper function to get Ollama status text and color
|
1227 |
+
const getOllamaStatus = () => {
|
1228 |
+
const ollamaProvider = providers?.Ollama;
|
1229 |
+
const isOllamaEnabled = ollamaProvider?.settings?.enabled;
|
1230 |
+
|
1231 |
+
if (!isOllamaEnabled) {
|
1232 |
+
return {
|
1233 |
+
status: 'Disabled',
|
1234 |
+
color: 'text-red-500',
|
1235 |
+
bgColor: 'bg-red-500',
|
1236 |
+
message: 'Ollama provider is disabled in settings',
|
1237 |
+
};
|
1238 |
+
}
|
1239 |
+
|
1240 |
+
if (!ollamaStatus.isRunning) {
|
1241 |
+
return {
|
1242 |
+
status: 'Not Running',
|
1243 |
+
color: 'text-red-500',
|
1244 |
+
bgColor: 'bg-red-500',
|
1245 |
+
message: ollamaStatus.error || 'Ollama service is not running',
|
1246 |
+
};
|
1247 |
+
}
|
1248 |
+
|
1249 |
+
const modelCount = ollamaStatus.models?.length ?? 0;
|
1250 |
+
|
1251 |
+
return {
|
1252 |
+
status: 'Running',
|
1253 |
+
color: 'text-green-500',
|
1254 |
+
bgColor: 'bg-green-500',
|
1255 |
+
message: `Ollama service is running with ${modelCount} installed models (Provider: Enabled)`,
|
1256 |
+
};
|
1257 |
+
};
|
1258 |
+
|
1259 |
+
// Add type for status result
|
1260 |
+
type StatusResult = {
|
1261 |
+
status: string;
|
1262 |
+
color: string;
|
1263 |
+
bgColor: string;
|
1264 |
+
message: string;
|
1265 |
+
};
|
1266 |
+
|
1267 |
+
const status = getOllamaStatus() as StatusResult;
|
1268 |
+
|
1269 |
+
return (
|
1270 |
+
<div className="flex flex-col gap-6 max-w-7xl mx-auto p-4">
|
1271 |
+
{/* Quick Stats Banner */}
|
1272 |
+
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
1273 |
+
{/* Errors Card */}
|
1274 |
+
<div className="p-4 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] hover:border-purple-500/30 transition-all duration-200 h-[180px] flex flex-col">
|
1275 |
+
<div className="flex items-center gap-2">
|
1276 |
+
<div className="i-ph:warning-octagon text-purple-500 w-4 h-4" />
|
1277 |
+
<div className="text-sm text-bolt-elements-textSecondary">Errors</div>
|
1278 |
+
</div>
|
1279 |
+
<div className="flex items-center gap-2 mt-2">
|
1280 |
+
<span
|
1281 |
+
className={classNames('text-2xl font-semibold', errorLogs.length > 0 ? 'text-red-500' : 'text-green-500')}
|
1282 |
+
>
|
1283 |
+
{errorLogs.length}
|
1284 |
+
</span>
|
1285 |
+
</div>
|
1286 |
+
<div className="text-xs text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
|
1287 |
+
<div
|
1288 |
+
className={classNames(
|
1289 |
+
'w-3.5 h-3.5',
|
1290 |
+
errorLogs.length > 0 ? 'i-ph:warning text-red-500' : 'i-ph:check-circle text-green-500',
|
1291 |
+
)}
|
1292 |
+
/>
|
1293 |
+
{errorLogs.length > 0 ? 'Errors detected' : 'No errors detected'}
|
1294 |
+
</div>
|
1295 |
+
</div>
|
1296 |
+
|
1297 |
+
{/* Memory Usage Card */}
|
1298 |
+
<div className="p-4 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] hover:border-purple-500/30 transition-all duration-200 h-[180px] flex flex-col">
|
1299 |
+
<div className="flex items-center gap-2">
|
1300 |
+
<div className="i-ph:cpu text-purple-500 w-4 h-4" />
|
1301 |
+
<div className="text-sm text-bolt-elements-textSecondary">Memory Usage</div>
|
1302 |
+
</div>
|
1303 |
+
<div className="flex items-center gap-2 mt-2">
|
1304 |
+
<span
|
1305 |
+
className={classNames(
|
1306 |
+
'text-2xl font-semibold',
|
1307 |
+
(systemInfo?.memory?.percentage ?? 0) > 80
|
1308 |
+
? 'text-red-500'
|
1309 |
+
: (systemInfo?.memory?.percentage ?? 0) > 60
|
1310 |
+
? 'text-yellow-500'
|
1311 |
+
: 'text-green-500',
|
1312 |
+
)}
|
1313 |
+
>
|
1314 |
+
{systemInfo?.memory?.percentage ?? 0}%
|
1315 |
+
</span>
|
1316 |
+
</div>
|
1317 |
+
<Progress
|
1318 |
+
value={systemInfo?.memory?.percentage ?? 0}
|
1319 |
+
className={classNames(
|
1320 |
+
'mt-2',
|
1321 |
+
(systemInfo?.memory?.percentage ?? 0) > 80
|
1322 |
+
? '[&>div]:bg-red-500'
|
1323 |
+
: (systemInfo?.memory?.percentage ?? 0) > 60
|
1324 |
+
? '[&>div]:bg-yellow-500'
|
1325 |
+
: '[&>div]:bg-green-500',
|
1326 |
+
)}
|
1327 |
+
/>
|
1328 |
+
<div className="text-xs text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
|
1329 |
+
<div className="i-ph:info w-3.5 h-3.5 text-purple-500" />
|
1330 |
+
Used: {systemInfo?.memory.used ?? '0 GB'} / {systemInfo?.memory.total ?? '0 GB'}
|
1331 |
+
</div>
|
1332 |
+
</div>
|
1333 |
+
|
1334 |
+
{/* Page Load Time Card */}
|
1335 |
+
<div className="p-4 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] hover:border-purple-500/30 transition-all duration-200 h-[180px] flex flex-col">
|
1336 |
+
<div className="flex items-center gap-2">
|
1337 |
+
<div className="i-ph:timer text-purple-500 w-4 h-4" />
|
1338 |
+
<div className="text-sm text-bolt-elements-textSecondary">Page Load Time</div>
|
1339 |
+
</div>
|
1340 |
+
<div className="flex items-center gap-2 mt-2">
|
1341 |
+
<span
|
1342 |
+
className={classNames(
|
1343 |
+
'text-2xl font-semibold',
|
1344 |
+
(systemInfo?.performance.timing.loadTime ?? 0) > 2000
|
1345 |
+
? 'text-red-500'
|
1346 |
+
: (systemInfo?.performance.timing.loadTime ?? 0) > 1000
|
1347 |
+
? 'text-yellow-500'
|
1348 |
+
: 'text-green-500',
|
1349 |
+
)}
|
1350 |
+
>
|
1351 |
+
{systemInfo ? (systemInfo.performance.timing.loadTime / 1000).toFixed(2) : '-'}s
|
1352 |
+
</span>
|
1353 |
+
</div>
|
1354 |
+
<div className="text-xs text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
|
1355 |
+
<div className="i-ph:code w-3.5 h-3.5 text-purple-500" />
|
1356 |
+
DOM Ready: {systemInfo ? (systemInfo.performance.timing.domReadyTime / 1000).toFixed(2) : '-'}s
|
1357 |
+
</div>
|
1358 |
+
</div>
|
1359 |
+
|
1360 |
+
{/* Network Speed Card */}
|
1361 |
+
<div className="p-4 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] hover:border-purple-500/30 transition-all duration-200 h-[180px] flex flex-col">
|
1362 |
+
<div className="flex items-center gap-2">
|
1363 |
+
<div className="i-ph:wifi-high text-purple-500 w-4 h-4" />
|
1364 |
+
<div className="text-sm text-bolt-elements-textSecondary">Network Speed</div>
|
1365 |
+
</div>
|
1366 |
+
<div className="flex items-center gap-2 mt-2">
|
1367 |
+
<span
|
1368 |
+
className={classNames(
|
1369 |
+
'text-2xl font-semibold',
|
1370 |
+
(systemInfo?.network.downlink ?? 0) < 5
|
1371 |
+
? 'text-red-500'
|
1372 |
+
: (systemInfo?.network.downlink ?? 0) < 10
|
1373 |
+
? 'text-yellow-500'
|
1374 |
+
: 'text-green-500',
|
1375 |
+
)}
|
1376 |
+
>
|
1377 |
+
{systemInfo?.network.downlink ?? '-'} Mbps
|
1378 |
+
</span>
|
1379 |
+
</div>
|
1380 |
+
<div className="text-xs text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
|
1381 |
+
<div className="i-ph:activity w-3.5 h-3.5 text-purple-500" />
|
1382 |
+
RTT: {systemInfo?.network.rtt ?? '-'} ms
|
1383 |
+
</div>
|
1384 |
+
</div>
|
1385 |
+
|
1386 |
+
{/* Ollama Service Card - Now spans all 4 columns */}
|
1387 |
+
<div className="md:col-span-4 p-6 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] hover:border-purple-500/30 transition-all duration-200 h-[260px] flex flex-col">
|
1388 |
+
<div className="flex items-center justify-between">
|
1389 |
+
<div className="flex items-center gap-3">
|
1390 |
+
<div className="i-ph:robot text-purple-500 w-5 h-5" />
|
1391 |
+
<div>
|
1392 |
+
<div className="text-base font-medium text-bolt-elements-textPrimary">Ollama Service</div>
|
1393 |
+
<div className="text-xs text-bolt-elements-textSecondary mt-0.5">{status.message}</div>
|
1394 |
+
</div>
|
1395 |
+
</div>
|
1396 |
+
<div className="flex items-center gap-3">
|
1397 |
+
<div className="flex items-center gap-2 px-2.5 py-1 rounded-full bg-bolt-elements-background-depth-3">
|
1398 |
+
<div
|
1399 |
+
className={classNames('w-2 h-2 rounded-full animate-pulse', status.bgColor, {
|
1400 |
+
'shadow-lg shadow-green-500/20': status.status === 'Running',
|
1401 |
+
'shadow-lg shadow-red-500/20': status.status === 'Not Running',
|
1402 |
+
})}
|
1403 |
+
/>
|
1404 |
+
<span className={classNames('text-xs font-medium flex items-center gap-1', status.color)}>
|
1405 |
+
{status.status}
|
1406 |
+
</span>
|
1407 |
+
</div>
|
1408 |
+
<div className="text-[10px] text-bolt-elements-textTertiary flex items-center gap-1.5">
|
1409 |
+
<div className="i-ph:clock w-3 h-3" />
|
1410 |
+
{ollamaStatus.lastChecked.toLocaleTimeString()}
|
1411 |
+
</div>
|
1412 |
+
</div>
|
1413 |
+
</div>
|
1414 |
+
|
1415 |
+
<div className="mt-6 flex-1 min-h-0 flex flex-col">
|
1416 |
+
{status.status === 'Running' && ollamaStatus.models && ollamaStatus.models.length > 0 ? (
|
1417 |
+
<>
|
1418 |
+
<div className="text-xs font-medium text-bolt-elements-textSecondary flex items-center justify-between mb-3">
|
1419 |
+
<div className="flex items-center gap-2">
|
1420 |
+
<div className="i-ph:cube-duotone w-4 h-4 text-purple-500" />
|
1421 |
+
<span>Installed Models</span>
|
1422 |
+
<Badge variant="secondary" className="ml-1">
|
1423 |
+
{ollamaStatus.models.length}
|
1424 |
+
</Badge>
|
1425 |
+
</div>
|
1426 |
+
</div>
|
1427 |
+
<div className="overflow-y-auto flex-1 scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-400 dark:hover:scrollbar-thumb-gray-600">
|
1428 |
+
<div className="grid grid-cols-2 gap-3 pr-2">
|
1429 |
+
{ollamaStatus.models.map((model) => (
|
1430 |
+
<div
|
1431 |
+
key={model.name}
|
1432 |
+
className="text-sm bg-bolt-elements-background-depth-3 hover:bg-bolt-elements-background-depth-4 rounded-lg px-4 py-3 flex items-center justify-between transition-colors group"
|
1433 |
+
>
|
1434 |
+
<div className="flex items-center gap-2 text-bolt-elements-textSecondary">
|
1435 |
+
<div className="i-ph:cube w-4 h-4 text-purple-500/70 group-hover:text-purple-500 transition-colors" />
|
1436 |
+
<span className="font-mono truncate">{model.name}</span>
|
1437 |
+
</div>
|
1438 |
+
<Badge variant="outline" className="ml-2 text-xs font-mono">
|
1439 |
+
{Math.round(parseInt(model.size) / 1024 / 1024)}MB
|
1440 |
+
</Badge>
|
1441 |
+
</div>
|
1442 |
+
))}
|
1443 |
+
</div>
|
1444 |
+
</div>
|
1445 |
+
</>
|
1446 |
+
) : (
|
1447 |
+
<div className="flex-1 flex items-center justify-center">
|
1448 |
+
<div className="flex flex-col items-center gap-3 max-w-[280px] text-center">
|
1449 |
+
<div
|
1450 |
+
className={classNames('w-12 h-12', {
|
1451 |
+
'i-ph:warning-circle text-red-500/80':
|
1452 |
+
status.status === 'Not Running' || status.status === 'Disabled',
|
1453 |
+
'i-ph:cube-duotone text-purple-500/80': status.status === 'Running',
|
1454 |
+
})}
|
1455 |
+
/>
|
1456 |
+
<span className="text-sm text-bolt-elements-textSecondary">{status.message}</span>
|
1457 |
+
</div>
|
1458 |
+
</div>
|
1459 |
+
)}
|
1460 |
+
</div>
|
1461 |
+
</div>
|
1462 |
+
</div>
|
1463 |
+
|
1464 |
+
{/* Action Buttons */}
|
1465 |
+
<div className="flex flex-wrap gap-4">
|
1466 |
+
<button
|
1467 |
+
onClick={getSystemInfo}
|
1468 |
+
disabled={loading.systemInfo}
|
1469 |
+
className={classNames(
|
1470 |
+
'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
|
1471 |
+
'bg-white dark:bg-[#0A0A0A]',
|
1472 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
1473 |
+
'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
|
1474 |
+
'hover:border-purple-200 dark:hover:border-purple-900/30',
|
1475 |
+
'text-bolt-elements-textPrimary',
|
1476 |
+
{ 'opacity-50 cursor-not-allowed': loading.systemInfo },
|
1477 |
+
)}
|
1478 |
+
>
|
1479 |
+
{loading.systemInfo ? (
|
1480 |
+
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
|
1481 |
+
) : (
|
1482 |
+
<div className="i-ph:gear w-4 h-4" />
|
1483 |
+
)}
|
1484 |
+
Update System Info
|
1485 |
+
</button>
|
1486 |
+
|
1487 |
+
<button
|
1488 |
+
onClick={handleLogPerformance}
|
1489 |
+
disabled={loading.performance}
|
1490 |
+
className={classNames(
|
1491 |
+
'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
|
1492 |
+
'bg-white dark:bg-[#0A0A0A]',
|
1493 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
1494 |
+
'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
|
1495 |
+
'hover:border-purple-200 dark:hover:border-purple-900/30',
|
1496 |
+
'text-bolt-elements-textPrimary',
|
1497 |
+
{ 'opacity-50 cursor-not-allowed': loading.performance },
|
1498 |
+
)}
|
1499 |
+
>
|
1500 |
+
{loading.performance ? (
|
1501 |
+
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
|
1502 |
+
) : (
|
1503 |
+
<div className="i-ph:chart-bar w-4 h-4" />
|
1504 |
+
)}
|
1505 |
+
Log Performance
|
1506 |
+
</button>
|
1507 |
+
|
1508 |
+
<button
|
1509 |
+
onClick={checkErrors}
|
1510 |
+
disabled={loading.errors}
|
1511 |
+
className={classNames(
|
1512 |
+
'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
|
1513 |
+
'bg-white dark:bg-[#0A0A0A]',
|
1514 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
1515 |
+
'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
|
1516 |
+
'hover:border-purple-200 dark:hover:border-purple-900/30',
|
1517 |
+
'text-bolt-elements-textPrimary',
|
1518 |
+
{ 'opacity-50 cursor-not-allowed': loading.errors },
|
1519 |
+
)}
|
1520 |
+
>
|
1521 |
+
{loading.errors ? (
|
1522 |
+
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
|
1523 |
+
) : (
|
1524 |
+
<div className="i-ph:warning w-4 h-4" />
|
1525 |
+
)}
|
1526 |
+
Check Errors
|
1527 |
+
</button>
|
1528 |
+
|
1529 |
+
<button
|
1530 |
+
onClick={getWebAppInfo}
|
1531 |
+
disabled={loading.webAppInfo}
|
1532 |
+
className={classNames(
|
1533 |
+
'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-colors',
|
1534 |
+
'bg-white dark:bg-[#0A0A0A]',
|
1535 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
1536 |
+
'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
|
1537 |
+
'hover:border-purple-200 dark:hover:border-purple-900/30',
|
1538 |
+
'text-bolt-elements-textPrimary',
|
1539 |
+
{ 'opacity-50 cursor-not-allowed': loading.webAppInfo },
|
1540 |
+
)}
|
1541 |
+
>
|
1542 |
+
{loading.webAppInfo ? (
|
1543 |
+
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
|
1544 |
+
) : (
|
1545 |
+
<div className="i-ph:info w-4 h-4" />
|
1546 |
+
)}
|
1547 |
+
Fetch WebApp Info
|
1548 |
+
</button>
|
1549 |
+
|
1550 |
+
<ExportButton />
|
1551 |
+
</div>
|
1552 |
+
|
1553 |
+
{/* System Information */}
|
1554 |
+
<Collapsible
|
1555 |
+
open={openSections.system}
|
1556 |
+
onOpenChange={(open: boolean) => setOpenSections((prev) => ({ ...prev, system: open }))}
|
1557 |
+
className="w-full"
|
1558 |
+
>
|
1559 |
+
<CollapsibleTrigger className="w-full">
|
1560 |
+
<div className="flex items-center justify-between p-6 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
|
1561 |
+
<div className="flex items-center gap-3">
|
1562 |
+
<div className="i-ph:cpu text-purple-500 w-5 h-5" />
|
1563 |
+
<h3 className="text-base font-medium text-bolt-elements-textPrimary">System Information</h3>
|
1564 |
+
</div>
|
1565 |
+
<div
|
1566 |
+
className={classNames(
|
1567 |
+
'i-ph:caret-down w-4 h-4 transform transition-transform duration-200',
|
1568 |
+
openSections.system ? 'rotate-180' : '',
|
1569 |
+
)}
|
1570 |
+
/>
|
1571 |
+
</div>
|
1572 |
+
</CollapsibleTrigger>
|
1573 |
+
|
1574 |
+
<CollapsibleContent>
|
1575 |
+
<div className="p-6 mt-2 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
|
1576 |
+
{systemInfo ? (
|
1577 |
+
<div className="grid grid-cols-2 gap-6">
|
1578 |
+
<div className="space-y-2">
|
1579 |
+
<div className="text-sm flex items-center gap-2">
|
1580 |
+
<div className="i-ph:desktop text-bolt-elements-textSecondary w-4 h-4" />
|
1581 |
+
<span className="text-bolt-elements-textSecondary">OS: </span>
|
1582 |
+
<span className="text-bolt-elements-textPrimary">{systemInfo.os}</span>
|
1583 |
+
</div>
|
1584 |
+
<div className="text-sm flex items-center gap-2">
|
1585 |
+
<div className="i-ph:device-mobile text-bolt-elements-textSecondary w-4 h-4" />
|
1586 |
+
<span className="text-bolt-elements-textSecondary">Platform: </span>
|
1587 |
+
<span className="text-bolt-elements-textPrimary">{systemInfo.platform}</span>
|
1588 |
+
</div>
|
1589 |
+
<div className="text-sm flex items-center gap-2">
|
1590 |
+
<div className="i-ph:microchip text-bolt-elements-textSecondary w-4 h-4" />
|
1591 |
+
<span className="text-bolt-elements-textSecondary">Architecture: </span>
|
1592 |
+
<span className="text-bolt-elements-textPrimary">{systemInfo.arch}</span>
|
1593 |
+
</div>
|
1594 |
+
<div className="text-sm flex items-center gap-2">
|
1595 |
+
<div className="i-ph:cpu text-bolt-elements-textSecondary w-4 h-4" />
|
1596 |
+
<span className="text-bolt-elements-textSecondary">CPU Cores: </span>
|
1597 |
+
<span className="text-bolt-elements-textPrimary">{systemInfo.cpus}</span>
|
1598 |
+
</div>
|
1599 |
+
<div className="text-sm flex items-center gap-2">
|
1600 |
+
<div className="i-ph:node text-bolt-elements-textSecondary w-4 h-4" />
|
1601 |
+
<span className="text-bolt-elements-textSecondary">Node Version: </span>
|
1602 |
+
<span className="text-bolt-elements-textPrimary">{systemInfo.node}</span>
|
1603 |
+
</div>
|
1604 |
+
<div className="text-sm flex items-center gap-2">
|
1605 |
+
<div className="i-ph:wifi-high text-bolt-elements-textSecondary w-4 h-4" />
|
1606 |
+
<span className="text-bolt-elements-textSecondary">Network Type: </span>
|
1607 |
+
<span className="text-bolt-elements-textPrimary">
|
1608 |
+
{systemInfo.network.type} ({systemInfo.network.effectiveType})
|
1609 |
+
</span>
|
1610 |
+
</div>
|
1611 |
+
<div className="text-sm flex items-center gap-2">
|
1612 |
+
<div className="i-ph:gauge text-bolt-elements-textSecondary w-4 h-4" />
|
1613 |
+
<span className="text-bolt-elements-textSecondary">Network Speed: </span>
|
1614 |
+
<span className="text-bolt-elements-textPrimary">
|
1615 |
+
{systemInfo.network.downlink}Mbps (RTT: {systemInfo.network.rtt}ms)
|
1616 |
+
</span>
|
1617 |
+
</div>
|
1618 |
+
{systemInfo.battery && (
|
1619 |
+
<div className="text-sm flex items-center gap-2">
|
1620 |
+
<div className="i-ph:battery-charging text-bolt-elements-textSecondary w-4 h-4" />
|
1621 |
+
<span className="text-bolt-elements-textSecondary">Battery: </span>
|
1622 |
+
<span className="text-bolt-elements-textPrimary">
|
1623 |
+
{systemInfo.battery.level.toFixed(1)}% {systemInfo.battery.charging ? '(Charging)' : ''}
|
1624 |
+
</span>
|
1625 |
+
</div>
|
1626 |
+
)}
|
1627 |
+
<div className="text-sm flex items-center gap-2">
|
1628 |
+
<div className="i-ph:hard-drive text-bolt-elements-textSecondary w-4 h-4" />
|
1629 |
+
<span className="text-bolt-elements-textSecondary">Storage: </span>
|
1630 |
+
<span className="text-bolt-elements-textPrimary">
|
1631 |
+
{(systemInfo.storage.usage / (1024 * 1024 * 1024)).toFixed(2)}GB /{' '}
|
1632 |
+
{(systemInfo.storage.quota / (1024 * 1024 * 1024)).toFixed(2)}GB
|
1633 |
+
</span>
|
1634 |
+
</div>
|
1635 |
+
</div>
|
1636 |
+
<div className="space-y-2">
|
1637 |
+
<div className="text-sm flex items-center gap-2">
|
1638 |
+
<div className="i-ph:database text-bolt-elements-textSecondary w-4 h-4" />
|
1639 |
+
<span className="text-bolt-elements-textSecondary">Memory Usage: </span>
|
1640 |
+
<span className="text-bolt-elements-textPrimary">
|
1641 |
+
{systemInfo.memory.used} / {systemInfo.memory.total} ({systemInfo.memory.percentage}%)
|
1642 |
+
</span>
|
1643 |
+
</div>
|
1644 |
+
<div className="text-sm flex items-center gap-2">
|
1645 |
+
<div className="i-ph:browser text-bolt-elements-textSecondary w-4 h-4" />
|
1646 |
+
<span className="text-bolt-elements-textSecondary">Browser: </span>
|
1647 |
+
<span className="text-bolt-elements-textPrimary">
|
1648 |
+
{systemInfo.browser.name} {systemInfo.browser.version}
|
1649 |
+
</span>
|
1650 |
+
</div>
|
1651 |
+
<div className="text-sm flex items-center gap-2">
|
1652 |
+
<div className="i-ph:monitor text-bolt-elements-textSecondary w-4 h-4" />
|
1653 |
+
<span className="text-bolt-elements-textSecondary">Screen: </span>
|
1654 |
+
<span className="text-bolt-elements-textPrimary">
|
1655 |
+
{systemInfo.screen.width}x{systemInfo.screen.height} ({systemInfo.screen.pixelRatio}x)
|
1656 |
+
</span>
|
1657 |
+
</div>
|
1658 |
+
<div className="text-sm flex items-center gap-2">
|
1659 |
+
<div className="i-ph:clock text-bolt-elements-textSecondary w-4 h-4" />
|
1660 |
+
<span className="text-bolt-elements-textSecondary">Timezone: </span>
|
1661 |
+
<span className="text-bolt-elements-textPrimary">{systemInfo.time.timezone}</span>
|
1662 |
+
</div>
|
1663 |
+
<div className="text-sm flex items-center gap-2">
|
1664 |
+
<div className="i-ph:translate text-bolt-elements-textSecondary w-4 h-4" />
|
1665 |
+
<span className="text-bolt-elements-textSecondary">Language: </span>
|
1666 |
+
<span className="text-bolt-elements-textPrimary">{systemInfo.browser.language}</span>
|
1667 |
+
</div>
|
1668 |
+
<div className="text-sm flex items-center gap-2">
|
1669 |
+
<div className="i-ph:chart-pie text-bolt-elements-textSecondary w-4 h-4" />
|
1670 |
+
<span className="text-bolt-elements-textSecondary">JS Heap: </span>
|
1671 |
+
<span className="text-bolt-elements-textPrimary">
|
1672 |
+
{(systemInfo.performance.memory.usedJSHeapSize / (1024 * 1024)).toFixed(1)}MB /{' '}
|
1673 |
+
{(systemInfo.performance.memory.totalJSHeapSize / (1024 * 1024)).toFixed(1)}MB (
|
1674 |
+
{systemInfo.performance.memory.usagePercentage.toFixed(1)}%)
|
1675 |
+
</span>
|
1676 |
+
</div>
|
1677 |
+
<div className="text-sm flex items-center gap-2">
|
1678 |
+
<div className="i-ph:timer text-bolt-elements-textSecondary w-4 h-4" />
|
1679 |
+
<span className="text-bolt-elements-textSecondary">Page Load: </span>
|
1680 |
+
<span className="text-bolt-elements-textPrimary">
|
1681 |
+
{(systemInfo.performance.timing.loadTime / 1000).toFixed(2)}s
|
1682 |
+
</span>
|
1683 |
+
</div>
|
1684 |
+
<div className="text-sm flex items-center gap-2">
|
1685 |
+
<div className="i-ph:code text-bolt-elements-textSecondary w-4 h-4" />
|
1686 |
+
<span className="text-bolt-elements-textSecondary">DOM Ready: </span>
|
1687 |
+
<span className="text-bolt-elements-textPrimary">
|
1688 |
+
{(systemInfo.performance.timing.domReadyTime / 1000).toFixed(2)}s
|
1689 |
+
</span>
|
1690 |
+
</div>
|
1691 |
+
</div>
|
1692 |
+
</div>
|
1693 |
+
) : (
|
1694 |
+
<div className="text-sm text-bolt-elements-textSecondary">Loading system information...</div>
|
1695 |
+
)}
|
1696 |
+
</div>
|
1697 |
+
</CollapsibleContent>
|
1698 |
+
</Collapsible>
|
1699 |
+
|
1700 |
+
{/* Performance Metrics */}
|
1701 |
+
<Collapsible
|
1702 |
+
open={openSections.performance}
|
1703 |
+
onOpenChange={(open: boolean) => setOpenSections((prev) => ({ ...prev, performance: open }))}
|
1704 |
+
className="w-full"
|
1705 |
+
>
|
1706 |
+
<CollapsibleTrigger className="w-full">
|
1707 |
+
<div className="flex items-center justify-between p-6 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
|
1708 |
+
<div className="flex items-center gap-3">
|
1709 |
+
<div className="i-ph:chart-line text-purple-500 w-5 h-5" />
|
1710 |
+
<h3 className="text-base font-medium text-bolt-elements-textPrimary">Performance Metrics</h3>
|
1711 |
+
</div>
|
1712 |
+
<div
|
1713 |
+
className={classNames(
|
1714 |
+
'i-ph:caret-down w-4 h-4 transform transition-transform duration-200',
|
1715 |
+
openSections.performance ? 'rotate-180' : '',
|
1716 |
+
)}
|
1717 |
+
/>
|
1718 |
+
</div>
|
1719 |
+
</CollapsibleTrigger>
|
1720 |
+
|
1721 |
+
<CollapsibleContent>
|
1722 |
+
<div className="p-6 mt-2 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
|
1723 |
+
{systemInfo && (
|
1724 |
+
<div className="grid grid-cols-2 gap-4">
|
1725 |
+
<div className="space-y-2">
|
1726 |
+
<div className="text-sm">
|
1727 |
+
<span className="text-bolt-elements-textSecondary">Page Load Time: </span>
|
1728 |
+
<span className="text-bolt-elements-textPrimary">
|
1729 |
+
{(systemInfo.performance.timing.loadTime / 1000).toFixed(2)}s
|
1730 |
+
</span>
|
1731 |
+
</div>
|
1732 |
+
<div className="text-sm">
|
1733 |
+
<span className="text-bolt-elements-textSecondary">DOM Ready Time: </span>
|
1734 |
+
<span className="text-bolt-elements-textPrimary">
|
1735 |
+
{(systemInfo.performance.timing.domReadyTime / 1000).toFixed(2)}s
|
1736 |
+
</span>
|
1737 |
+
</div>
|
1738 |
+
<div className="text-sm">
|
1739 |
+
<span className="text-bolt-elements-textSecondary">Request Time: </span>
|
1740 |
+
<span className="text-bolt-elements-textPrimary">
|
1741 |
+
{(systemInfo.performance.timing.requestTime / 1000).toFixed(2)}s
|
1742 |
+
</span>
|
1743 |
+
</div>
|
1744 |
+
<div className="text-sm">
|
1745 |
+
<span className="text-bolt-elements-textSecondary">Redirect Time: </span>
|
1746 |
+
<span className="text-bolt-elements-textPrimary">
|
1747 |
+
{(systemInfo.performance.timing.redirectTime / 1000).toFixed(2)}s
|
1748 |
+
</span>
|
1749 |
+
</div>
|
1750 |
+
</div>
|
1751 |
+
<div className="space-y-2">
|
1752 |
+
<div className="text-sm">
|
1753 |
+
<span className="text-bolt-elements-textSecondary">JS Heap Usage: </span>
|
1754 |
+
<span className="text-bolt-elements-textPrimary">
|
1755 |
+
{(systemInfo.performance.memory.usedJSHeapSize / (1024 * 1024)).toFixed(1)}MB /{' '}
|
1756 |
+
{(systemInfo.performance.memory.totalJSHeapSize / (1024 * 1024)).toFixed(1)}MB
|
1757 |
+
</span>
|
1758 |
+
</div>
|
1759 |
+
<div className="text-sm">
|
1760 |
+
<span className="text-bolt-elements-textSecondary">Heap Utilization: </span>
|
1761 |
+
<span className="text-bolt-elements-textPrimary">
|
1762 |
+
{systemInfo.performance.memory.usagePercentage.toFixed(1)}%
|
1763 |
+
</span>
|
1764 |
+
</div>
|
1765 |
+
<div className="text-sm">
|
1766 |
+
<span className="text-bolt-elements-textSecondary">Navigation Type: </span>
|
1767 |
+
<span className="text-bolt-elements-textPrimary">
|
1768 |
+
{systemInfo.performance.navigation.type === 0
|
1769 |
+
? 'Navigate'
|
1770 |
+
: systemInfo.performance.navigation.type === 1
|
1771 |
+
? 'Reload'
|
1772 |
+
: systemInfo.performance.navigation.type === 2
|
1773 |
+
? 'Back/Forward'
|
1774 |
+
: 'Other'}
|
1775 |
+
</span>
|
1776 |
+
</div>
|
1777 |
+
<div className="text-sm">
|
1778 |
+
<span className="text-bolt-elements-textSecondary">Redirects: </span>
|
1779 |
+
<span className="text-bolt-elements-textPrimary">
|
1780 |
+
{systemInfo.performance.navigation.redirectCount}
|
1781 |
+
</span>
|
1782 |
+
</div>
|
1783 |
+
</div>
|
1784 |
+
</div>
|
1785 |
+
)}
|
1786 |
+
</div>
|
1787 |
+
</CollapsibleContent>
|
1788 |
+
</Collapsible>
|
1789 |
+
|
1790 |
+
{/* WebApp Information */}
|
1791 |
+
<Collapsible
|
1792 |
+
open={openSections.webapp}
|
1793 |
+
onOpenChange={(open) => setOpenSections((prev) => ({ ...prev, webapp: open }))}
|
1794 |
+
className="w-full"
|
1795 |
+
>
|
1796 |
+
<CollapsibleTrigger className="w-full">
|
1797 |
+
<div className="flex items-center justify-between p-6 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
|
1798 |
+
<div className="flex items-center gap-3">
|
1799 |
+
<div className="i-ph:info text-blue-500 w-5 h-5" />
|
1800 |
+
<h3 className="text-base font-medium text-bolt-elements-textPrimary">WebApp Information</h3>
|
1801 |
+
{loading.webAppInfo && <span className="loading loading-spinner loading-sm" />}
|
1802 |
+
</div>
|
1803 |
+
<div
|
1804 |
+
className={classNames(
|
1805 |
+
'i-ph:caret-down w-4 h-4 transform transition-transform duration-200',
|
1806 |
+
openSections.webapp ? 'rotate-180' : '',
|
1807 |
+
)}
|
1808 |
+
/>
|
1809 |
+
</div>
|
1810 |
+
</CollapsibleTrigger>
|
1811 |
+
|
1812 |
+
<CollapsibleContent>
|
1813 |
+
<div className="p-6 mt-2 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
|
1814 |
+
{loading.webAppInfo ? (
|
1815 |
+
<div className="flex items-center justify-center p-8">
|
1816 |
+
<span className="loading loading-spinner loading-lg" />
|
1817 |
+
</div>
|
1818 |
+
) : !webAppInfo ? (
|
1819 |
+
<div className="flex flex-col items-center justify-center p-8 text-bolt-elements-textSecondary">
|
1820 |
+
<div className="i-ph:warning-circle w-8 h-8 mb-2" />
|
1821 |
+
<p>Failed to load WebApp information</p>
|
1822 |
+
<button
|
1823 |
+
onClick={() => getWebAppInfo()}
|
1824 |
+
className="mt-4 px-4 py-2 text-sm bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
|
1825 |
+
>
|
1826 |
+
Retry
|
1827 |
+
</button>
|
1828 |
+
</div>
|
1829 |
+
) : (
|
1830 |
+
<div className="grid grid-cols-2 gap-6">
|
1831 |
+
<div>
|
1832 |
+
<h3 className="mb-4 text-base font-medium text-bolt-elements-textPrimary">Basic Information</h3>
|
1833 |
+
<div className="space-y-3">
|
1834 |
+
<div className="text-sm flex items-center gap-2">
|
1835 |
+
<div className="i-ph:app-window text-bolt-elements-textSecondary w-4 h-4" />
|
1836 |
+
<span className="text-bolt-elements-textSecondary">Name:</span>
|
1837 |
+
<span className="text-bolt-elements-textPrimary">{webAppInfo.name}</span>
|
1838 |
+
</div>
|
1839 |
+
<div className="text-sm flex items-center gap-2">
|
1840 |
+
<div className="i-ph:tag text-bolt-elements-textSecondary w-4 h-4" />
|
1841 |
+
<span className="text-bolt-elements-textSecondary">Version:</span>
|
1842 |
+
<span className="text-bolt-elements-textPrimary">{webAppInfo.version}</span>
|
1843 |
+
</div>
|
1844 |
+
<div className="text-sm flex items-center gap-2">
|
1845 |
+
<div className="i-ph:certificate text-bolt-elements-textSecondary w-4 h-4" />
|
1846 |
+
<span className="text-bolt-elements-textSecondary">License:</span>
|
1847 |
+
<span className="text-bolt-elements-textPrimary">{webAppInfo.license}</span>
|
1848 |
+
</div>
|
1849 |
+
<div className="text-sm flex items-center gap-2">
|
1850 |
+
<div className="i-ph:cloud text-bolt-elements-textSecondary w-4 h-4" />
|
1851 |
+
<span className="text-bolt-elements-textSecondary">Environment:</span>
|
1852 |
+
<span className="text-bolt-elements-textPrimary">{webAppInfo.environment}</span>
|
1853 |
+
</div>
|
1854 |
+
<div className="text-sm flex items-center gap-2">
|
1855 |
+
<div className="i-ph:node text-bolt-elements-textSecondary w-4 h-4" />
|
1856 |
+
<span className="text-bolt-elements-textSecondary">Node Version:</span>
|
1857 |
+
<span className="text-bolt-elements-textPrimary">{webAppInfo.runtimeInfo.nodeVersion}</span>
|
1858 |
+
</div>
|
1859 |
+
</div>
|
1860 |
+
</div>
|
1861 |
+
|
1862 |
+
<div>
|
1863 |
+
<h3 className="mb-4 text-base font-medium text-bolt-elements-textPrimary">Git Information</h3>
|
1864 |
+
<div className="space-y-3">
|
1865 |
+
<div className="text-sm flex items-center gap-2">
|
1866 |
+
<div className="i-ph:git-branch text-bolt-elements-textSecondary w-4 h-4" />
|
1867 |
+
<span className="text-bolt-elements-textSecondary">Branch:</span>
|
1868 |
+
<span className="text-bolt-elements-textPrimary">{webAppInfo.gitInfo.local.branch}</span>
|
1869 |
+
</div>
|
1870 |
+
<div className="text-sm flex items-center gap-2">
|
1871 |
+
<div className="i-ph:git-commit text-bolt-elements-textSecondary w-4 h-4" />
|
1872 |
+
<span className="text-bolt-elements-textSecondary">Commit:</span>
|
1873 |
+
<span className="text-bolt-elements-textPrimary">{webAppInfo.gitInfo.local.commitHash}</span>
|
1874 |
+
</div>
|
1875 |
+
<div className="text-sm flex items-center gap-2">
|
1876 |
+
<div className="i-ph:user text-bolt-elements-textSecondary w-4 h-4" />
|
1877 |
+
<span className="text-bolt-elements-textSecondary">Author:</span>
|
1878 |
+
<span className="text-bolt-elements-textPrimary">{webAppInfo.gitInfo.local.author}</span>
|
1879 |
+
</div>
|
1880 |
+
<div className="text-sm flex items-center gap-2">
|
1881 |
+
<div className="i-ph:clock text-bolt-elements-textSecondary w-4 h-4" />
|
1882 |
+
<span className="text-bolt-elements-textSecondary">Commit Time:</span>
|
1883 |
+
<span className="text-bolt-elements-textPrimary">{webAppInfo.gitInfo.local.commitTime}</span>
|
1884 |
+
</div>
|
1885 |
+
|
1886 |
+
{webAppInfo.gitInfo.github && (
|
1887 |
+
<>
|
1888 |
+
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-800">
|
1889 |
+
<div className="text-sm flex items-center gap-2">
|
1890 |
+
<div className="i-ph:git-repository text-bolt-elements-textSecondary w-4 h-4" />
|
1891 |
+
<span className="text-bolt-elements-textSecondary">Repository:</span>
|
1892 |
+
<span className="text-bolt-elements-textPrimary">
|
1893 |
+
{webAppInfo.gitInfo.github.currentRepo.fullName}
|
1894 |
+
{webAppInfo.gitInfo.isForked && ' (fork)'}
|
1895 |
+
</span>
|
1896 |
+
</div>
|
1897 |
+
|
1898 |
+
<div className="mt-2 flex items-center gap-4 text-sm">
|
1899 |
+
<div className="flex items-center gap-1">
|
1900 |
+
<div className="i-ph:star text-yellow-500 w-4 h-4" />
|
1901 |
+
<span className="text-bolt-elements-textSecondary">
|
1902 |
+
{webAppInfo.gitInfo.github.currentRepo.stars}
|
1903 |
+
</span>
|
1904 |
+
</div>
|
1905 |
+
<div className="flex items-center gap-1">
|
1906 |
+
<div className="i-ph:git-fork text-blue-500 w-4 h-4" />
|
1907 |
+
<span className="text-bolt-elements-textSecondary">
|
1908 |
+
{webAppInfo.gitInfo.github.currentRepo.forks}
|
1909 |
+
</span>
|
1910 |
+
</div>
|
1911 |
+
<div className="flex items-center gap-1">
|
1912 |
+
<div className="i-ph:warning-circle text-red-500 w-4 h-4" />
|
1913 |
+
<span className="text-bolt-elements-textSecondary">
|
1914 |
+
{webAppInfo.gitInfo.github.currentRepo.openIssues}
|
1915 |
+
</span>
|
1916 |
+
</div>
|
1917 |
+
</div>
|
1918 |
+
</div>
|
1919 |
+
|
1920 |
+
{webAppInfo.gitInfo.github.upstream && (
|
1921 |
+
<div className="mt-2">
|
1922 |
+
<div className="text-sm flex items-center gap-2">
|
1923 |
+
<div className="i-ph:git-fork text-bolt-elements-textSecondary w-4 h-4" />
|
1924 |
+
<span className="text-bolt-elements-textSecondary">Upstream:</span>
|
1925 |
+
<span className="text-bolt-elements-textPrimary">
|
1926 |
+
{webAppInfo.gitInfo.github.upstream.fullName}
|
1927 |
+
</span>
|
1928 |
+
</div>
|
1929 |
+
|
1930 |
+
<div className="mt-2 flex items-center gap-4 text-sm">
|
1931 |
+
<div className="flex items-center gap-1">
|
1932 |
+
<div className="i-ph:star text-yellow-500 w-4 h-4" />
|
1933 |
+
<span className="text-bolt-elements-textSecondary">
|
1934 |
+
{webAppInfo.gitInfo.github.upstream.stars}
|
1935 |
+
</span>
|
1936 |
+
</div>
|
1937 |
+
<div className="flex items-center gap-1">
|
1938 |
+
<div className="i-ph:git-fork text-blue-500 w-4 h-4" />
|
1939 |
+
<span className="text-bolt-elements-textSecondary">
|
1940 |
+
{webAppInfo.gitInfo.github.upstream.forks}
|
1941 |
+
</span>
|
1942 |
+
</div>
|
1943 |
+
</div>
|
1944 |
+
</div>
|
1945 |
+
)}
|
1946 |
+
</>
|
1947 |
+
)}
|
1948 |
+
</div>
|
1949 |
+
</div>
|
1950 |
+
</div>
|
1951 |
+
)}
|
1952 |
+
|
1953 |
+
{webAppInfo && (
|
1954 |
+
<div className="mt-6">
|
1955 |
+
<h3 className="mb-4 text-base font-medium text-bolt-elements-textPrimary">Dependencies</h3>
|
1956 |
+
<div className="bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A] rounded-lg divide-y divide-[#E5E5E5] dark:divide-[#1A1A1A]">
|
1957 |
+
<DependencySection title="Production" deps={webAppInfo.dependencies.production} />
|
1958 |
+
<DependencySection title="Development" deps={webAppInfo.dependencies.development} />
|
1959 |
+
<DependencySection title="Peer" deps={webAppInfo.dependencies.peer} />
|
1960 |
+
<DependencySection title="Optional" deps={webAppInfo.dependencies.optional} />
|
1961 |
+
</div>
|
1962 |
+
</div>
|
1963 |
+
)}
|
1964 |
+
</div>
|
1965 |
+
</CollapsibleContent>
|
1966 |
+
</Collapsible>
|
1967 |
+
|
1968 |
+
{/* Error Check */}
|
1969 |
+
<Collapsible
|
1970 |
+
open={openSections.errors}
|
1971 |
+
onOpenChange={(open) => setOpenSections((prev) => ({ ...prev, errors: open }))}
|
1972 |
+
className="w-full"
|
1973 |
+
>
|
1974 |
+
<CollapsibleTrigger className="w-full">
|
1975 |
+
<div className="flex items-center justify-between p-6 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
|
1976 |
+
<div className="flex items-center gap-3">
|
1977 |
+
<div className="i-ph:warning text-red-500 w-5 h-5" />
|
1978 |
+
<h3 className="text-base font-medium text-bolt-elements-textPrimary">Error Check</h3>
|
1979 |
+
{errorLogs.length > 0 && (
|
1980 |
+
<Badge variant="destructive" className="ml-2">
|
1981 |
+
{errorLogs.length} Errors
|
1982 |
+
</Badge>
|
1983 |
+
)}
|
1984 |
+
</div>
|
1985 |
+
<div
|
1986 |
+
className={classNames(
|
1987 |
+
'i-ph:caret-down w-4 h-4 transform transition-transform duration-200',
|
1988 |
+
openSections.errors ? 'rotate-180' : '',
|
1989 |
+
)}
|
1990 |
+
/>
|
1991 |
+
</div>
|
1992 |
+
</CollapsibleTrigger>
|
1993 |
+
|
1994 |
+
<CollapsibleContent>
|
1995 |
+
<div className="p-6 mt-2 rounded-xl bg-white dark:bg-[#0A0A0A] border border-[#E5E5E5] dark:border-[#1A1A1A]">
|
1996 |
+
<ScrollArea className="h-[300px]">
|
1997 |
+
<div className="space-y-4">
|
1998 |
+
<div className="text-sm text-bolt-elements-textSecondary">
|
1999 |
+
Checks for:
|
2000 |
+
<ul className="list-disc list-inside mt-2 space-y-1">
|
2001 |
+
<li>Unhandled JavaScript errors</li>
|
2002 |
+
<li>Unhandled Promise rejections</li>
|
2003 |
+
<li>Runtime exceptions</li>
|
2004 |
+
<li>Network errors</li>
|
2005 |
+
</ul>
|
2006 |
+
</div>
|
2007 |
+
<div className="text-sm">
|
2008 |
+
<span className="text-bolt-elements-textSecondary">Status: </span>
|
2009 |
+
<span className="text-bolt-elements-textPrimary">
|
2010 |
+
{loading.errors
|
2011 |
+
? 'Checking...'
|
2012 |
+
: errorLogs.length > 0
|
2013 |
+
? `${errorLogs.length} errors found`
|
2014 |
+
: 'No errors found'}
|
2015 |
+
</span>
|
2016 |
+
</div>
|
2017 |
+
{errorLogs.length > 0 && (
|
2018 |
+
<div className="mt-4">
|
2019 |
+
<div className="text-sm font-medium text-bolt-elements-textPrimary mb-2">Recent Errors:</div>
|
2020 |
+
<div className="space-y-2">
|
2021 |
+
{errorLogs.map((error) => (
|
2022 |
+
<div key={error.id} className="text-sm text-red-500 dark:text-red-400 p-2 rounded bg-red-500/5">
|
2023 |
+
<div className="font-medium">{error.message}</div>
|
2024 |
+
{error.source && (
|
2025 |
+
<div className="text-xs mt-1 text-red-400">
|
2026 |
+
Source: {error.source}
|
2027 |
+
{error.details?.lineNumber && `:${error.details.lineNumber}`}
|
2028 |
+
</div>
|
2029 |
+
)}
|
2030 |
+
{error.stack && (
|
2031 |
+
<div className="text-xs mt-1 text-red-400 font-mono whitespace-pre-wrap">{error.stack}</div>
|
2032 |
+
)}
|
2033 |
+
</div>
|
2034 |
+
))}
|
2035 |
+
</div>
|
2036 |
+
</div>
|
2037 |
+
)}
|
2038 |
+
</div>
|
2039 |
+
</ScrollArea>
|
2040 |
+
</div>
|
2041 |
+
</CollapsibleContent>
|
2042 |
+
</Collapsible>
|
2043 |
+
</div>
|
2044 |
+
);
|
2045 |
+
}
|
DraggableTabList.tsx
ADDED
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useDrag, useDrop } from 'react-dnd';
|
2 |
+
import { motion } from 'framer-motion';
|
3 |
+
import { classNames } from '~/utils/classNames';
|
4 |
+
import type { TabVisibilityConfig } from '~/components/@settings/core/types';
|
5 |
+
import { TAB_LABELS } from '~/components/@settings/core/types';
|
6 |
+
import { Switch } from '~/components/ui/Switch';
|
7 |
+
|
8 |
+
interface DraggableTabListProps {
|
9 |
+
tabs: TabVisibilityConfig[];
|
10 |
+
onReorder: (tabs: TabVisibilityConfig[]) => void;
|
11 |
+
onWindowChange?: (tab: TabVisibilityConfig, window: 'user' | 'developer') => void;
|
12 |
+
onVisibilityChange?: (tab: TabVisibilityConfig, visible: boolean) => void;
|
13 |
+
showControls?: boolean;
|
14 |
+
}
|
15 |
+
|
16 |
+
interface DraggableTabItemProps {
|
17 |
+
tab: TabVisibilityConfig;
|
18 |
+
index: number;
|
19 |
+
moveTab: (dragIndex: number, hoverIndex: number) => void;
|
20 |
+
showControls?: boolean;
|
21 |
+
onWindowChange?: (tab: TabVisibilityConfig, window: 'user' | 'developer') => void;
|
22 |
+
onVisibilityChange?: (tab: TabVisibilityConfig, visible: boolean) => void;
|
23 |
+
}
|
24 |
+
|
25 |
+
interface DragItem {
|
26 |
+
type: string;
|
27 |
+
index: number;
|
28 |
+
id: string;
|
29 |
+
}
|
30 |
+
|
31 |
+
const DraggableTabItem = ({
|
32 |
+
tab,
|
33 |
+
index,
|
34 |
+
moveTab,
|
35 |
+
showControls,
|
36 |
+
onWindowChange,
|
37 |
+
onVisibilityChange,
|
38 |
+
}: DraggableTabItemProps) => {
|
39 |
+
const [{ isDragging }, dragRef] = useDrag({
|
40 |
+
type: 'tab',
|
41 |
+
item: { type: 'tab', index, id: tab.id },
|
42 |
+
collect: (monitor) => ({
|
43 |
+
isDragging: monitor.isDragging(),
|
44 |
+
}),
|
45 |
+
});
|
46 |
+
|
47 |
+
const [, dropRef] = useDrop({
|
48 |
+
accept: 'tab',
|
49 |
+
hover: (item: DragItem, monitor) => {
|
50 |
+
if (!monitor.isOver({ shallow: true })) {
|
51 |
+
return;
|
52 |
+
}
|
53 |
+
|
54 |
+
if (item.index === index) {
|
55 |
+
return;
|
56 |
+
}
|
57 |
+
|
58 |
+
if (item.id === tab.id) {
|
59 |
+
return;
|
60 |
+
}
|
61 |
+
|
62 |
+
moveTab(item.index, index);
|
63 |
+
item.index = index;
|
64 |
+
},
|
65 |
+
});
|
66 |
+
|
67 |
+
const ref = (node: HTMLDivElement | null) => {
|
68 |
+
dragRef(node);
|
69 |
+
dropRef(node);
|
70 |
+
};
|
71 |
+
|
72 |
+
return (
|
73 |
+
<motion.div
|
74 |
+
ref={ref}
|
75 |
+
initial={false}
|
76 |
+
animate={{
|
77 |
+
scale: isDragging ? 1.02 : 1,
|
78 |
+
boxShadow: isDragging ? '0 8px 16px rgba(0,0,0,0.1)' : 'none',
|
79 |
+
}}
|
80 |
+
className={classNames(
|
81 |
+
'flex items-center justify-between p-4 rounded-lg',
|
82 |
+
'bg-[#F5F5F5] dark:bg-[#1A1A1A]',
|
83 |
+
'border border-[#E5E5E5] dark:border-[#333333]',
|
84 |
+
isDragging ? 'z-50' : '',
|
85 |
+
)}
|
86 |
+
>
|
87 |
+
<div className="flex items-center gap-4">
|
88 |
+
<div className="cursor-grab">
|
89 |
+
<div className="i-ph:dots-six-vertical w-4 h-4 text-bolt-elements-textSecondary" />
|
90 |
+
</div>
|
91 |
+
<div>
|
92 |
+
<div className="font-medium text-bolt-elements-textPrimary">{TAB_LABELS[tab.id]}</div>
|
93 |
+
{showControls && (
|
94 |
+
<div className="text-xs text-bolt-elements-textSecondary">
|
95 |
+
Order: {tab.order}, Window: {tab.window}
|
96 |
+
</div>
|
97 |
+
)}
|
98 |
+
</div>
|
99 |
+
</div>
|
100 |
+
{showControls && !tab.locked && (
|
101 |
+
<div className="flex items-center gap-4">
|
102 |
+
<div className="flex items-center gap-2">
|
103 |
+
<Switch
|
104 |
+
checked={tab.visible}
|
105 |
+
onCheckedChange={(checked: boolean) => onVisibilityChange?.(tab, checked)}
|
106 |
+
className="data-[state=checked]:bg-purple-500"
|
107 |
+
aria-label={`Toggle ${TAB_LABELS[tab.id]} visibility`}
|
108 |
+
/>
|
109 |
+
<label className="text-sm text-bolt-elements-textSecondary">Visible</label>
|
110 |
+
</div>
|
111 |
+
<div className="flex items-center gap-2">
|
112 |
+
<label className="text-sm text-bolt-elements-textSecondary">User</label>
|
113 |
+
<Switch
|
114 |
+
checked={tab.window === 'developer'}
|
115 |
+
onCheckedChange={(checked: boolean) => onWindowChange?.(tab, checked ? 'developer' : 'user')}
|
116 |
+
className="data-[state=checked]:bg-purple-500"
|
117 |
+
aria-label={`Toggle ${TAB_LABELS[tab.id]} window assignment`}
|
118 |
+
/>
|
119 |
+
<label className="text-sm text-bolt-elements-textSecondary">Dev</label>
|
120 |
+
</div>
|
121 |
+
</div>
|
122 |
+
)}
|
123 |
+
</motion.div>
|
124 |
+
);
|
125 |
+
};
|
126 |
+
|
127 |
+
export const DraggableTabList = ({
|
128 |
+
tabs,
|
129 |
+
onReorder,
|
130 |
+
onWindowChange,
|
131 |
+
onVisibilityChange,
|
132 |
+
showControls = false,
|
133 |
+
}: DraggableTabListProps) => {
|
134 |
+
const moveTab = (dragIndex: number, hoverIndex: number) => {
|
135 |
+
const items = Array.from(tabs);
|
136 |
+
const [reorderedItem] = items.splice(dragIndex, 1);
|
137 |
+
items.splice(hoverIndex, 0, reorderedItem);
|
138 |
+
|
139 |
+
// Update order numbers based on position
|
140 |
+
const reorderedTabs = items.map((tab, index) => ({
|
141 |
+
...tab,
|
142 |
+
order: index + 1,
|
143 |
+
}));
|
144 |
+
|
145 |
+
onReorder(reorderedTabs);
|
146 |
+
};
|
147 |
+
|
148 |
+
return (
|
149 |
+
<div className="space-y-2">
|
150 |
+
{tabs.map((tab, index) => (
|
151 |
+
<DraggableTabItem
|
152 |
+
key={tab.id}
|
153 |
+
tab={tab}
|
154 |
+
index={index}
|
155 |
+
moveTab={moveTab}
|
156 |
+
showControls={showControls}
|
157 |
+
onWindowChange={onWindowChange}
|
158 |
+
onVisibilityChange={onVisibilityChange}
|
159 |
+
/>
|
160 |
+
))}
|
161 |
+
</div>
|
162 |
+
);
|
163 |
+
};
|
EventLogsTab.tsx
ADDED
@@ -0,0 +1,1013 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
2 |
+
import { motion } from 'framer-motion';
|
3 |
+
import { Switch } from '~/components/ui/Switch';
|
4 |
+
import { logStore, type LogEntry } from '~/lib/stores/logs';
|
5 |
+
import { useStore } from '@nanostores/react';
|
6 |
+
import { classNames } from '~/utils/classNames';
|
7 |
+
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
8 |
+
import { Dialog, DialogRoot, DialogTitle } from '~/components/ui/Dialog';
|
9 |
+
import { jsPDF } from 'jspdf';
|
10 |
+
import { toast } from 'react-toastify';
|
11 |
+
|
12 |
+
interface SelectOption {
|
13 |
+
value: string;
|
14 |
+
label: string;
|
15 |
+
icon?: string;
|
16 |
+
color?: string;
|
17 |
+
}
|
18 |
+
|
19 |
+
const logLevelOptions: SelectOption[] = [
|
20 |
+
{
|
21 |
+
value: 'all',
|
22 |
+
label: 'All Types',
|
23 |
+
icon: 'i-ph:funnel',
|
24 |
+
color: '#9333ea',
|
25 |
+
},
|
26 |
+
{
|
27 |
+
value: 'provider',
|
28 |
+
label: 'LLM',
|
29 |
+
icon: 'i-ph:robot',
|
30 |
+
color: '#10b981',
|
31 |
+
},
|
32 |
+
{
|
33 |
+
value: 'api',
|
34 |
+
label: 'API',
|
35 |
+
icon: 'i-ph:cloud',
|
36 |
+
color: '#3b82f6',
|
37 |
+
},
|
38 |
+
{
|
39 |
+
value: 'error',
|
40 |
+
label: 'Errors',
|
41 |
+
icon: 'i-ph:warning-circle',
|
42 |
+
color: '#ef4444',
|
43 |
+
},
|
44 |
+
{
|
45 |
+
value: 'warning',
|
46 |
+
label: 'Warnings',
|
47 |
+
icon: 'i-ph:warning',
|
48 |
+
color: '#f59e0b',
|
49 |
+
},
|
50 |
+
{
|
51 |
+
value: 'info',
|
52 |
+
label: 'Info',
|
53 |
+
icon: 'i-ph:info',
|
54 |
+
color: '#3b82f6',
|
55 |
+
},
|
56 |
+
{
|
57 |
+
value: 'debug',
|
58 |
+
label: 'Debug',
|
59 |
+
icon: 'i-ph:bug',
|
60 |
+
color: '#6b7280',
|
61 |
+
},
|
62 |
+
];
|
63 |
+
|
64 |
+
interface LogEntryItemProps {
|
65 |
+
log: LogEntry;
|
66 |
+
isExpanded: boolean;
|
67 |
+
use24Hour: boolean;
|
68 |
+
showTimestamp: boolean;
|
69 |
+
}
|
70 |
+
|
71 |
+
const LogEntryItem = ({ log, isExpanded: forceExpanded, use24Hour, showTimestamp }: LogEntryItemProps) => {
|
72 |
+
const [localExpanded, setLocalExpanded] = useState(forceExpanded);
|
73 |
+
|
74 |
+
useEffect(() => {
|
75 |
+
setLocalExpanded(forceExpanded);
|
76 |
+
}, [forceExpanded]);
|
77 |
+
|
78 |
+
const timestamp = useMemo(() => {
|
79 |
+
const date = new Date(log.timestamp);
|
80 |
+
return date.toLocaleTimeString('en-US', { hour12: !use24Hour });
|
81 |
+
}, [log.timestamp, use24Hour]);
|
82 |
+
|
83 |
+
const style = useMemo(() => {
|
84 |
+
if (log.category === 'provider') {
|
85 |
+
return {
|
86 |
+
icon: 'i-ph:robot',
|
87 |
+
color: 'text-emerald-500 dark:text-emerald-400',
|
88 |
+
bg: 'hover:bg-emerald-500/10 dark:hover:bg-emerald-500/20',
|
89 |
+
badge: 'text-emerald-500 bg-emerald-50 dark:bg-emerald-500/10',
|
90 |
+
};
|
91 |
+
}
|
92 |
+
|
93 |
+
if (log.category === 'api') {
|
94 |
+
return {
|
95 |
+
icon: 'i-ph:cloud',
|
96 |
+
color: 'text-blue-500 dark:text-blue-400',
|
97 |
+
bg: 'hover:bg-blue-500/10 dark:hover:bg-blue-500/20',
|
98 |
+
badge: 'text-blue-500 bg-blue-50 dark:bg-blue-500/10',
|
99 |
+
};
|
100 |
+
}
|
101 |
+
|
102 |
+
switch (log.level) {
|
103 |
+
case 'error':
|
104 |
+
return {
|
105 |
+
icon: 'i-ph:warning-circle',
|
106 |
+
color: 'text-red-500 dark:text-red-400',
|
107 |
+
bg: 'hover:bg-red-500/10 dark:hover:bg-red-500/20',
|
108 |
+
badge: 'text-red-500 bg-red-50 dark:bg-red-500/10',
|
109 |
+
};
|
110 |
+
case 'warning':
|
111 |
+
return {
|
112 |
+
icon: 'i-ph:warning',
|
113 |
+
color: 'text-yellow-500 dark:text-yellow-400',
|
114 |
+
bg: 'hover:bg-yellow-500/10 dark:hover:bg-yellow-500/20',
|
115 |
+
badge: 'text-yellow-500 bg-yellow-50 dark:bg-yellow-500/10',
|
116 |
+
};
|
117 |
+
case 'debug':
|
118 |
+
return {
|
119 |
+
icon: 'i-ph:bug',
|
120 |
+
color: 'text-gray-500 dark:text-gray-400',
|
121 |
+
bg: 'hover:bg-gray-500/10 dark:hover:bg-gray-500/20',
|
122 |
+
badge: 'text-gray-500 bg-gray-50 dark:bg-gray-500/10',
|
123 |
+
};
|
124 |
+
default:
|
125 |
+
return {
|
126 |
+
icon: 'i-ph:info',
|
127 |
+
color: 'text-blue-500 dark:text-blue-400',
|
128 |
+
bg: 'hover:bg-blue-500/10 dark:hover:bg-blue-500/20',
|
129 |
+
badge: 'text-blue-500 bg-blue-50 dark:bg-blue-500/10',
|
130 |
+
};
|
131 |
+
}
|
132 |
+
}, [log.level, log.category]);
|
133 |
+
|
134 |
+
const renderDetails = (details: any) => {
|
135 |
+
if (log.category === 'provider') {
|
136 |
+
return (
|
137 |
+
<div className="flex flex-col gap-2">
|
138 |
+
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
139 |
+
<span>Model: {details.model}</span>
|
140 |
+
<span>•</span>
|
141 |
+
<span>Tokens: {details.totalTokens}</span>
|
142 |
+
<span>•</span>
|
143 |
+
<span>Duration: {details.duration}ms</span>
|
144 |
+
</div>
|
145 |
+
{details.prompt && (
|
146 |
+
<div className="flex flex-col gap-1">
|
147 |
+
<div className="text-xs font-medium text-gray-700 dark:text-gray-300">Prompt:</div>
|
148 |
+
<pre className="text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded p-2 whitespace-pre-wrap">
|
149 |
+
{details.prompt}
|
150 |
+
</pre>
|
151 |
+
</div>
|
152 |
+
)}
|
153 |
+
{details.response && (
|
154 |
+
<div className="flex flex-col gap-1">
|
155 |
+
<div className="text-xs font-medium text-gray-700 dark:text-gray-300">Response:</div>
|
156 |
+
<pre className="text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded p-2 whitespace-pre-wrap">
|
157 |
+
{details.response}
|
158 |
+
</pre>
|
159 |
+
</div>
|
160 |
+
)}
|
161 |
+
</div>
|
162 |
+
);
|
163 |
+
}
|
164 |
+
|
165 |
+
if (log.category === 'api') {
|
166 |
+
return (
|
167 |
+
<div className="flex flex-col gap-2">
|
168 |
+
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
169 |
+
<span className={details.method === 'GET' ? 'text-green-500' : 'text-blue-500'}>{details.method}</span>
|
170 |
+
<span>•</span>
|
171 |
+
<span>Status: {details.statusCode}</span>
|
172 |
+
<span>•</span>
|
173 |
+
<span>Duration: {details.duration}ms</span>
|
174 |
+
</div>
|
175 |
+
<div className="text-xs text-gray-600 dark:text-gray-400 break-all">{details.url}</div>
|
176 |
+
{details.request && (
|
177 |
+
<div className="flex flex-col gap-1">
|
178 |
+
<div className="text-xs font-medium text-gray-700 dark:text-gray-300">Request:</div>
|
179 |
+
<pre className="text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded p-2 whitespace-pre-wrap">
|
180 |
+
{JSON.stringify(details.request, null, 2)}
|
181 |
+
</pre>
|
182 |
+
</div>
|
183 |
+
)}
|
184 |
+
{details.response && (
|
185 |
+
<div className="flex flex-col gap-1">
|
186 |
+
<div className="text-xs font-medium text-gray-700 dark:text-gray-300">Response:</div>
|
187 |
+
<pre className="text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded p-2 whitespace-pre-wrap">
|
188 |
+
{JSON.stringify(details.response, null, 2)}
|
189 |
+
</pre>
|
190 |
+
</div>
|
191 |
+
)}
|
192 |
+
{details.error && (
|
193 |
+
<div className="flex flex-col gap-1">
|
194 |
+
<div className="text-xs font-medium text-red-500">Error:</div>
|
195 |
+
<pre className="text-xs text-red-400 bg-red-50 dark:bg-red-500/10 rounded p-2 whitespace-pre-wrap">
|
196 |
+
{JSON.stringify(details.error, null, 2)}
|
197 |
+
</pre>
|
198 |
+
</div>
|
199 |
+
)}
|
200 |
+
</div>
|
201 |
+
);
|
202 |
+
}
|
203 |
+
|
204 |
+
return (
|
205 |
+
<pre className="text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded whitespace-pre-wrap">
|
206 |
+
{JSON.stringify(details, null, 2)}
|
207 |
+
</pre>
|
208 |
+
);
|
209 |
+
};
|
210 |
+
|
211 |
+
return (
|
212 |
+
<motion.div
|
213 |
+
initial={{ opacity: 0, y: 20 }}
|
214 |
+
animate={{ opacity: 1, y: 0 }}
|
215 |
+
className={classNames(
|
216 |
+
'flex flex-col gap-2',
|
217 |
+
'rounded-lg p-4',
|
218 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
219 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
220 |
+
style.bg,
|
221 |
+
'transition-all duration-200',
|
222 |
+
)}
|
223 |
+
>
|
224 |
+
<div className="flex items-start justify-between gap-4">
|
225 |
+
<div className="flex items-start gap-3">
|
226 |
+
<span className={classNames('text-lg', style.icon, style.color)} />
|
227 |
+
<div className="flex flex-col gap-1">
|
228 |
+
<div className="text-sm font-medium text-gray-900 dark:text-white">{log.message}</div>
|
229 |
+
{log.details && (
|
230 |
+
<>
|
231 |
+
<button
|
232 |
+
onClick={() => setLocalExpanded(!localExpanded)}
|
233 |
+
className="text-xs text-gray-500 dark:text-gray-400 hover:text-purple-500 dark:hover:text-purple-400 transition-colors"
|
234 |
+
>
|
235 |
+
{localExpanded ? 'Hide' : 'Show'} Details
|
236 |
+
</button>
|
237 |
+
{localExpanded && renderDetails(log.details)}
|
238 |
+
</>
|
239 |
+
)}
|
240 |
+
<div className="flex items-center gap-2">
|
241 |
+
<div className={classNames('px-2 py-0.5 rounded text-xs font-medium uppercase', style.badge)}>
|
242 |
+
{log.level}
|
243 |
+
</div>
|
244 |
+
{log.category && (
|
245 |
+
<div className="px-2 py-0.5 rounded-full text-xs bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400">
|
246 |
+
{log.category}
|
247 |
+
</div>
|
248 |
+
)}
|
249 |
+
</div>
|
250 |
+
</div>
|
251 |
+
</div>
|
252 |
+
{showTimestamp && <time className="shrink-0 text-xs text-gray-500 dark:text-gray-400">{timestamp}</time>}
|
253 |
+
</div>
|
254 |
+
</motion.div>
|
255 |
+
);
|
256 |
+
};
|
257 |
+
|
258 |
+
interface ExportFormat {
|
259 |
+
id: string;
|
260 |
+
label: string;
|
261 |
+
icon: string;
|
262 |
+
handler: () => void;
|
263 |
+
}
|
264 |
+
|
265 |
+
export function EventLogsTab() {
|
266 |
+
const logs = useStore(logStore.logs);
|
267 |
+
const [selectedLevel, setSelectedLevel] = useState<'all' | string>('all');
|
268 |
+
const [searchQuery, setSearchQuery] = useState('');
|
269 |
+
const [use24Hour, setUse24Hour] = useState(false);
|
270 |
+
const [autoExpand, setAutoExpand] = useState(false);
|
271 |
+
const [showTimestamps, setShowTimestamps] = useState(true);
|
272 |
+
const [showLevelFilter, setShowLevelFilter] = useState(false);
|
273 |
+
const [isRefreshing, setIsRefreshing] = useState(false);
|
274 |
+
const levelFilterRef = useRef<HTMLDivElement>(null);
|
275 |
+
|
276 |
+
const filteredLogs = useMemo(() => {
|
277 |
+
const allLogs = Object.values(logs);
|
278 |
+
|
279 |
+
if (selectedLevel === 'all') {
|
280 |
+
return allLogs.filter((log) =>
|
281 |
+
searchQuery ? log.message.toLowerCase().includes(searchQuery.toLowerCase()) : true,
|
282 |
+
);
|
283 |
+
}
|
284 |
+
|
285 |
+
return allLogs.filter((log) => {
|
286 |
+
const matchesType = log.category === selectedLevel || log.level === selectedLevel;
|
287 |
+
const matchesSearch = searchQuery ? log.message.toLowerCase().includes(searchQuery.toLowerCase()) : true;
|
288 |
+
|
289 |
+
return matchesType && matchesSearch;
|
290 |
+
});
|
291 |
+
}, [logs, selectedLevel, searchQuery]);
|
292 |
+
|
293 |
+
// Add performance tracking on mount
|
294 |
+
useEffect(() => {
|
295 |
+
const startTime = performance.now();
|
296 |
+
|
297 |
+
logStore.logInfo('Event Logs tab mounted', {
|
298 |
+
type: 'component_mount',
|
299 |
+
message: 'Event Logs tab component mounted',
|
300 |
+
component: 'EventLogsTab',
|
301 |
+
});
|
302 |
+
|
303 |
+
return () => {
|
304 |
+
const duration = performance.now() - startTime;
|
305 |
+
logStore.logPerformanceMetric('EventLogsTab', 'mount-duration', duration);
|
306 |
+
};
|
307 |
+
}, []);
|
308 |
+
|
309 |
+
// Log filter changes
|
310 |
+
const handleLevelFilterChange = useCallback(
|
311 |
+
(newLevel: string) => {
|
312 |
+
logStore.logInfo('Log level filter changed', {
|
313 |
+
type: 'filter_change',
|
314 |
+
message: `Log level filter changed from ${selectedLevel} to ${newLevel}`,
|
315 |
+
component: 'EventLogsTab',
|
316 |
+
previousLevel: selectedLevel,
|
317 |
+
newLevel,
|
318 |
+
});
|
319 |
+
setSelectedLevel(newLevel as string);
|
320 |
+
setShowLevelFilter(false);
|
321 |
+
},
|
322 |
+
[selectedLevel],
|
323 |
+
);
|
324 |
+
|
325 |
+
// Log search changes with debounce
|
326 |
+
useEffect(() => {
|
327 |
+
const timeoutId = setTimeout(() => {
|
328 |
+
if (searchQuery) {
|
329 |
+
logStore.logInfo('Log search performed', {
|
330 |
+
type: 'search',
|
331 |
+
message: `Search performed with query "${searchQuery}" (${filteredLogs.length} results)`,
|
332 |
+
component: 'EventLogsTab',
|
333 |
+
query: searchQuery,
|
334 |
+
resultsCount: filteredLogs.length,
|
335 |
+
});
|
336 |
+
}
|
337 |
+
}, 1000);
|
338 |
+
|
339 |
+
return () => clearTimeout(timeoutId);
|
340 |
+
}, [searchQuery, filteredLogs.length]);
|
341 |
+
|
342 |
+
// Enhanced refresh handler
|
343 |
+
const handleRefresh = useCallback(async () => {
|
344 |
+
const startTime = performance.now();
|
345 |
+
setIsRefreshing(true);
|
346 |
+
|
347 |
+
try {
|
348 |
+
await logStore.refreshLogs();
|
349 |
+
|
350 |
+
const duration = performance.now() - startTime;
|
351 |
+
|
352 |
+
logStore.logSuccess('Logs refreshed successfully', {
|
353 |
+
type: 'refresh',
|
354 |
+
message: `Successfully refreshed ${Object.keys(logs).length} logs`,
|
355 |
+
component: 'EventLogsTab',
|
356 |
+
duration,
|
357 |
+
logsCount: Object.keys(logs).length,
|
358 |
+
});
|
359 |
+
} catch (error) {
|
360 |
+
logStore.logError('Failed to refresh logs', error, {
|
361 |
+
type: 'refresh_error',
|
362 |
+
message: 'Failed to refresh logs',
|
363 |
+
component: 'EventLogsTab',
|
364 |
+
});
|
365 |
+
} finally {
|
366 |
+
setTimeout(() => setIsRefreshing(false), 500);
|
367 |
+
}
|
368 |
+
}, [logs]);
|
369 |
+
|
370 |
+
// Log preference changes
|
371 |
+
const handlePreferenceChange = useCallback((type: string, value: boolean) => {
|
372 |
+
logStore.logInfo('Log preference changed', {
|
373 |
+
type: 'preference_change',
|
374 |
+
message: `Log preference "${type}" changed to ${value}`,
|
375 |
+
component: 'EventLogsTab',
|
376 |
+
preference: type,
|
377 |
+
value,
|
378 |
+
});
|
379 |
+
|
380 |
+
switch (type) {
|
381 |
+
case 'timestamps':
|
382 |
+
setShowTimestamps(value);
|
383 |
+
break;
|
384 |
+
case '24hour':
|
385 |
+
setUse24Hour(value);
|
386 |
+
break;
|
387 |
+
case 'autoExpand':
|
388 |
+
setAutoExpand(value);
|
389 |
+
break;
|
390 |
+
}
|
391 |
+
}, []);
|
392 |
+
|
393 |
+
// Close filters when clicking outside
|
394 |
+
useEffect(() => {
|
395 |
+
const handleClickOutside = (event: MouseEvent) => {
|
396 |
+
if (levelFilterRef.current && !levelFilterRef.current.contains(event.target as Node)) {
|
397 |
+
setShowLevelFilter(false);
|
398 |
+
}
|
399 |
+
};
|
400 |
+
|
401 |
+
document.addEventListener('mousedown', handleClickOutside);
|
402 |
+
|
403 |
+
return () => {
|
404 |
+
document.removeEventListener('mousedown', handleClickOutside);
|
405 |
+
};
|
406 |
+
}, []);
|
407 |
+
|
408 |
+
const selectedLevelOption = logLevelOptions.find((opt) => opt.value === selectedLevel);
|
409 |
+
|
410 |
+
// Export functions
|
411 |
+
const exportAsJSON = () => {
|
412 |
+
try {
|
413 |
+
const exportData = {
|
414 |
+
timestamp: new Date().toISOString(),
|
415 |
+
logs: filteredLogs,
|
416 |
+
filters: {
|
417 |
+
level: selectedLevel,
|
418 |
+
searchQuery,
|
419 |
+
},
|
420 |
+
preferences: {
|
421 |
+
use24Hour,
|
422 |
+
showTimestamps,
|
423 |
+
autoExpand,
|
424 |
+
},
|
425 |
+
};
|
426 |
+
|
427 |
+
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
428 |
+
const url = window.URL.createObjectURL(blob);
|
429 |
+
const a = document.createElement('a');
|
430 |
+
a.href = url;
|
431 |
+
a.download = `bolt-event-logs-${new Date().toISOString()}.json`;
|
432 |
+
document.body.appendChild(a);
|
433 |
+
a.click();
|
434 |
+
window.URL.revokeObjectURL(url);
|
435 |
+
document.body.removeChild(a);
|
436 |
+
toast.success('Event logs exported successfully as JSON');
|
437 |
+
} catch (error) {
|
438 |
+
console.error('Failed to export JSON:', error);
|
439 |
+
toast.error('Failed to export event logs as JSON');
|
440 |
+
}
|
441 |
+
};
|
442 |
+
|
443 |
+
const exportAsCSV = () => {
|
444 |
+
try {
|
445 |
+
// Convert logs to CSV format
|
446 |
+
const headers = ['Timestamp', 'Level', 'Category', 'Message', 'Details'];
|
447 |
+
const csvData = [
|
448 |
+
headers,
|
449 |
+
...filteredLogs.map((log) => [
|
450 |
+
new Date(log.timestamp).toISOString(),
|
451 |
+
log.level,
|
452 |
+
log.category || '',
|
453 |
+
log.message,
|
454 |
+
log.details ? JSON.stringify(log.details) : '',
|
455 |
+
]),
|
456 |
+
];
|
457 |
+
|
458 |
+
const csvContent = csvData
|
459 |
+
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
|
460 |
+
.join('\n');
|
461 |
+
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
462 |
+
const url = window.URL.createObjectURL(blob);
|
463 |
+
const a = document.createElement('a');
|
464 |
+
a.href = url;
|
465 |
+
a.download = `bolt-event-logs-${new Date().toISOString()}.csv`;
|
466 |
+
document.body.appendChild(a);
|
467 |
+
a.click();
|
468 |
+
window.URL.revokeObjectURL(url);
|
469 |
+
document.body.removeChild(a);
|
470 |
+
toast.success('Event logs exported successfully as CSV');
|
471 |
+
} catch (error) {
|
472 |
+
console.error('Failed to export CSV:', error);
|
473 |
+
toast.error('Failed to export event logs as CSV');
|
474 |
+
}
|
475 |
+
};
|
476 |
+
|
477 |
+
const exportAsPDF = () => {
|
478 |
+
try {
|
479 |
+
// Create new PDF document
|
480 |
+
const doc = new jsPDF();
|
481 |
+
const lineHeight = 7;
|
482 |
+
let yPos = 20;
|
483 |
+
const margin = 20;
|
484 |
+
const pageWidth = doc.internal.pageSize.getWidth();
|
485 |
+
const maxLineWidth = pageWidth - 2 * margin;
|
486 |
+
|
487 |
+
// Helper function to add section header
|
488 |
+
const addSectionHeader = (title: string) => {
|
489 |
+
// Check if we need a new page
|
490 |
+
if (yPos > doc.internal.pageSize.getHeight() - 30) {
|
491 |
+
doc.addPage();
|
492 |
+
yPos = margin;
|
493 |
+
}
|
494 |
+
|
495 |
+
doc.setFillColor('#F3F4F6');
|
496 |
+
doc.rect(margin - 2, yPos - 5, pageWidth - 2 * (margin - 2), lineHeight + 6, 'F');
|
497 |
+
doc.setFont('helvetica', 'bold');
|
498 |
+
doc.setTextColor('#111827');
|
499 |
+
doc.setFontSize(12);
|
500 |
+
doc.text(title.toUpperCase(), margin, yPos);
|
501 |
+
yPos += lineHeight * 2;
|
502 |
+
};
|
503 |
+
|
504 |
+
// Add title and header
|
505 |
+
doc.setFillColor('#6366F1');
|
506 |
+
doc.rect(0, 0, pageWidth, 50, 'F');
|
507 |
+
doc.setTextColor('#FFFFFF');
|
508 |
+
doc.setFontSize(24);
|
509 |
+
doc.setFont('helvetica', 'bold');
|
510 |
+
doc.text('Event Logs Report', margin, 35);
|
511 |
+
|
512 |
+
// Add subtitle with bolt.diy
|
513 |
+
doc.setFontSize(12);
|
514 |
+
doc.setFont('helvetica', 'normal');
|
515 |
+
doc.text('bolt.diy - AI Development Platform', margin, 45);
|
516 |
+
yPos = 70;
|
517 |
+
|
518 |
+
// Add report summary section
|
519 |
+
addSectionHeader('Report Summary');
|
520 |
+
|
521 |
+
doc.setFontSize(10);
|
522 |
+
doc.setFont('helvetica', 'normal');
|
523 |
+
doc.setTextColor('#374151');
|
524 |
+
|
525 |
+
const summaryItems = [
|
526 |
+
{ label: 'Generated', value: new Date().toLocaleString() },
|
527 |
+
{ label: 'Total Logs', value: filteredLogs.length.toString() },
|
528 |
+
{ label: 'Filter Applied', value: selectedLevel === 'all' ? 'All Types' : selectedLevel },
|
529 |
+
{ label: 'Search Query', value: searchQuery || 'None' },
|
530 |
+
{ label: 'Time Format', value: use24Hour ? '24-hour' : '12-hour' },
|
531 |
+
];
|
532 |
+
|
533 |
+
summaryItems.forEach((item) => {
|
534 |
+
doc.setFont('helvetica', 'bold');
|
535 |
+
doc.text(`${item.label}:`, margin, yPos);
|
536 |
+
doc.setFont('helvetica', 'normal');
|
537 |
+
doc.text(item.value, margin + 60, yPos);
|
538 |
+
yPos += lineHeight;
|
539 |
+
});
|
540 |
+
|
541 |
+
yPos += lineHeight * 2;
|
542 |
+
|
543 |
+
// Add statistics section
|
544 |
+
addSectionHeader('Log Statistics');
|
545 |
+
|
546 |
+
// Calculate statistics
|
547 |
+
const stats = {
|
548 |
+
error: filteredLogs.filter((log) => log.level === 'error').length,
|
549 |
+
warning: filteredLogs.filter((log) => log.level === 'warning').length,
|
550 |
+
info: filteredLogs.filter((log) => log.level === 'info').length,
|
551 |
+
debug: filteredLogs.filter((log) => log.level === 'debug').length,
|
552 |
+
provider: filteredLogs.filter((log) => log.category === 'provider').length,
|
553 |
+
api: filteredLogs.filter((log) => log.category === 'api').length,
|
554 |
+
};
|
555 |
+
|
556 |
+
// Create two columns for statistics
|
557 |
+
const leftStats = [
|
558 |
+
{ label: 'Error Logs', value: stats.error, color: '#DC2626' },
|
559 |
+
{ label: 'Warning Logs', value: stats.warning, color: '#F59E0B' },
|
560 |
+
{ label: 'Info Logs', value: stats.info, color: '#3B82F6' },
|
561 |
+
];
|
562 |
+
|
563 |
+
const rightStats = [
|
564 |
+
{ label: 'Debug Logs', value: stats.debug, color: '#6B7280' },
|
565 |
+
{ label: 'LLM Logs', value: stats.provider, color: '#10B981' },
|
566 |
+
{ label: 'API Logs', value: stats.api, color: '#3B82F6' },
|
567 |
+
];
|
568 |
+
|
569 |
+
const colWidth = (pageWidth - 2 * margin) / 2;
|
570 |
+
|
571 |
+
// Draw statistics in two columns
|
572 |
+
leftStats.forEach((stat, index) => {
|
573 |
+
doc.setTextColor(stat.color);
|
574 |
+
doc.setFont('helvetica', 'bold');
|
575 |
+
doc.text(stat.value.toString(), margin, yPos);
|
576 |
+
doc.setTextColor('#374151');
|
577 |
+
doc.setFont('helvetica', 'normal');
|
578 |
+
doc.text(stat.label, margin + 20, yPos);
|
579 |
+
|
580 |
+
if (rightStats[index]) {
|
581 |
+
doc.setTextColor(rightStats[index].color);
|
582 |
+
doc.setFont('helvetica', 'bold');
|
583 |
+
doc.text(rightStats[index].value.toString(), margin + colWidth, yPos);
|
584 |
+
doc.setTextColor('#374151');
|
585 |
+
doc.setFont('helvetica', 'normal');
|
586 |
+
doc.text(rightStats[index].label, margin + colWidth + 20, yPos);
|
587 |
+
}
|
588 |
+
|
589 |
+
yPos += lineHeight;
|
590 |
+
});
|
591 |
+
|
592 |
+
yPos += lineHeight * 2;
|
593 |
+
|
594 |
+
// Add logs section
|
595 |
+
addSectionHeader('Event Logs');
|
596 |
+
|
597 |
+
// Helper function to add a log entry with improved formatting
|
598 |
+
const addLogEntry = (log: LogEntry) => {
|
599 |
+
const entryHeight = 20 + (log.details ? 40 : 0); // Estimate entry height
|
600 |
+
|
601 |
+
// Check if we need a new page
|
602 |
+
if (yPos + entryHeight > doc.internal.pageSize.getHeight() - 20) {
|
603 |
+
doc.addPage();
|
604 |
+
yPos = margin;
|
605 |
+
}
|
606 |
+
|
607 |
+
// Add timestamp and level
|
608 |
+
const timestamp = new Date(log.timestamp).toLocaleString(undefined, {
|
609 |
+
year: 'numeric',
|
610 |
+
month: '2-digit',
|
611 |
+
day: '2-digit',
|
612 |
+
hour: '2-digit',
|
613 |
+
minute: '2-digit',
|
614 |
+
second: '2-digit',
|
615 |
+
hour12: !use24Hour,
|
616 |
+
});
|
617 |
+
|
618 |
+
// Draw log level badge background
|
619 |
+
const levelColors: Record<string, string> = {
|
620 |
+
error: '#FEE2E2',
|
621 |
+
warning: '#FEF3C7',
|
622 |
+
info: '#DBEAFE',
|
623 |
+
debug: '#F3F4F6',
|
624 |
+
};
|
625 |
+
|
626 |
+
const textColors: Record<string, string> = {
|
627 |
+
error: '#DC2626',
|
628 |
+
warning: '#F59E0B',
|
629 |
+
info: '#3B82F6',
|
630 |
+
debug: '#6B7280',
|
631 |
+
};
|
632 |
+
|
633 |
+
const levelWidth = doc.getTextWidth(log.level.toUpperCase()) + 10;
|
634 |
+
doc.setFillColor(levelColors[log.level] || '#F3F4F6');
|
635 |
+
doc.roundedRect(margin, yPos - 4, levelWidth, lineHeight + 4, 1, 1, 'F');
|
636 |
+
|
637 |
+
// Add log level text
|
638 |
+
doc.setTextColor(textColors[log.level] || '#6B7280');
|
639 |
+
doc.setFont('helvetica', 'bold');
|
640 |
+
doc.setFontSize(8);
|
641 |
+
doc.text(log.level.toUpperCase(), margin + 5, yPos);
|
642 |
+
|
643 |
+
// Add timestamp
|
644 |
+
doc.setTextColor('#6B7280');
|
645 |
+
doc.setFont('helvetica', 'normal');
|
646 |
+
doc.setFontSize(9);
|
647 |
+
doc.text(timestamp, margin + levelWidth + 10, yPos);
|
648 |
+
|
649 |
+
// Add category if present
|
650 |
+
if (log.category) {
|
651 |
+
const categoryX = margin + levelWidth + doc.getTextWidth(timestamp) + 20;
|
652 |
+
doc.setFillColor('#F3F4F6');
|
653 |
+
|
654 |
+
const categoryWidth = doc.getTextWidth(log.category) + 10;
|
655 |
+
doc.roundedRect(categoryX, yPos - 4, categoryWidth, lineHeight + 4, 2, 2, 'F');
|
656 |
+
doc.setTextColor('#6B7280');
|
657 |
+
doc.text(log.category, categoryX + 5, yPos);
|
658 |
+
}
|
659 |
+
|
660 |
+
yPos += lineHeight * 1.5;
|
661 |
+
|
662 |
+
// Add message
|
663 |
+
doc.setTextColor('#111827');
|
664 |
+
doc.setFontSize(10);
|
665 |
+
|
666 |
+
const messageLines = doc.splitTextToSize(log.message, maxLineWidth - 10);
|
667 |
+
doc.text(messageLines, margin + 5, yPos);
|
668 |
+
yPos += messageLines.length * lineHeight;
|
669 |
+
|
670 |
+
// Add details if present
|
671 |
+
if (log.details) {
|
672 |
+
doc.setTextColor('#6B7280');
|
673 |
+
doc.setFontSize(8);
|
674 |
+
|
675 |
+
const detailsStr = JSON.stringify(log.details, null, 2);
|
676 |
+
const detailsLines = doc.splitTextToSize(detailsStr, maxLineWidth - 15);
|
677 |
+
|
678 |
+
// Add details background
|
679 |
+
doc.setFillColor('#F9FAFB');
|
680 |
+
doc.roundedRect(margin + 5, yPos - 2, maxLineWidth - 10, detailsLines.length * lineHeight + 8, 1, 1, 'F');
|
681 |
+
|
682 |
+
doc.text(detailsLines, margin + 10, yPos + 4);
|
683 |
+
yPos += detailsLines.length * lineHeight + 10;
|
684 |
+
}
|
685 |
+
|
686 |
+
// Add separator line
|
687 |
+
doc.setDrawColor('#E5E7EB');
|
688 |
+
doc.setLineWidth(0.1);
|
689 |
+
doc.line(margin, yPos, pageWidth - margin, yPos);
|
690 |
+
yPos += lineHeight * 1.5;
|
691 |
+
};
|
692 |
+
|
693 |
+
// Add all logs
|
694 |
+
filteredLogs.forEach((log) => {
|
695 |
+
addLogEntry(log);
|
696 |
+
});
|
697 |
+
|
698 |
+
// Add footer to all pages
|
699 |
+
const totalPages = doc.internal.pages.length - 1;
|
700 |
+
|
701 |
+
for (let i = 1; i <= totalPages; i++) {
|
702 |
+
doc.setPage(i);
|
703 |
+
doc.setFontSize(8);
|
704 |
+
doc.setTextColor('#9CA3AF');
|
705 |
+
|
706 |
+
// Add page numbers
|
707 |
+
doc.text(`Page ${i} of ${totalPages}`, pageWidth / 2, doc.internal.pageSize.getHeight() - 10, {
|
708 |
+
align: 'center',
|
709 |
+
});
|
710 |
+
|
711 |
+
// Add footer text
|
712 |
+
doc.text('Generated by bolt.diy', margin, doc.internal.pageSize.getHeight() - 10);
|
713 |
+
|
714 |
+
const dateStr = new Date().toLocaleDateString();
|
715 |
+
doc.text(dateStr, pageWidth - margin, doc.internal.pageSize.getHeight() - 10, { align: 'right' });
|
716 |
+
}
|
717 |
+
|
718 |
+
// Save the PDF
|
719 |
+
doc.save(`bolt-event-logs-${new Date().toISOString()}.pdf`);
|
720 |
+
toast.success('Event logs exported successfully as PDF');
|
721 |
+
} catch (error) {
|
722 |
+
console.error('Failed to export PDF:', error);
|
723 |
+
toast.error('Failed to export event logs as PDF');
|
724 |
+
}
|
725 |
+
};
|
726 |
+
|
727 |
+
const exportAsText = () => {
|
728 |
+
try {
|
729 |
+
const textContent = filteredLogs
|
730 |
+
.map((log) => {
|
731 |
+
const timestamp = new Date(log.timestamp).toLocaleString();
|
732 |
+
let content = `[${timestamp}] ${log.level.toUpperCase()}: ${log.message}\n`;
|
733 |
+
|
734 |
+
if (log.category) {
|
735 |
+
content += `Category: ${log.category}\n`;
|
736 |
+
}
|
737 |
+
|
738 |
+
if (log.details) {
|
739 |
+
content += `Details:\n${JSON.stringify(log.details, null, 2)}\n`;
|
740 |
+
}
|
741 |
+
|
742 |
+
return content + '-'.repeat(80) + '\n';
|
743 |
+
})
|
744 |
+
.join('\n');
|
745 |
+
|
746 |
+
const blob = new Blob([textContent], { type: 'text/plain' });
|
747 |
+
const url = window.URL.createObjectURL(blob);
|
748 |
+
const a = document.createElement('a');
|
749 |
+
a.href = url;
|
750 |
+
a.download = `bolt-event-logs-${new Date().toISOString()}.txt`;
|
751 |
+
document.body.appendChild(a);
|
752 |
+
a.click();
|
753 |
+
window.URL.revokeObjectURL(url);
|
754 |
+
document.body.removeChild(a);
|
755 |
+
toast.success('Event logs exported successfully as text file');
|
756 |
+
} catch (error) {
|
757 |
+
console.error('Failed to export text file:', error);
|
758 |
+
toast.error('Failed to export event logs as text file');
|
759 |
+
}
|
760 |
+
};
|
761 |
+
|
762 |
+
const exportFormats: ExportFormat[] = [
|
763 |
+
{
|
764 |
+
id: 'json',
|
765 |
+
label: 'Export as JSON',
|
766 |
+
icon: 'i-ph:file-json',
|
767 |
+
handler: exportAsJSON,
|
768 |
+
},
|
769 |
+
{
|
770 |
+
id: 'csv',
|
771 |
+
label: 'Export as CSV',
|
772 |
+
icon: 'i-ph:file-csv',
|
773 |
+
handler: exportAsCSV,
|
774 |
+
},
|
775 |
+
{
|
776 |
+
id: 'pdf',
|
777 |
+
label: 'Export as PDF',
|
778 |
+
icon: 'i-ph:file-pdf',
|
779 |
+
handler: exportAsPDF,
|
780 |
+
},
|
781 |
+
{
|
782 |
+
id: 'txt',
|
783 |
+
label: 'Export as Text',
|
784 |
+
icon: 'i-ph:file-text',
|
785 |
+
handler: exportAsText,
|
786 |
+
},
|
787 |
+
];
|
788 |
+
|
789 |
+
const ExportButton = () => {
|
790 |
+
const [isOpen, setIsOpen] = useState(false);
|
791 |
+
|
792 |
+
const handleOpenChange = useCallback((open: boolean) => {
|
793 |
+
setIsOpen(open);
|
794 |
+
}, []);
|
795 |
+
|
796 |
+
const handleFormatClick = useCallback((handler: () => void) => {
|
797 |
+
handler();
|
798 |
+
setIsOpen(false);
|
799 |
+
}, []);
|
800 |
+
|
801 |
+
return (
|
802 |
+
<DialogRoot open={isOpen} onOpenChange={handleOpenChange}>
|
803 |
+
<button
|
804 |
+
onClick={() => setIsOpen(true)}
|
805 |
+
className={classNames(
|
806 |
+
'group flex items-center gap-2',
|
807 |
+
'rounded-lg px-3 py-1.5',
|
808 |
+
'text-sm text-gray-900 dark:text-white',
|
809 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
810 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
811 |
+
'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
|
812 |
+
'transition-all duration-200',
|
813 |
+
)}
|
814 |
+
>
|
815 |
+
<span className="i-ph:download text-lg text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
816 |
+
Export
|
817 |
+
</button>
|
818 |
+
|
819 |
+
<Dialog showCloseButton>
|
820 |
+
<div className="p-6">
|
821 |
+
<DialogTitle className="flex items-center gap-2">
|
822 |
+
<div className="i-ph:download w-5 h-5" />
|
823 |
+
Export Event Logs
|
824 |
+
</DialogTitle>
|
825 |
+
|
826 |
+
<div className="mt-4 flex flex-col gap-2">
|
827 |
+
{exportFormats.map((format) => (
|
828 |
+
<button
|
829 |
+
key={format.id}
|
830 |
+
onClick={() => handleFormatClick(format.handler)}
|
831 |
+
className={classNames(
|
832 |
+
'flex items-center gap-3 px-4 py-3 text-sm rounded-lg transition-colors w-full text-left',
|
833 |
+
'bg-white dark:bg-[#0A0A0A]',
|
834 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
835 |
+
'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
|
836 |
+
'hover:border-purple-200 dark:hover:border-purple-900/30',
|
837 |
+
'text-bolt-elements-textPrimary',
|
838 |
+
)}
|
839 |
+
>
|
840 |
+
<div className={classNames(format.icon, 'w-5 h-5')} />
|
841 |
+
<div>
|
842 |
+
<div className="font-medium">{format.label}</div>
|
843 |
+
<div className="text-xs text-bolt-elements-textSecondary mt-0.5">
|
844 |
+
{format.id === 'json' && 'Export as a structured JSON file'}
|
845 |
+
{format.id === 'csv' && 'Export as a CSV spreadsheet'}
|
846 |
+
{format.id === 'pdf' && 'Export as a formatted PDF document'}
|
847 |
+
{format.id === 'txt' && 'Export as a formatted text file'}
|
848 |
+
</div>
|
849 |
+
</div>
|
850 |
+
</button>
|
851 |
+
))}
|
852 |
+
</div>
|
853 |
+
</div>
|
854 |
+
</Dialog>
|
855 |
+
</DialogRoot>
|
856 |
+
);
|
857 |
+
};
|
858 |
+
|
859 |
+
return (
|
860 |
+
<div className="flex h-full flex-col gap-6">
|
861 |
+
<div className="flex items-center justify-between">
|
862 |
+
<DropdownMenu.Root open={showLevelFilter} onOpenChange={setShowLevelFilter}>
|
863 |
+
<DropdownMenu.Trigger asChild>
|
864 |
+
<button
|
865 |
+
className={classNames(
|
866 |
+
'flex items-center gap-2',
|
867 |
+
'rounded-lg px-3 py-1.5',
|
868 |
+
'text-sm text-gray-900 dark:text-white',
|
869 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
870 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
871 |
+
'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
|
872 |
+
'transition-all duration-200',
|
873 |
+
)}
|
874 |
+
>
|
875 |
+
<span
|
876 |
+
className={classNames('text-lg', selectedLevelOption?.icon || 'i-ph:funnel')}
|
877 |
+
style={{ color: selectedLevelOption?.color }}
|
878 |
+
/>
|
879 |
+
{selectedLevelOption?.label || 'All Types'}
|
880 |
+
<span className="i-ph:caret-down text-lg text-gray-500 dark:text-gray-400" />
|
881 |
+
</button>
|
882 |
+
</DropdownMenu.Trigger>
|
883 |
+
|
884 |
+
<DropdownMenu.Portal>
|
885 |
+
<DropdownMenu.Content
|
886 |
+
className="min-w-[200px] bg-white dark:bg-[#0A0A0A] rounded-lg shadow-lg py-1 z-[250] animate-in fade-in-0 zoom-in-95 border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
887 |
+
sideOffset={5}
|
888 |
+
align="start"
|
889 |
+
side="bottom"
|
890 |
+
>
|
891 |
+
{logLevelOptions.map((option) => (
|
892 |
+
<DropdownMenu.Item
|
893 |
+
key={option.value}
|
894 |
+
className="group flex items-center px-4 py-2.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-purple-500/10 dark:hover:bg-purple-500/20 cursor-pointer transition-colors"
|
895 |
+
onClick={() => handleLevelFilterChange(option.value)}
|
896 |
+
>
|
897 |
+
<div className="mr-3 flex h-5 w-5 items-center justify-center">
|
898 |
+
<div
|
899 |
+
className={classNames(option.icon, 'text-lg group-hover:text-purple-500 transition-colors')}
|
900 |
+
style={{ color: option.color }}
|
901 |
+
/>
|
902 |
+
</div>
|
903 |
+
<span className="group-hover:text-purple-500 transition-colors">{option.label}</span>
|
904 |
+
</DropdownMenu.Item>
|
905 |
+
))}
|
906 |
+
</DropdownMenu.Content>
|
907 |
+
</DropdownMenu.Portal>
|
908 |
+
</DropdownMenu.Root>
|
909 |
+
|
910 |
+
<div className="flex items-center gap-4">
|
911 |
+
<div className="flex items-center gap-2">
|
912 |
+
<Switch
|
913 |
+
checked={showTimestamps}
|
914 |
+
onCheckedChange={(value) => handlePreferenceChange('timestamps', value)}
|
915 |
+
className="data-[state=checked]:bg-purple-500"
|
916 |
+
/>
|
917 |
+
<span className="text-sm text-gray-500 dark:text-gray-400">Show Timestamps</span>
|
918 |
+
</div>
|
919 |
+
|
920 |
+
<div className="flex items-center gap-2">
|
921 |
+
<Switch
|
922 |
+
checked={use24Hour}
|
923 |
+
onCheckedChange={(value) => handlePreferenceChange('24hour', value)}
|
924 |
+
className="data-[state=checked]:bg-purple-500"
|
925 |
+
/>
|
926 |
+
<span className="text-sm text-gray-500 dark:text-gray-400">24h Time</span>
|
927 |
+
</div>
|
928 |
+
|
929 |
+
<div className="flex items-center gap-2">
|
930 |
+
<Switch
|
931 |
+
checked={autoExpand}
|
932 |
+
onCheckedChange={(value) => handlePreferenceChange('autoExpand', value)}
|
933 |
+
className="data-[state=checked]:bg-purple-500"
|
934 |
+
/>
|
935 |
+
<span className="text-sm text-gray-500 dark:text-gray-400">Auto Expand</span>
|
936 |
+
</div>
|
937 |
+
|
938 |
+
<div className="w-px h-4 bg-gray-200 dark:bg-gray-700" />
|
939 |
+
|
940 |
+
<button
|
941 |
+
onClick={handleRefresh}
|
942 |
+
className={classNames(
|
943 |
+
'group flex items-center gap-2',
|
944 |
+
'rounded-lg px-3 py-1.5',
|
945 |
+
'text-sm text-gray-900 dark:text-white',
|
946 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
947 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
948 |
+
'hover:bg-purple-500/10 dark:hover:bg-purple-500/20',
|
949 |
+
'transition-all duration-200',
|
950 |
+
{ 'animate-spin': isRefreshing },
|
951 |
+
)}
|
952 |
+
>
|
953 |
+
<span className="i-ph:arrows-clockwise text-lg text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
954 |
+
Refresh
|
955 |
+
</button>
|
956 |
+
|
957 |
+
<ExportButton />
|
958 |
+
</div>
|
959 |
+
</div>
|
960 |
+
|
961 |
+
<div className="flex flex-col gap-4">
|
962 |
+
<div className="relative">
|
963 |
+
<input
|
964 |
+
type="text"
|
965 |
+
placeholder="Search logs..."
|
966 |
+
value={searchQuery}
|
967 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
968 |
+
className={classNames(
|
969 |
+
'w-full px-4 py-2 pl-10 rounded-lg',
|
970 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
971 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
972 |
+
'text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400',
|
973 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500',
|
974 |
+
'transition-all duration-200',
|
975 |
+
)}
|
976 |
+
/>
|
977 |
+
<div className="absolute left-3 top-1/2 -translate-y-1/2">
|
978 |
+
<div className="i-ph:magnifying-glass text-lg text-gray-500 dark:text-gray-400" />
|
979 |
+
</div>
|
980 |
+
</div>
|
981 |
+
|
982 |
+
{filteredLogs.length === 0 ? (
|
983 |
+
<motion.div
|
984 |
+
initial={{ opacity: 0, y: 20 }}
|
985 |
+
animate={{ opacity: 1, y: 0 }}
|
986 |
+
className={classNames(
|
987 |
+
'flex flex-col items-center justify-center gap-4',
|
988 |
+
'rounded-lg p-8 text-center',
|
989 |
+
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
990 |
+
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
991 |
+
)}
|
992 |
+
>
|
993 |
+
<span className="i-ph:clipboard-text text-4xl text-gray-400 dark:text-gray-600" />
|
994 |
+
<div className="flex flex-col gap-1">
|
995 |
+
<h3 className="text-sm font-medium text-gray-900 dark:text-white">No Logs Found</h3>
|
996 |
+
<p className="text-sm text-gray-500 dark:text-gray-400">Try adjusting your search or filters</p>
|
997 |
+
</div>
|
998 |
+
</motion.div>
|
999 |
+
) : (
|
1000 |
+
filteredLogs.map((log) => (
|
1001 |
+
<LogEntryItem
|
1002 |
+
key={log.id}
|
1003 |
+
log={log}
|
1004 |
+
isExpanded={autoExpand}
|
1005 |
+
use24Hour={use24Hour}
|
1006 |
+
showTimestamp={showTimestamps}
|
1007 |
+
/>
|
1008 |
+
))
|
1009 |
+
)}
|
1010 |
+
</div>
|
1011 |
+
</div>
|
1012 |
+
);
|
1013 |
+
}
|
ExamplePrompts.tsx
ADDED
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from 'react';
|
2 |
+
|
3 |
+
const EXAMPLE_PROMPTS = [
|
4 |
+
{ text: 'Build a todo app in React using Tailwind' },
|
5 |
+
{ text: 'Build a simple blog using Astro' },
|
6 |
+
{ text: 'Create a cookie consent form using Material UI' },
|
7 |
+
{ text: 'Make a space invaders game' },
|
8 |
+
{ text: 'Make a Tic Tac Toe game in html, css and js only' },
|
9 |
+
];
|
10 |
+
|
11 |
+
export function ExamplePrompts(sendMessage?: { (event: React.UIEvent, messageInput?: string): void | undefined }) {
|
12 |
+
return (
|
13 |
+
<div id="examples" className="relative flex flex-col gap-9 w-full max-w-3xl mx-auto flex justify-center mt-6">
|
14 |
+
<div
|
15 |
+
className="flex flex-wrap justify-center gap-2"
|
16 |
+
style={{
|
17 |
+
animation: '.25s ease-out 0s 1 _fade-and-move-in_g2ptj_1 forwards',
|
18 |
+
}}
|
19 |
+
>
|
20 |
+
{EXAMPLE_PROMPTS.map((examplePrompt, index: number) => {
|
21 |
+
return (
|
22 |
+
<button
|
23 |
+
key={index}
|
24 |
+
onClick={(event) => {
|
25 |
+
sendMessage?.(event, examplePrompt.text);
|
26 |
+
}}
|
27 |
+
className="border border-bolt-elements-borderColor rounded-full bg-gray-50 hover:bg-gray-100 dark:bg-gray-950 dark:hover:bg-gray-900 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary px-3 py-1 text-xs transition-theme"
|
28 |
+
>
|
29 |
+
{examplePrompt.text}
|
30 |
+
</button>
|
31 |
+
);
|
32 |
+
})}
|
33 |
+
</div>
|
34 |
+
</div>
|
35 |
+
);
|
36 |
+
}
|
ExportChatButton.tsx
ADDED
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import WithTooltip from '~/components/ui/Tooltip';
|
2 |
+
import { IconButton } from '~/components/ui/IconButton';
|
3 |
+
import React from 'react';
|
4 |
+
|
5 |
+
export const ExportChatButton = ({ exportChat }: { exportChat?: () => void }) => {
|
6 |
+
return (
|
7 |
+
<WithTooltip tooltip="Export Chat">
|
8 |
+
<IconButton title="Export Chat" onClick={() => exportChat?.()}>
|
9 |
+
<div className="i-ph:download-simple text-xl"></div>
|
10 |
+
</IconButton>
|
11 |
+
</WithTooltip>
|
12 |
+
);
|
13 |
+
};
|
FeaturesTab.tsx
ADDED
@@ -0,0 +1,295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// Remove unused imports
|
2 |
+
import React, { memo, useCallback } from 'react';
|
3 |
+
import { motion } from 'framer-motion';
|
4 |
+
import { Switch } from '~/components/ui/Switch';
|
5 |
+
import { useSettings } from '~/lib/hooks/useSettings';
|
6 |
+
import { classNames } from '~/utils/classNames';
|
7 |
+
import { toast } from 'react-toastify';
|
8 |
+
import { PromptLibrary } from '~/lib/common/prompt-library';
|
9 |
+
|
10 |
+
interface FeatureToggle {
|
11 |
+
id: string;
|
12 |
+
title: string;
|
13 |
+
description: string;
|
14 |
+
icon: string;
|
15 |
+
enabled: boolean;
|
16 |
+
beta?: boolean;
|
17 |
+
experimental?: boolean;
|
18 |
+
tooltip?: string;
|
19 |
+
}
|
20 |
+
|
21 |
+
const FeatureCard = memo(
|
22 |
+
({
|
23 |
+
feature,
|
24 |
+
index,
|
25 |
+
onToggle,
|
26 |
+
}: {
|
27 |
+
feature: FeatureToggle;
|
28 |
+
index: number;
|
29 |
+
onToggle: (id: string, enabled: boolean) => void;
|
30 |
+
}) => (
|
31 |
+
<motion.div
|
32 |
+
key={feature.id}
|
33 |
+
layoutId={feature.id}
|
34 |
+
className={classNames(
|
35 |
+
'relative group cursor-pointer',
|
36 |
+
'bg-bolt-elements-background-depth-2',
|
37 |
+
'hover:bg-bolt-elements-background-depth-3',
|
38 |
+
'transition-colors duration-200',
|
39 |
+
'rounded-lg overflow-hidden',
|
40 |
+
)}
|
41 |
+
initial={{ opacity: 0, y: 20 }}
|
42 |
+
animate={{ opacity: 1, y: 0 }}
|
43 |
+
transition={{ delay: index * 0.1 }}
|
44 |
+
>
|
45 |
+
<div className="p-4">
|
46 |
+
<div className="flex items-center justify-between">
|
47 |
+
<div className="flex items-center gap-3">
|
48 |
+
<div className={classNames(feature.icon, 'w-5 h-5 text-bolt-elements-textSecondary')} />
|
49 |
+
<div className="flex items-center gap-2">
|
50 |
+
<h4 className="font-medium text-bolt-elements-textPrimary">{feature.title}</h4>
|
51 |
+
{feature.beta && (
|
52 |
+
<span className="px-2 py-0.5 text-xs rounded-full bg-blue-500/10 text-blue-500 font-medium">Beta</span>
|
53 |
+
)}
|
54 |
+
{feature.experimental && (
|
55 |
+
<span className="px-2 py-0.5 text-xs rounded-full bg-orange-500/10 text-orange-500 font-medium">
|
56 |
+
Experimental
|
57 |
+
</span>
|
58 |
+
)}
|
59 |
+
</div>
|
60 |
+
</div>
|
61 |
+
<Switch checked={feature.enabled} onCheckedChange={(checked) => onToggle(feature.id, checked)} />
|
62 |
+
</div>
|
63 |
+
<p className="mt-2 text-sm text-bolt-elements-textSecondary">{feature.description}</p>
|
64 |
+
{feature.tooltip && <p className="mt-1 text-xs text-bolt-elements-textTertiary">{feature.tooltip}</p>}
|
65 |
+
</div>
|
66 |
+
</motion.div>
|
67 |
+
),
|
68 |
+
);
|
69 |
+
|
70 |
+
const FeatureSection = memo(
|
71 |
+
({
|
72 |
+
title,
|
73 |
+
features,
|
74 |
+
icon,
|
75 |
+
description,
|
76 |
+
onToggleFeature,
|
77 |
+
}: {
|
78 |
+
title: string;
|
79 |
+
features: FeatureToggle[];
|
80 |
+
icon: string;
|
81 |
+
description: string;
|
82 |
+
onToggleFeature: (id: string, enabled: boolean) => void;
|
83 |
+
}) => (
|
84 |
+
<motion.div
|
85 |
+
layout
|
86 |
+
className="flex flex-col gap-4"
|
87 |
+
initial={{ opacity: 0, y: 20 }}
|
88 |
+
animate={{ opacity: 1, y: 0 }}
|
89 |
+
transition={{ duration: 0.3 }}
|
90 |
+
>
|
91 |
+
<div className="flex items-center gap-3">
|
92 |
+
<div className={classNames(icon, 'text-xl text-purple-500')} />
|
93 |
+
<div>
|
94 |
+
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">{title}</h3>
|
95 |
+
<p className="text-sm text-bolt-elements-textSecondary">{description}</p>
|
96 |
+
</div>
|
97 |
+
</div>
|
98 |
+
|
99 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
100 |
+
{features.map((feature, index) => (
|
101 |
+
<FeatureCard key={feature.id} feature={feature} index={index} onToggle={onToggleFeature} />
|
102 |
+
))}
|
103 |
+
</div>
|
104 |
+
</motion.div>
|
105 |
+
),
|
106 |
+
);
|
107 |
+
|
108 |
+
export default function FeaturesTab() {
|
109 |
+
const {
|
110 |
+
autoSelectTemplate,
|
111 |
+
isLatestBranch,
|
112 |
+
contextOptimizationEnabled,
|
113 |
+
eventLogs,
|
114 |
+
setAutoSelectTemplate,
|
115 |
+
enableLatestBranch,
|
116 |
+
enableContextOptimization,
|
117 |
+
setEventLogs,
|
118 |
+
setPromptId,
|
119 |
+
promptId,
|
120 |
+
} = useSettings();
|
121 |
+
|
122 |
+
// Enable features by default on first load
|
123 |
+
React.useEffect(() => {
|
124 |
+
// Only set defaults if values are undefined
|
125 |
+
if (isLatestBranch === undefined) {
|
126 |
+
enableLatestBranch(false); // Default: OFF - Don't auto-update from main branch
|
127 |
+
}
|
128 |
+
|
129 |
+
if (contextOptimizationEnabled === undefined) {
|
130 |
+
enableContextOptimization(true); // Default: ON - Enable context optimization
|
131 |
+
}
|
132 |
+
|
133 |
+
if (autoSelectTemplate === undefined) {
|
134 |
+
setAutoSelectTemplate(true); // Default: ON - Enable auto-select templates
|
135 |
+
}
|
136 |
+
|
137 |
+
if (promptId === undefined) {
|
138 |
+
setPromptId('default'); // Default: 'default'
|
139 |
+
}
|
140 |
+
|
141 |
+
if (eventLogs === undefined) {
|
142 |
+
setEventLogs(true); // Default: ON - Enable event logging
|
143 |
+
}
|
144 |
+
}, []); // Only run once on component mount
|
145 |
+
|
146 |
+
const handleToggleFeature = useCallback(
|
147 |
+
(id: string, enabled: boolean) => {
|
148 |
+
switch (id) {
|
149 |
+
case 'latestBranch': {
|
150 |
+
enableLatestBranch(enabled);
|
151 |
+
toast.success(`Main branch updates ${enabled ? 'enabled' : 'disabled'}`);
|
152 |
+
break;
|
153 |
+
}
|
154 |
+
|
155 |
+
case 'autoSelectTemplate': {
|
156 |
+
setAutoSelectTemplate(enabled);
|
157 |
+
toast.success(`Auto select template ${enabled ? 'enabled' : 'disabled'}`);
|
158 |
+
break;
|
159 |
+
}
|
160 |
+
|
161 |
+
case 'contextOptimization': {
|
162 |
+
enableContextOptimization(enabled);
|
163 |
+
toast.success(`Context optimization ${enabled ? 'enabled' : 'disabled'}`);
|
164 |
+
break;
|
165 |
+
}
|
166 |
+
|
167 |
+
case 'eventLogs': {
|
168 |
+
setEventLogs(enabled);
|
169 |
+
toast.success(`Event logging ${enabled ? 'enabled' : 'disabled'}`);
|
170 |
+
break;
|
171 |
+
}
|
172 |
+
|
173 |
+
default:
|
174 |
+
break;
|
175 |
+
}
|
176 |
+
},
|
177 |
+
[enableLatestBranch, setAutoSelectTemplate, enableContextOptimization, setEventLogs],
|
178 |
+
);
|
179 |
+
|
180 |
+
const features = {
|
181 |
+
stable: [
|
182 |
+
{
|
183 |
+
id: 'latestBranch',
|
184 |
+
title: 'Main Branch Updates',
|
185 |
+
description: 'Get the latest updates from the main branch',
|
186 |
+
icon: 'i-ph:git-branch',
|
187 |
+
enabled: isLatestBranch,
|
188 |
+
tooltip: 'Enabled by default to receive updates from the main development branch',
|
189 |
+
},
|
190 |
+
{
|
191 |
+
id: 'autoSelectTemplate',
|
192 |
+
title: 'Auto Select Template',
|
193 |
+
description: 'Automatically select starter template',
|
194 |
+
icon: 'i-ph:selection',
|
195 |
+
enabled: autoSelectTemplate,
|
196 |
+
tooltip: 'Enabled by default to automatically select the most appropriate starter template',
|
197 |
+
},
|
198 |
+
{
|
199 |
+
id: 'contextOptimization',
|
200 |
+
title: 'Context Optimization',
|
201 |
+
description: 'Optimize context for better responses',
|
202 |
+
icon: 'i-ph:brain',
|
203 |
+
enabled: contextOptimizationEnabled,
|
204 |
+
tooltip: 'Enabled by default for improved AI responses',
|
205 |
+
},
|
206 |
+
{
|
207 |
+
id: 'eventLogs',
|
208 |
+
title: 'Event Logging',
|
209 |
+
description: 'Enable detailed event logging and history',
|
210 |
+
icon: 'i-ph:list-bullets',
|
211 |
+
enabled: eventLogs,
|
212 |
+
tooltip: 'Enabled by default to record detailed logs of system events and user actions',
|
213 |
+
},
|
214 |
+
],
|
215 |
+
beta: [],
|
216 |
+
};
|
217 |
+
|
218 |
+
return (
|
219 |
+
<div className="flex flex-col gap-8">
|
220 |
+
<FeatureSection
|
221 |
+
title="Core Features"
|
222 |
+
features={features.stable}
|
223 |
+
icon="i-ph:check-circle"
|
224 |
+
description="Essential features that are enabled by default for optimal performance"
|
225 |
+
onToggleFeature={handleToggleFeature}
|
226 |
+
/>
|
227 |
+
|
228 |
+
{features.beta.length > 0 && (
|
229 |
+
<FeatureSection
|
230 |
+
title="Beta Features"
|
231 |
+
features={features.beta}
|
232 |
+
icon="i-ph:test-tube"
|
233 |
+
description="New features that are ready for testing but may have some rough edges"
|
234 |
+
onToggleFeature={handleToggleFeature}
|
235 |
+
/>
|
236 |
+
)}
|
237 |
+
|
238 |
+
<motion.div
|
239 |
+
layout
|
240 |
+
className={classNames(
|
241 |
+
'bg-bolt-elements-background-depth-2',
|
242 |
+
'hover:bg-bolt-elements-background-depth-3',
|
243 |
+
'transition-all duration-200',
|
244 |
+
'rounded-lg p-4',
|
245 |
+
'group',
|
246 |
+
)}
|
247 |
+
initial={{ opacity: 0, y: 20 }}
|
248 |
+
animate={{ opacity: 1, y: 0 }}
|
249 |
+
transition={{ delay: 0.3 }}
|
250 |
+
>
|
251 |
+
<div className="flex items-center gap-4">
|
252 |
+
<div
|
253 |
+
className={classNames(
|
254 |
+
'p-2 rounded-lg text-xl',
|
255 |
+
'bg-bolt-elements-background-depth-3 group-hover:bg-bolt-elements-background-depth-4',
|
256 |
+
'transition-colors duration-200',
|
257 |
+
'text-purple-500',
|
258 |
+
)}
|
259 |
+
>
|
260 |
+
<div className="i-ph:book" />
|
261 |
+
</div>
|
262 |
+
<div className="flex-1">
|
263 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary group-hover:text-purple-500 transition-colors">
|
264 |
+
Prompt Library
|
265 |
+
</h4>
|
266 |
+
<p className="text-xs text-bolt-elements-textSecondary mt-0.5">
|
267 |
+
Choose a prompt from the library to use as the system prompt
|
268 |
+
</p>
|
269 |
+
</div>
|
270 |
+
<select
|
271 |
+
value={promptId}
|
272 |
+
onChange={(e) => {
|
273 |
+
setPromptId(e.target.value);
|
274 |
+
toast.success('Prompt template updated');
|
275 |
+
}}
|
276 |
+
className={classNames(
|
277 |
+
'p-2 rounded-lg text-sm min-w-[200px]',
|
278 |
+
'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
|
279 |
+
'text-bolt-elements-textPrimary',
|
280 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
|
281 |
+
'group-hover:border-purple-500/30',
|
282 |
+
'transition-all duration-200',
|
283 |
+
)}
|
284 |
+
>
|
285 |
+
{PromptLibrary.getList().map((x) => (
|
286 |
+
<option key={x.id} value={x.id}>
|
287 |
+
{x.label}
|
288 |
+
</option>
|
289 |
+
))}
|
290 |
+
</select>
|
291 |
+
</div>
|
292 |
+
</motion.div>
|
293 |
+
</div>
|
294 |
+
);
|
295 |
+
}
|
FilePreview.tsx
ADDED
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from 'react';
|
2 |
+
|
3 |
+
interface FilePreviewProps {
|
4 |
+
files: File[];
|
5 |
+
imageDataList: string[];
|
6 |
+
onRemove: (index: number) => void;
|
7 |
+
}
|
8 |
+
|
9 |
+
const FilePreview: React.FC<FilePreviewProps> = ({ files, imageDataList, onRemove }) => {
|
10 |
+
if (!files || files.length === 0) {
|
11 |
+
return null;
|
12 |
+
}
|
13 |
+
|
14 |
+
return (
|
15 |
+
<div className="flex flex-row overflow-x-auto -mt-2">
|
16 |
+
{files.map((file, index) => (
|
17 |
+
<div key={file.name + file.size} className="mr-2 relative">
|
18 |
+
{imageDataList[index] && (
|
19 |
+
<div className="relative pt-4 pr-4">
|
20 |
+
<img src={imageDataList[index]} alt={file.name} className="max-h-20" />
|
21 |
+
<button
|
22 |
+
onClick={() => onRemove(index)}
|
23 |
+
className="absolute top-1 right-1 z-10 bg-black rounded-full w-5 h-5 shadow-md hover:bg-gray-900 transition-colors flex items-center justify-center"
|
24 |
+
>
|
25 |
+
<div className="i-ph:x w-3 h-3 text-gray-200" />
|
26 |
+
</button>
|
27 |
+
</div>
|
28 |
+
)}
|
29 |
+
</div>
|
30 |
+
))}
|
31 |
+
</div>
|
32 |
+
);
|
33 |
+
};
|
34 |
+
|
35 |
+
export default FilePreview;
|
GitCloneButton.tsx
ADDED
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import ignore from 'ignore';
|
2 |
+
import { useGit } from '~/lib/hooks/useGit';
|
3 |
+
import type { Message } from 'ai';
|
4 |
+
import { detectProjectCommands, createCommandsMessage, escapeBoltTags } from '~/utils/projectCommands';
|
5 |
+
import { generateId } from '~/utils/fileUtils';
|
6 |
+
import { useState } from 'react';
|
7 |
+
import { toast } from 'react-toastify';
|
8 |
+
import { LoadingOverlay } from '~/components/ui/LoadingOverlay';
|
9 |
+
import { RepositorySelectionDialog } from '~/components/@settings/tabs/connections/components/RepositorySelectionDialog';
|
10 |
+
import { classNames } from '~/utils/classNames';
|
11 |
+
import { Button } from '~/components/ui/Button';
|
12 |
+
import type { IChatMetadata } from '~/lib/persistence/db';
|
13 |
+
|
14 |
+
const IGNORE_PATTERNS = [
|
15 |
+
'node_modules/**',
|
16 |
+
'.git/**',
|
17 |
+
'.github/**',
|
18 |
+
'.vscode/**',
|
19 |
+
'dist/**',
|
20 |
+
'build/**',
|
21 |
+
'.next/**',
|
22 |
+
'coverage/**',
|
23 |
+
'.cache/**',
|
24 |
+
'.idea/**',
|
25 |
+
'**/*.log',
|
26 |
+
'**/.DS_Store',
|
27 |
+
'**/npm-debug.log*',
|
28 |
+
'**/yarn-debug.log*',
|
29 |
+
'**/yarn-error.log*',
|
30 |
+
'**/*lock.json',
|
31 |
+
'**/*lock.yaml',
|
32 |
+
];
|
33 |
+
|
34 |
+
const ig = ignore().add(IGNORE_PATTERNS);
|
35 |
+
|
36 |
+
const MAX_FILE_SIZE = 100 * 1024; // 100KB limit per file
|
37 |
+
const MAX_TOTAL_SIZE = 500 * 1024; // 500KB total limit
|
38 |
+
|
39 |
+
interface GitCloneButtonProps {
|
40 |
+
className?: string;
|
41 |
+
importChat?: (description: string, messages: Message[], metadata?: IChatMetadata) => Promise<void>;
|
42 |
+
}
|
43 |
+
|
44 |
+
export default function GitCloneButton({ importChat, className }: GitCloneButtonProps) {
|
45 |
+
const { ready, gitClone } = useGit();
|
46 |
+
const [loading, setLoading] = useState(false);
|
47 |
+
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
48 |
+
|
49 |
+
const handleClone = async (repoUrl: string) => {
|
50 |
+
if (!ready) {
|
51 |
+
return;
|
52 |
+
}
|
53 |
+
|
54 |
+
setLoading(true);
|
55 |
+
|
56 |
+
try {
|
57 |
+
const { workdir, data } = await gitClone(repoUrl);
|
58 |
+
|
59 |
+
if (importChat) {
|
60 |
+
const filePaths = Object.keys(data).filter((filePath) => !ig.ignores(filePath));
|
61 |
+
const textDecoder = new TextDecoder('utf-8');
|
62 |
+
|
63 |
+
let totalSize = 0;
|
64 |
+
const skippedFiles: string[] = [];
|
65 |
+
const fileContents = [];
|
66 |
+
|
67 |
+
for (const filePath of filePaths) {
|
68 |
+
const { data: content, encoding } = data[filePath];
|
69 |
+
|
70 |
+
// Skip binary files
|
71 |
+
if (
|
72 |
+
content instanceof Uint8Array &&
|
73 |
+
!filePath.match(/\.(txt|md|astro|mjs|js|jsx|ts|tsx|json|html|css|scss|less|yml|yaml|xml|svg)$/i)
|
74 |
+
) {
|
75 |
+
skippedFiles.push(filePath);
|
76 |
+
continue;
|
77 |
+
}
|
78 |
+
|
79 |
+
try {
|
80 |
+
const textContent =
|
81 |
+
encoding === 'utf8' ? content : content instanceof Uint8Array ? textDecoder.decode(content) : '';
|
82 |
+
|
83 |
+
if (!textContent) {
|
84 |
+
continue;
|
85 |
+
}
|
86 |
+
|
87 |
+
// Check file size
|
88 |
+
const fileSize = new TextEncoder().encode(textContent).length;
|
89 |
+
|
90 |
+
if (fileSize > MAX_FILE_SIZE) {
|
91 |
+
skippedFiles.push(`${filePath} (too large: ${Math.round(fileSize / 1024)}KB)`);
|
92 |
+
continue;
|
93 |
+
}
|
94 |
+
|
95 |
+
// Check total size
|
96 |
+
if (totalSize + fileSize > MAX_TOTAL_SIZE) {
|
97 |
+
skippedFiles.push(`${filePath} (would exceed total size limit)`);
|
98 |
+
continue;
|
99 |
+
}
|
100 |
+
|
101 |
+
totalSize += fileSize;
|
102 |
+
fileContents.push({
|
103 |
+
path: filePath,
|
104 |
+
content: textContent,
|
105 |
+
});
|
106 |
+
} catch (e: any) {
|
107 |
+
skippedFiles.push(`${filePath} (error: ${e.message})`);
|
108 |
+
}
|
109 |
+
}
|
110 |
+
|
111 |
+
const commands = await detectProjectCommands(fileContents);
|
112 |
+
const commandsMessage = createCommandsMessage(commands);
|
113 |
+
|
114 |
+
const filesMessage: Message = {
|
115 |
+
role: 'assistant',
|
116 |
+
content: `Cloning the repo ${repoUrl} into ${workdir}
|
117 |
+
${
|
118 |
+
skippedFiles.length > 0
|
119 |
+
? `\nSkipped files (${skippedFiles.length}):
|
120 |
+
${skippedFiles.map((f) => `- ${f}`).join('\n')}`
|
121 |
+
: ''
|
122 |
+
}
|
123 |
+
|
124 |
+
<boltArtifact id="imported-files" title="Git Cloned Files" type="bundled">
|
125 |
+
${fileContents
|
126 |
+
.map(
|
127 |
+
(file) =>
|
128 |
+
`<boltAction type="file" filePath="${file.path}">
|
129 |
+
${escapeBoltTags(file.content)}
|
130 |
+
</boltAction>`,
|
131 |
+
)
|
132 |
+
.join('\n')}
|
133 |
+
</boltArtifact>`,
|
134 |
+
id: generateId(),
|
135 |
+
createdAt: new Date(),
|
136 |
+
};
|
137 |
+
|
138 |
+
const messages = [filesMessage];
|
139 |
+
|
140 |
+
if (commandsMessage) {
|
141 |
+
messages.push(commandsMessage);
|
142 |
+
}
|
143 |
+
|
144 |
+
await importChat(`Git Project:${repoUrl.split('/').slice(-1)[0]}`, messages);
|
145 |
+
}
|
146 |
+
} catch (error) {
|
147 |
+
console.error('Error during import:', error);
|
148 |
+
toast.error('Failed to import repository');
|
149 |
+
} finally {
|
150 |
+
setLoading(false);
|
151 |
+
}
|
152 |
+
};
|
153 |
+
|
154 |
+
return (
|
155 |
+
<>
|
156 |
+
<Button
|
157 |
+
onClick={() => setIsDialogOpen(true)}
|
158 |
+
title="Clone a Git Repo"
|
159 |
+
variant="outline"
|
160 |
+
size="lg"
|
161 |
+
className={classNames(
|
162 |
+
'gap-2 bg-[#F5F5F5] dark:bg-[#252525]',
|
163 |
+
'text-bolt-elements-textPrimary dark:text-white',
|
164 |
+
'hover:bg-[#E5E5E5] dark:hover:bg-[#333333]',
|
165 |
+
'border-[#E5E5E5] dark:border-[#333333]',
|
166 |
+
'h-10 px-4 py-2 min-w-[120px] justify-center',
|
167 |
+
'transition-all duration-200 ease-in-out',
|
168 |
+
className,
|
169 |
+
)}
|
170 |
+
disabled={!ready || loading}
|
171 |
+
>
|
172 |
+
<span className="i-ph:git-branch w-4 h-4" />
|
173 |
+
Clone a Git Repo
|
174 |
+
</Button>
|
175 |
+
|
176 |
+
<RepositorySelectionDialog isOpen={isDialogOpen} onClose={() => setIsDialogOpen(false)} onSelect={handleClone} />
|
177 |
+
|
178 |
+
{loading && <LoadingOverlay message="Please wait while we clone the repository..." />}
|
179 |
+
</>
|
180 |
+
);
|
181 |
+
}
|
GitHub.ts
ADDED
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
export interface GitHubUserResponse {
|
2 |
+
login: string;
|
3 |
+
avatar_url: string;
|
4 |
+
html_url: string;
|
5 |
+
name: string;
|
6 |
+
bio: string;
|
7 |
+
public_repos: number;
|
8 |
+
followers: number;
|
9 |
+
following: number;
|
10 |
+
public_gists: number;
|
11 |
+
created_at: string;
|
12 |
+
updated_at: string;
|
13 |
+
}
|
14 |
+
|
15 |
+
export interface GitHubRepoInfo {
|
16 |
+
name: string;
|
17 |
+
full_name: string;
|
18 |
+
html_url: string;
|
19 |
+
description: string;
|
20 |
+
stargazers_count: number;
|
21 |
+
forks_count: number;
|
22 |
+
default_branch: string;
|
23 |
+
updated_at: string;
|
24 |
+
language: string;
|
25 |
+
languages_url: string;
|
26 |
+
}
|
27 |
+
|
28 |
+
export interface GitHubOrganization {
|
29 |
+
login: string;
|
30 |
+
avatar_url: string;
|
31 |
+
description: string;
|
32 |
+
html_url: string;
|
33 |
+
}
|
34 |
+
|
35 |
+
export interface GitHubEvent {
|
36 |
+
id: string;
|
37 |
+
type: string;
|
38 |
+
created_at: string;
|
39 |
+
repo: {
|
40 |
+
name: string;
|
41 |
+
url: string;
|
42 |
+
};
|
43 |
+
payload: {
|
44 |
+
action?: string;
|
45 |
+
ref?: string;
|
46 |
+
ref_type?: string;
|
47 |
+
description?: string;
|
48 |
+
};
|
49 |
+
}
|
50 |
+
|
51 |
+
export interface GitHubLanguageStats {
|
52 |
+
[key: string]: number;
|
53 |
+
}
|
54 |
+
|
55 |
+
export interface GitHubStats {
|
56 |
+
repos: GitHubRepoInfo[];
|
57 |
+
totalStars: number;
|
58 |
+
totalForks: number;
|
59 |
+
organizations: GitHubOrganization[];
|
60 |
+
recentActivity: GitHubEvent[];
|
61 |
+
languages: GitHubLanguageStats;
|
62 |
+
totalGists: number;
|
63 |
+
}
|
64 |
+
|
65 |
+
export interface GitHubConnection {
|
66 |
+
user: GitHubUserResponse | null;
|
67 |
+
token: string;
|
68 |
+
tokenType: 'classic' | 'fine-grained';
|
69 |
+
stats?: GitHubStats;
|
70 |
+
}
|
71 |
+
|
72 |
+
export interface GitHubTokenInfo {
|
73 |
+
token: string;
|
74 |
+
scope: string[];
|
75 |
+
avatar_url: string;
|
76 |
+
name: string | null;
|
77 |
+
created_at: string;
|
78 |
+
followers: number;
|
79 |
+
}
|
80 |
+
|
81 |
+
export interface GitHubRateLimits {
|
82 |
+
limit: number;
|
83 |
+
remaining: number;
|
84 |
+
reset: Date;
|
85 |
+
used: number;
|
86 |
+
}
|
87 |
+
|
88 |
+
export interface GitHubAuthState {
|
89 |
+
username: string;
|
90 |
+
tokenInfo: GitHubTokenInfo | null;
|
91 |
+
isConnected: boolean;
|
92 |
+
isVerifying: boolean;
|
93 |
+
isLoadingRepos: boolean;
|
94 |
+
rateLimits?: GitHubRateLimits;
|
95 |
+
}
|
GithubConnection.tsx
ADDED
@@ -0,0 +1,557 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useState, useEffect } from 'react';
|
2 |
+
import { motion } from 'framer-motion';
|
3 |
+
import { toast } from 'react-toastify';
|
4 |
+
import { logStore } from '~/lib/stores/logs';
|
5 |
+
import { classNames } from '~/utils/classNames';
|
6 |
+
|
7 |
+
interface GitHubUserResponse {
|
8 |
+
login: string;
|
9 |
+
avatar_url: string;
|
10 |
+
html_url: string;
|
11 |
+
name: string;
|
12 |
+
bio: string;
|
13 |
+
public_repos: number;
|
14 |
+
followers: number;
|
15 |
+
following: number;
|
16 |
+
created_at: string;
|
17 |
+
public_gists: number;
|
18 |
+
}
|
19 |
+
|
20 |
+
interface GitHubRepoInfo {
|
21 |
+
name: string;
|
22 |
+
full_name: string;
|
23 |
+
html_url: string;
|
24 |
+
description: string;
|
25 |
+
stargazers_count: number;
|
26 |
+
forks_count: number;
|
27 |
+
default_branch: string;
|
28 |
+
updated_at: string;
|
29 |
+
languages_url: string;
|
30 |
+
}
|
31 |
+
|
32 |
+
interface GitHubOrganization {
|
33 |
+
login: string;
|
34 |
+
avatar_url: string;
|
35 |
+
html_url: string;
|
36 |
+
}
|
37 |
+
|
38 |
+
interface GitHubEvent {
|
39 |
+
id: string;
|
40 |
+
type: string;
|
41 |
+
repo: {
|
42 |
+
name: string;
|
43 |
+
};
|
44 |
+
created_at: string;
|
45 |
+
}
|
46 |
+
|
47 |
+
interface GitHubLanguageStats {
|
48 |
+
[language: string]: number;
|
49 |
+
}
|
50 |
+
|
51 |
+
interface GitHubStats {
|
52 |
+
repos: GitHubRepoInfo[];
|
53 |
+
totalStars: number;
|
54 |
+
totalForks: number;
|
55 |
+
organizations: GitHubOrganization[];
|
56 |
+
recentActivity: GitHubEvent[];
|
57 |
+
languages: GitHubLanguageStats;
|
58 |
+
totalGists: number;
|
59 |
+
}
|
60 |
+
|
61 |
+
interface GitHubConnection {
|
62 |
+
user: GitHubUserResponse | null;
|
63 |
+
token: string;
|
64 |
+
tokenType: 'classic' | 'fine-grained';
|
65 |
+
stats?: GitHubStats;
|
66 |
+
}
|
67 |
+
|
68 |
+
export function GithubConnection() {
|
69 |
+
const [connection, setConnection] = useState<GitHubConnection>({
|
70 |
+
user: null,
|
71 |
+
token: '',
|
72 |
+
tokenType: 'classic',
|
73 |
+
});
|
74 |
+
const [isLoading, setIsLoading] = useState(true);
|
75 |
+
const [isConnecting, setIsConnecting] = useState(false);
|
76 |
+
const [isFetchingStats, setIsFetchingStats] = useState(false);
|
77 |
+
const [isStatsExpanded, setIsStatsExpanded] = useState(false);
|
78 |
+
|
79 |
+
const fetchGitHubStats = async (token: string) => {
|
80 |
+
try {
|
81 |
+
setIsFetchingStats(true);
|
82 |
+
|
83 |
+
const reposResponse = await fetch(
|
84 |
+
'https://api.github.com/user/repos?sort=updated&per_page=10&affiliation=owner,organization_member,collaborator',
|
85 |
+
{
|
86 |
+
headers: {
|
87 |
+
Authorization: `Bearer ${token}`,
|
88 |
+
},
|
89 |
+
},
|
90 |
+
);
|
91 |
+
|
92 |
+
if (!reposResponse.ok) {
|
93 |
+
throw new Error('Failed to fetch repositories');
|
94 |
+
}
|
95 |
+
|
96 |
+
const repos = (await reposResponse.json()) as GitHubRepoInfo[];
|
97 |
+
|
98 |
+
const orgsResponse = await fetch('https://api.github.com/user/orgs', {
|
99 |
+
headers: {
|
100 |
+
Authorization: `Bearer ${token}`,
|
101 |
+
},
|
102 |
+
});
|
103 |
+
|
104 |
+
if (!orgsResponse.ok) {
|
105 |
+
throw new Error('Failed to fetch organizations');
|
106 |
+
}
|
107 |
+
|
108 |
+
const organizations = (await orgsResponse.json()) as GitHubOrganization[];
|
109 |
+
|
110 |
+
const eventsResponse = await fetch('https://api.github.com/users/' + connection.user?.login + '/events/public', {
|
111 |
+
headers: {
|
112 |
+
Authorization: `Bearer ${token}`,
|
113 |
+
},
|
114 |
+
});
|
115 |
+
|
116 |
+
if (!eventsResponse.ok) {
|
117 |
+
throw new Error('Failed to fetch events');
|
118 |
+
}
|
119 |
+
|
120 |
+
const recentActivity = ((await eventsResponse.json()) as GitHubEvent[]).slice(0, 5);
|
121 |
+
|
122 |
+
const languagePromises = repos.map((repo) =>
|
123 |
+
fetch(repo.languages_url, {
|
124 |
+
headers: {
|
125 |
+
Authorization: `Bearer ${token}`,
|
126 |
+
},
|
127 |
+
}).then((res) => res.json() as Promise<Record<string, number>>),
|
128 |
+
);
|
129 |
+
|
130 |
+
const repoLanguages = await Promise.all(languagePromises);
|
131 |
+
const languages: GitHubLanguageStats = {};
|
132 |
+
|
133 |
+
repoLanguages.forEach((repoLang) => {
|
134 |
+
Object.entries(repoLang).forEach(([lang, bytes]) => {
|
135 |
+
languages[lang] = (languages[lang] || 0) + bytes;
|
136 |
+
});
|
137 |
+
});
|
138 |
+
|
139 |
+
const totalStars = repos.reduce((acc, repo) => acc + repo.stargazers_count, 0);
|
140 |
+
const totalForks = repos.reduce((acc, repo) => acc + repo.forks_count, 0);
|
141 |
+
const totalGists = connection.user?.public_gists || 0;
|
142 |
+
|
143 |
+
setConnection((prev) => ({
|
144 |
+
...prev,
|
145 |
+
stats: {
|
146 |
+
repos,
|
147 |
+
totalStars,
|
148 |
+
totalForks,
|
149 |
+
organizations,
|
150 |
+
recentActivity,
|
151 |
+
languages,
|
152 |
+
totalGists,
|
153 |
+
},
|
154 |
+
}));
|
155 |
+
} catch (error) {
|
156 |
+
logStore.logError('Failed to fetch GitHub stats', { error });
|
157 |
+
toast.error('Failed to fetch GitHub statistics');
|
158 |
+
} finally {
|
159 |
+
setIsFetchingStats(false);
|
160 |
+
}
|
161 |
+
};
|
162 |
+
|
163 |
+
useEffect(() => {
|
164 |
+
const savedConnection = localStorage.getItem('github_connection');
|
165 |
+
|
166 |
+
if (savedConnection) {
|
167 |
+
const parsed = JSON.parse(savedConnection);
|
168 |
+
|
169 |
+
if (!parsed.tokenType) {
|
170 |
+
parsed.tokenType = 'classic';
|
171 |
+
}
|
172 |
+
|
173 |
+
setConnection(parsed);
|
174 |
+
|
175 |
+
if (parsed.user && parsed.token) {
|
176 |
+
fetchGitHubStats(parsed.token);
|
177 |
+
}
|
178 |
+
}
|
179 |
+
|
180 |
+
setIsLoading(false);
|
181 |
+
}, []);
|
182 |
+
|
183 |
+
if (isLoading || isConnecting || isFetchingStats) {
|
184 |
+
return <LoadingSpinner />;
|
185 |
+
}
|
186 |
+
|
187 |
+
const fetchGithubUser = async (token: string) => {
|
188 |
+
try {
|
189 |
+
setIsConnecting(true);
|
190 |
+
|
191 |
+
const response = await fetch('https://api.github.com/user', {
|
192 |
+
headers: {
|
193 |
+
Authorization: `Bearer ${token}`,
|
194 |
+
},
|
195 |
+
});
|
196 |
+
|
197 |
+
if (!response.ok) {
|
198 |
+
throw new Error('Invalid token or unauthorized');
|
199 |
+
}
|
200 |
+
|
201 |
+
const data = (await response.json()) as GitHubUserResponse;
|
202 |
+
const newConnection: GitHubConnection = {
|
203 |
+
user: data,
|
204 |
+
token,
|
205 |
+
tokenType: connection.tokenType,
|
206 |
+
};
|
207 |
+
|
208 |
+
localStorage.setItem('github_connection', JSON.stringify(newConnection));
|
209 |
+
setConnection(newConnection);
|
210 |
+
|
211 |
+
await fetchGitHubStats(token);
|
212 |
+
|
213 |
+
toast.success('Successfully connected to GitHub');
|
214 |
+
} catch (error) {
|
215 |
+
logStore.logError('Failed to authenticate with GitHub', { error });
|
216 |
+
toast.error('Failed to connect to GitHub');
|
217 |
+
setConnection({ user: null, token: '', tokenType: 'classic' });
|
218 |
+
} finally {
|
219 |
+
setIsConnecting(false);
|
220 |
+
}
|
221 |
+
};
|
222 |
+
|
223 |
+
const handleConnect = async (event: React.FormEvent) => {
|
224 |
+
event.preventDefault();
|
225 |
+
await fetchGithubUser(connection.token);
|
226 |
+
};
|
227 |
+
|
228 |
+
const handleDisconnect = () => {
|
229 |
+
localStorage.removeItem('github_connection');
|
230 |
+
setConnection({ user: null, token: '', tokenType: 'classic' });
|
231 |
+
toast.success('Disconnected from GitHub');
|
232 |
+
};
|
233 |
+
|
234 |
+
return (
|
235 |
+
<motion.div
|
236 |
+
className="bg-[#FFFFFF] dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A]"
|
237 |
+
initial={{ opacity: 0, y: 20 }}
|
238 |
+
animate={{ opacity: 1, y: 0 }}
|
239 |
+
transition={{ delay: 0.2 }}
|
240 |
+
>
|
241 |
+
<div className="p-6 space-y-6">
|
242 |
+
<div className="flex items-center gap-2">
|
243 |
+
<div className="i-ph:github-logo w-5 h-5 text-bolt-elements-textPrimary" />
|
244 |
+
<h3 className="text-base font-medium text-bolt-elements-textPrimary">GitHub Connection</h3>
|
245 |
+
</div>
|
246 |
+
|
247 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
248 |
+
<div>
|
249 |
+
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Token Type</label>
|
250 |
+
<select
|
251 |
+
value={connection.tokenType}
|
252 |
+
onChange={(e) =>
|
253 |
+
setConnection((prev) => ({ ...prev, tokenType: e.target.value as 'classic' | 'fine-grained' }))
|
254 |
+
}
|
255 |
+
disabled={isConnecting || !!connection.user}
|
256 |
+
className={classNames(
|
257 |
+
'w-full px-3 py-2 rounded-lg text-sm',
|
258 |
+
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
|
259 |
+
'border border-[#E5E5E5] dark:border-[#333333]',
|
260 |
+
'text-bolt-elements-textPrimary',
|
261 |
+
'focus:outline-none focus:ring-1 focus:ring-purple-500',
|
262 |
+
'disabled:opacity-50',
|
263 |
+
)}
|
264 |
+
>
|
265 |
+
<option value="classic">Personal Access Token (Classic)</option>
|
266 |
+
<option value="fine-grained">Fine-grained Token</option>
|
267 |
+
</select>
|
268 |
+
</div>
|
269 |
+
|
270 |
+
<div>
|
271 |
+
<label className="block text-sm text-bolt-elements-textSecondary mb-2">
|
272 |
+
{connection.tokenType === 'classic' ? 'Personal Access Token' : 'Fine-grained Token'}
|
273 |
+
</label>
|
274 |
+
<input
|
275 |
+
type="password"
|
276 |
+
value={connection.token}
|
277 |
+
onChange={(e) => setConnection((prev) => ({ ...prev, token: e.target.value }))}
|
278 |
+
disabled={isConnecting || !!connection.user}
|
279 |
+
placeholder={`Enter your GitHub ${
|
280 |
+
connection.tokenType === 'classic' ? 'personal access token' : 'fine-grained token'
|
281 |
+
}`}
|
282 |
+
className={classNames(
|
283 |
+
'w-full px-3 py-2 rounded-lg text-sm',
|
284 |
+
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
|
285 |
+
'border border-[#E5E5E5] dark:border-[#333333]',
|
286 |
+
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
287 |
+
'focus:outline-none focus:ring-1 focus:ring-purple-500',
|
288 |
+
'disabled:opacity-50',
|
289 |
+
)}
|
290 |
+
/>
|
291 |
+
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
|
292 |
+
<a
|
293 |
+
href={`https://github.com/settings/tokens${connection.tokenType === 'fine-grained' ? '/beta' : '/new'}`}
|
294 |
+
target="_blank"
|
295 |
+
rel="noopener noreferrer"
|
296 |
+
className="text-purple-500 hover:underline inline-flex items-center gap-1"
|
297 |
+
>
|
298 |
+
Get your token
|
299 |
+
<div className="i-ph:arrow-square-out w-10 h-5" />
|
300 |
+
</a>
|
301 |
+
<span className="mx-2">•</span>
|
302 |
+
<span>
|
303 |
+
Required scopes:{' '}
|
304 |
+
{connection.tokenType === 'classic'
|
305 |
+
? 'repo, read:org, read:user'
|
306 |
+
: 'Repository access, Organization access'}
|
307 |
+
</span>
|
308 |
+
</div>
|
309 |
+
</div>
|
310 |
+
</div>
|
311 |
+
|
312 |
+
<div className="flex items-center gap-3">
|
313 |
+
{!connection.user ? (
|
314 |
+
<button
|
315 |
+
onClick={handleConnect}
|
316 |
+
disabled={isConnecting || !connection.token}
|
317 |
+
className={classNames(
|
318 |
+
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
319 |
+
'bg-purple-500 text-white',
|
320 |
+
'hover:bg-purple-600',
|
321 |
+
'disabled:opacity-50 disabled:cursor-not-allowed',
|
322 |
+
)}
|
323 |
+
>
|
324 |
+
{isConnecting ? (
|
325 |
+
<>
|
326 |
+
<div className="i-ph:spinner-gap animate-spin" />
|
327 |
+
Connecting...
|
328 |
+
</>
|
329 |
+
) : (
|
330 |
+
<>
|
331 |
+
<div className="i-ph:plug-charging w-4 h-4" />
|
332 |
+
Connect
|
333 |
+
</>
|
334 |
+
)}
|
335 |
+
</button>
|
336 |
+
) : (
|
337 |
+
<button
|
338 |
+
onClick={handleDisconnect}
|
339 |
+
className={classNames(
|
340 |
+
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
|
341 |
+
'bg-red-500 text-white',
|
342 |
+
'hover:bg-red-600',
|
343 |
+
)}
|
344 |
+
>
|
345 |
+
<div className="i-ph:plug-x w-4 h-4" />
|
346 |
+
Disconnect
|
347 |
+
</button>
|
348 |
+
)}
|
349 |
+
|
350 |
+
{connection.user && (
|
351 |
+
<span className="text-sm text-bolt-elements-textSecondary flex items-center gap-1">
|
352 |
+
<div className="i-ph:check-circle w-4 h-4" />
|
353 |
+
Connected to GitHub
|
354 |
+
</span>
|
355 |
+
)}
|
356 |
+
</div>
|
357 |
+
|
358 |
+
{connection.user && connection.stats && (
|
359 |
+
<div className="mt-6 border-t border-[#E5E5E5] dark:border-[#1A1A1A] pt-6">
|
360 |
+
<button onClick={() => setIsStatsExpanded(!isStatsExpanded)} className="w-full bg-transparent">
|
361 |
+
<div className="flex items-center gap-4">
|
362 |
+
<img src={connection.user.avatar_url} alt={connection.user.login} className="w-16 h-16 rounded-full" />
|
363 |
+
<div className="flex-1">
|
364 |
+
<div className="flex items-center justify-between">
|
365 |
+
<h3 className="text-lg font-medium text-bolt-elements-textPrimary">
|
366 |
+
{connection.user.name || connection.user.login}
|
367 |
+
</h3>
|
368 |
+
<div
|
369 |
+
className={classNames(
|
370 |
+
'i-ph:caret-down w-4 h-4 text-bolt-elements-textSecondary transition-transform',
|
371 |
+
isStatsExpanded ? 'rotate-180' : '',
|
372 |
+
)}
|
373 |
+
/>
|
374 |
+
</div>
|
375 |
+
{connection.user.bio && (
|
376 |
+
<p className="text-sm text-start text-bolt-elements-textSecondary">{connection.user.bio}</p>
|
377 |
+
)}
|
378 |
+
<div className="flex gap-4 mt-2 text-sm text-bolt-elements-textSecondary">
|
379 |
+
<span className="flex items-center gap-1">
|
380 |
+
<div className="i-ph:users w-4 h-4" />
|
381 |
+
{connection.user.followers} followers
|
382 |
+
</span>
|
383 |
+
<span className="flex items-center gap-1">
|
384 |
+
<div className="i-ph:book-bookmark w-4 h-4" />
|
385 |
+
{connection.user.public_repos} public repos
|
386 |
+
</span>
|
387 |
+
<span className="flex items-center gap-1">
|
388 |
+
<div className="i-ph:star w-4 h-4" />
|
389 |
+
{connection.stats.totalStars} stars
|
390 |
+
</span>
|
391 |
+
<span className="flex items-center gap-1">
|
392 |
+
<div className="i-ph:git-fork w-4 h-4" />
|
393 |
+
{connection.stats.totalForks} forks
|
394 |
+
</span>
|
395 |
+
</div>
|
396 |
+
</div>
|
397 |
+
</div>
|
398 |
+
</button>
|
399 |
+
|
400 |
+
{isStatsExpanded && (
|
401 |
+
<div className="pt-4">
|
402 |
+
{connection.stats.organizations.length > 0 && (
|
403 |
+
<div className="mb-6">
|
404 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Organizations</h4>
|
405 |
+
<div className="flex flex-wrap gap-3">
|
406 |
+
{connection.stats.organizations.map((org) => (
|
407 |
+
<a
|
408 |
+
key={org.login}
|
409 |
+
href={org.html_url}
|
410 |
+
target="_blank"
|
411 |
+
rel="noopener noreferrer"
|
412 |
+
className="flex items-center gap-2 p-2 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] hover:bg-[#F0F0F0] dark:hover:bg-[#252525] transition-colors"
|
413 |
+
>
|
414 |
+
<img src={org.avatar_url} alt={org.login} className="w-6 h-6 rounded-md" />
|
415 |
+
<span className="text-sm text-bolt-elements-textPrimary">{org.login}</span>
|
416 |
+
</a>
|
417 |
+
))}
|
418 |
+
</div>
|
419 |
+
</div>
|
420 |
+
)}
|
421 |
+
|
422 |
+
{/* Languages Section */}
|
423 |
+
<div className="mb-6">
|
424 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Top Languages</h4>
|
425 |
+
<div className="flex flex-wrap gap-2">
|
426 |
+
{Object.entries(connection.stats.languages)
|
427 |
+
.sort(([, a], [, b]) => b - a)
|
428 |
+
.slice(0, 5)
|
429 |
+
.map(([language]) => (
|
430 |
+
<span
|
431 |
+
key={language}
|
432 |
+
className="px-3 py-1 text-xs rounded-full bg-purple-500/10 text-purple-500 dark:bg-purple-500/20"
|
433 |
+
>
|
434 |
+
{language}
|
435 |
+
</span>
|
436 |
+
))}
|
437 |
+
</div>
|
438 |
+
</div>
|
439 |
+
|
440 |
+
{/* Recent Activity Section */}
|
441 |
+
<div className="mb-6">
|
442 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Recent Activity</h4>
|
443 |
+
<div className="space-y-3">
|
444 |
+
{connection.stats.recentActivity.map((event) => (
|
445 |
+
<div key={event.id} className="p-3 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] text-sm">
|
446 |
+
<div className="flex items-center gap-2 text-bolt-elements-textPrimary">
|
447 |
+
<div className="i-ph:git-commit w-4 h-4 text-bolt-elements-textSecondary" />
|
448 |
+
<span className="font-medium">{event.type.replace('Event', '')}</span>
|
449 |
+
<span>on</span>
|
450 |
+
<a
|
451 |
+
href={`https://github.com/${event.repo.name}`}
|
452 |
+
target="_blank"
|
453 |
+
rel="noopener noreferrer"
|
454 |
+
className="text-purple-500 hover:underline"
|
455 |
+
>
|
456 |
+
{event.repo.name}
|
457 |
+
</a>
|
458 |
+
</div>
|
459 |
+
<div className="mt-1 text-xs text-bolt-elements-textSecondary">
|
460 |
+
{new Date(event.created_at).toLocaleDateString()} at{' '}
|
461 |
+
{new Date(event.created_at).toLocaleTimeString()}
|
462 |
+
</div>
|
463 |
+
</div>
|
464 |
+
))}
|
465 |
+
</div>
|
466 |
+
</div>
|
467 |
+
|
468 |
+
{/* Additional Stats */}
|
469 |
+
<div className="grid grid-cols-4 gap-4 mb-6">
|
470 |
+
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
471 |
+
<div className="text-sm text-bolt-elements-textSecondary">Member Since</div>
|
472 |
+
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
473 |
+
{new Date(connection.user.created_at).toLocaleDateString()}
|
474 |
+
</div>
|
475 |
+
</div>
|
476 |
+
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
477 |
+
<div className="text-sm text-bolt-elements-textSecondary">Public Gists</div>
|
478 |
+
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
479 |
+
{connection.stats.totalGists}
|
480 |
+
</div>
|
481 |
+
</div>
|
482 |
+
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
483 |
+
<div className="text-sm text-bolt-elements-textSecondary">Organizations</div>
|
484 |
+
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
485 |
+
{connection.stats.organizations.length}
|
486 |
+
</div>
|
487 |
+
</div>
|
488 |
+
<div className="p-4 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A]">
|
489 |
+
<div className="text-sm text-bolt-elements-textSecondary">Languages</div>
|
490 |
+
<div className="text-lg font-medium text-bolt-elements-textPrimary">
|
491 |
+
{Object.keys(connection.stats.languages).length}
|
492 |
+
</div>
|
493 |
+
</div>
|
494 |
+
</div>
|
495 |
+
|
496 |
+
{/* Repositories Section */}
|
497 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary mb-3">Recent Repositories</h4>
|
498 |
+
<div className="space-y-3">
|
499 |
+
{connection.stats.repos.map((repo) => (
|
500 |
+
<a
|
501 |
+
key={repo.full_name}
|
502 |
+
href={repo.html_url}
|
503 |
+
target="_blank"
|
504 |
+
rel="noopener noreferrer"
|
505 |
+
className="block p-3 rounded-lg bg-[#F8F8F8] dark:bg-[#1A1A1A] hover:bg-[#F0F0F0] dark:hover:bg-[#252525] transition-colors"
|
506 |
+
>
|
507 |
+
<div className="flex items-center justify-between">
|
508 |
+
<div>
|
509 |
+
<h5 className="text-sm font-medium text-bolt-elements-textPrimary flex items-center gap-2">
|
510 |
+
<div className="i-ph:git-repository w-4 h-4 text-bolt-elements-textSecondary" />
|
511 |
+
{repo.name}
|
512 |
+
</h5>
|
513 |
+
{repo.description && (
|
514 |
+
<p className="text-xs text-bolt-elements-textSecondary mt-1">{repo.description}</p>
|
515 |
+
)}
|
516 |
+
<div className="flex items-center gap-2 mt-2 text-xs text-bolt-elements-textSecondary">
|
517 |
+
<span className="flex items-center gap-1">
|
518 |
+
<div className="i-ph:git-branch w-3 h-3" />
|
519 |
+
{repo.default_branch}
|
520 |
+
</span>
|
521 |
+
<span>•</span>
|
522 |
+
<span>Updated {new Date(repo.updated_at).toLocaleDateString()}</span>
|
523 |
+
</div>
|
524 |
+
</div>
|
525 |
+
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
|
526 |
+
<span className="flex items-center gap-1">
|
527 |
+
<div className="i-ph:star w-3 h-3" />
|
528 |
+
{repo.stargazers_count}
|
529 |
+
</span>
|
530 |
+
<span className="flex items-center gap-1">
|
531 |
+
<div className="i-ph:git-fork w-3 h-3" />
|
532 |
+
{repo.forks_count}
|
533 |
+
</span>
|
534 |
+
</div>
|
535 |
+
</div>
|
536 |
+
</a>
|
537 |
+
))}
|
538 |
+
</div>
|
539 |
+
</div>
|
540 |
+
)}
|
541 |
+
</div>
|
542 |
+
)}
|
543 |
+
</div>
|
544 |
+
</motion.div>
|
545 |
+
);
|
546 |
+
}
|
547 |
+
|
548 |
+
function LoadingSpinner() {
|
549 |
+
return (
|
550 |
+
<div className="flex items-center justify-center p-4">
|
551 |
+
<div className="flex items-center gap-2">
|
552 |
+
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
553 |
+
<span className="text-bolt-elements-textSecondary">Loading...</span>
|
554 |
+
</div>
|
555 |
+
</div>
|
556 |
+
);
|
557 |
+
}
|
INSTALLER
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
pip
|
ImportButtons.tsx
ADDED
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import type { Message } from 'ai';
|
2 |
+
import { toast } from 'react-toastify';
|
3 |
+
import { ImportFolderButton } from '~/components/chat/ImportFolderButton';
|
4 |
+
import { Button } from '~/components/ui/Button';
|
5 |
+
import { classNames } from '~/utils/classNames';
|
6 |
+
|
7 |
+
type ChatData = {
|
8 |
+
messages?: Message[]; // Standard Bolt format
|
9 |
+
description?: string; // Optional description
|
10 |
+
};
|
11 |
+
|
12 |
+
export function ImportButtons(importChat: ((description: string, messages: Message[]) => Promise<void>) | undefined) {
|
13 |
+
return (
|
14 |
+
<div className="flex flex-col items-center justify-center w-auto">
|
15 |
+
<input
|
16 |
+
type="file"
|
17 |
+
id="chat-import"
|
18 |
+
className="hidden"
|
19 |
+
accept=".json"
|
20 |
+
onChange={async (e) => {
|
21 |
+
const file = e.target.files?.[0];
|
22 |
+
|
23 |
+
if (file && importChat) {
|
24 |
+
try {
|
25 |
+
const reader = new FileReader();
|
26 |
+
|
27 |
+
reader.onload = async (e) => {
|
28 |
+
try {
|
29 |
+
const content = e.target?.result as string;
|
30 |
+
const data = JSON.parse(content) as ChatData;
|
31 |
+
|
32 |
+
// Standard format
|
33 |
+
if (Array.isArray(data.messages)) {
|
34 |
+
await importChat(data.description || 'Imported Chat', data.messages);
|
35 |
+
toast.success('Chat imported successfully');
|
36 |
+
|
37 |
+
return;
|
38 |
+
}
|
39 |
+
|
40 |
+
toast.error('Invalid chat file format');
|
41 |
+
} catch (error: unknown) {
|
42 |
+
if (error instanceof Error) {
|
43 |
+
toast.error('Failed to parse chat file: ' + error.message);
|
44 |
+
} else {
|
45 |
+
toast.error('Failed to parse chat file');
|
46 |
+
}
|
47 |
+
}
|
48 |
+
};
|
49 |
+
reader.onerror = () => toast.error('Failed to read chat file');
|
50 |
+
reader.readAsText(file);
|
51 |
+
} catch (error) {
|
52 |
+
toast.error(error instanceof Error ? error.message : 'Failed to import chat');
|
53 |
+
}
|
54 |
+
e.target.value = ''; // Reset file input
|
55 |
+
} else {
|
56 |
+
toast.error('Something went wrong');
|
57 |
+
}
|
58 |
+
}}
|
59 |
+
/>
|
60 |
+
<div className="flex flex-col items-center gap-4 max-w-2xl text-center">
|
61 |
+
<div className="flex gap-2">
|
62 |
+
<Button
|
63 |
+
onClick={() => {
|
64 |
+
const input = document.getElementById('chat-import');
|
65 |
+
input?.click();
|
66 |
+
}}
|
67 |
+
variant="outline"
|
68 |
+
size="lg"
|
69 |
+
className={classNames(
|
70 |
+
'gap-2 bg-[#F5F5F5] dark:bg-[#252525]',
|
71 |
+
'text-bolt-elements-textPrimary dark:text-white',
|
72 |
+
'hover:bg-[#E5E5E5] dark:hover:bg-[#333333]',
|
73 |
+
'border-[#E5E5E5] dark:border-[#333333]',
|
74 |
+
'h-10 px-4 py-2 min-w-[120px] justify-center',
|
75 |
+
'transition-all duration-200 ease-in-out',
|
76 |
+
)}
|
77 |
+
>
|
78 |
+
<span className="i-ph:upload-simple w-4 h-4" />
|
79 |
+
Import Chat
|
80 |
+
</Button>
|
81 |
+
<ImportFolderButton
|
82 |
+
importChat={importChat}
|
83 |
+
className={classNames(
|
84 |
+
'gap-2 bg-[#F5F5F5] dark:bg-[#252525]',
|
85 |
+
'text-bolt-elements-textPrimary dark:text-white',
|
86 |
+
'hover:bg-[#E5E5E5] dark:hover:bg-[#333333]',
|
87 |
+
'border border-[#E5E5E5] dark:border-[#333333]',
|
88 |
+
'h-10 px-4 py-2 min-w-[120px] justify-center',
|
89 |
+
'transition-all duration-200 ease-in-out rounded-lg',
|
90 |
+
)}
|
91 |
+
/>
|
92 |
+
</div>
|
93 |
+
</div>
|
94 |
+
</div>
|
95 |
+
);
|
96 |
+
}
|
ImportFolderButton.tsx
ADDED
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useState } from 'react';
|
2 |
+
import type { Message } from 'ai';
|
3 |
+
import { toast } from 'react-toastify';
|
4 |
+
import { MAX_FILES, isBinaryFile, shouldIncludeFile } from '~/utils/fileUtils';
|
5 |
+
import { createChatFromFolder } from '~/utils/folderImport';
|
6 |
+
import { logStore } from '~/lib/stores/logs'; // Assuming logStore is imported from this location
|
7 |
+
import { Button } from '~/components/ui/Button';
|
8 |
+
import { classNames } from '~/utils/classNames';
|
9 |
+
|
10 |
+
interface ImportFolderButtonProps {
|
11 |
+
className?: string;
|
12 |
+
importChat?: (description: string, messages: Message[]) => Promise<void>;
|
13 |
+
}
|
14 |
+
|
15 |
+
export const ImportFolderButton: React.FC<ImportFolderButtonProps> = ({ className, importChat }) => {
|
16 |
+
const [isLoading, setIsLoading] = useState(false);
|
17 |
+
|
18 |
+
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
19 |
+
const allFiles = Array.from(e.target.files || []);
|
20 |
+
|
21 |
+
const filteredFiles = allFiles.filter((file) => {
|
22 |
+
const path = file.webkitRelativePath.split('/').slice(1).join('/');
|
23 |
+
const include = shouldIncludeFile(path);
|
24 |
+
|
25 |
+
return include;
|
26 |
+
});
|
27 |
+
|
28 |
+
if (filteredFiles.length === 0) {
|
29 |
+
const error = new Error('No valid files found');
|
30 |
+
logStore.logError('File import failed - no valid files', error, { folderName: 'Unknown Folder' });
|
31 |
+
toast.error('No files found in the selected folder');
|
32 |
+
|
33 |
+
return;
|
34 |
+
}
|
35 |
+
|
36 |
+
if (filteredFiles.length > MAX_FILES) {
|
37 |
+
const error = new Error(`Too many files: ${filteredFiles.length}`);
|
38 |
+
logStore.logError('File import failed - too many files', error, {
|
39 |
+
fileCount: filteredFiles.length,
|
40 |
+
maxFiles: MAX_FILES,
|
41 |
+
});
|
42 |
+
toast.error(
|
43 |
+
`This folder contains ${filteredFiles.length.toLocaleString()} files. This product is not yet optimized for very large projects. Please select a folder with fewer than ${MAX_FILES.toLocaleString()} files.`,
|
44 |
+
);
|
45 |
+
|
46 |
+
return;
|
47 |
+
}
|
48 |
+
|
49 |
+
const folderName = filteredFiles[0]?.webkitRelativePath.split('/')[0] || 'Unknown Folder';
|
50 |
+
setIsLoading(true);
|
51 |
+
|
52 |
+
const loadingToast = toast.loading(`Importing ${folderName}...`);
|
53 |
+
|
54 |
+
try {
|
55 |
+
const fileChecks = await Promise.all(
|
56 |
+
filteredFiles.map(async (file) => ({
|
57 |
+
file,
|
58 |
+
isBinary: await isBinaryFile(file),
|
59 |
+
})),
|
60 |
+
);
|
61 |
+
|
62 |
+
const textFiles = fileChecks.filter((f) => !f.isBinary).map((f) => f.file);
|
63 |
+
const binaryFilePaths = fileChecks
|
64 |
+
.filter((f) => f.isBinary)
|
65 |
+
.map((f) => f.file.webkitRelativePath.split('/').slice(1).join('/'));
|
66 |
+
|
67 |
+
if (textFiles.length === 0) {
|
68 |
+
const error = new Error('No text files found');
|
69 |
+
logStore.logError('File import failed - no text files', error, { folderName });
|
70 |
+
toast.error('No text files found in the selected folder');
|
71 |
+
|
72 |
+
return;
|
73 |
+
}
|
74 |
+
|
75 |
+
if (binaryFilePaths.length > 0) {
|
76 |
+
logStore.logWarning(`Skipping binary files during import`, {
|
77 |
+
folderName,
|
78 |
+
binaryCount: binaryFilePaths.length,
|
79 |
+
});
|
80 |
+
toast.info(`Skipping ${binaryFilePaths.length} binary files`);
|
81 |
+
}
|
82 |
+
|
83 |
+
const messages = await createChatFromFolder(textFiles, binaryFilePaths, folderName);
|
84 |
+
|
85 |
+
if (importChat) {
|
86 |
+
await importChat(folderName, [...messages]);
|
87 |
+
}
|
88 |
+
|
89 |
+
logStore.logSystem('Folder imported successfully', {
|
90 |
+
folderName,
|
91 |
+
textFileCount: textFiles.length,
|
92 |
+
binaryFileCount: binaryFilePaths.length,
|
93 |
+
});
|
94 |
+
toast.success('Folder imported successfully');
|
95 |
+
} catch (error) {
|
96 |
+
logStore.logError('Failed to import folder', error, { folderName });
|
97 |
+
console.error('Failed to import folder:', error);
|
98 |
+
toast.error('Failed to import folder');
|
99 |
+
} finally {
|
100 |
+
setIsLoading(false);
|
101 |
+
toast.dismiss(loadingToast);
|
102 |
+
e.target.value = ''; // Reset file input
|
103 |
+
}
|
104 |
+
};
|
105 |
+
|
106 |
+
return (
|
107 |
+
<>
|
108 |
+
<input
|
109 |
+
type="file"
|
110 |
+
id="folder-import"
|
111 |
+
className="hidden"
|
112 |
+
webkitdirectory=""
|
113 |
+
directory=""
|
114 |
+
onChange={handleFileChange}
|
115 |
+
{...({} as any)}
|
116 |
+
/>
|
117 |
+
<Button
|
118 |
+
onClick={() => {
|
119 |
+
const input = document.getElementById('folder-import');
|
120 |
+
input?.click();
|
121 |
+
}}
|
122 |
+
title="Import Folder"
|
123 |
+
variant="outline"
|
124 |
+
size="lg"
|
125 |
+
className={classNames(
|
126 |
+
'gap-2 bg-[#F5F5F5] dark:bg-[#252525]',
|
127 |
+
'text-bolt-elements-textPrimary dark:text-white',
|
128 |
+
'hover:bg-[#E5E5E5] dark:hover:bg-[#333333]',
|
129 |
+
'border-[#E5E5E5] dark:border-[#333333]',
|
130 |
+
'h-10 px-4 py-2 min-w-[120px] justify-center',
|
131 |
+
'transition-all duration-200 ease-in-out',
|
132 |
+
className,
|
133 |
+
)}
|
134 |
+
disabled={isLoading}
|
135 |
+
>
|
136 |
+
<span className="i-ph:upload-simple w-4 h-4" />
|
137 |
+
{isLoading ? 'Importing...' : 'Import Folder'}
|
138 |
+
</Button>
|
139 |
+
</>
|
140 |
+
);
|
141 |
+
};
|
LICENSE
ADDED
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
All portions of this repository are under one of two licenses. The majority of the AutoGPT repository is under the MIT License below. The autogpt_platform folder is under the
|
2 |
+
Polyform Shield License.
|
3 |
+
|
4 |
+
|
5 |
+
MIT License
|
6 |
+
|
7 |
+
|
8 |
+
Copyright (c) 2023 Toran Bruce Richards
|
9 |
+
|
10 |
+
|
11 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
12 |
+
of this software and associated documentation files (the "Software"), to deal
|
13 |
+
in the Software without restriction, including without limitation the rights
|
14 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
15 |
+
copies of the Software, and to permit persons to whom the Software is
|
16 |
+
furnished to do so, subject to the following conditions:
|
17 |
+
|
18 |
+
|
19 |
+
The above copyright notice and this permission notice shall be included in all
|
20 |
+
copies or substantial portions of the Software.
|
21 |
+
|
22 |
+
|
23 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
24 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
25 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
26 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
27 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
28 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
29 |
+
SOFTWARE.
|
LICENSE.APACHE2
ADDED
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
Apache License
|
3 |
+
Version 2.0, January 2004
|
4 |
+
http://www.apache.org/licenses/
|
5 |
+
|
6 |
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
7 |
+
|
8 |
+
1. Definitions.
|
9 |
+
|
10 |
+
"License" shall mean the terms and conditions for use, reproduction,
|
11 |
+
and distribution as defined by Sections 1 through 9 of this document.
|
12 |
+
|
13 |
+
"Licensor" shall mean the copyright owner or entity authorized by
|
14 |
+
the copyright owner that is granting the License.
|
15 |
+
|
16 |
+
"Legal Entity" shall mean the union of the acting entity and all
|
17 |
+
other entities that control, are controlled by, or are under common
|
18 |
+
control with that entity. For the purposes of this definition,
|
19 |
+
"control" means (i) the power, direct or indirect, to cause the
|
20 |
+
direction or management of such entity, whether by contract or
|
21 |
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
22 |
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
23 |
+
|
24 |
+
"You" (or "Your") shall mean an individual or Legal Entity
|
25 |
+
exercising permissions granted by this License.
|
26 |
+
|
27 |
+
"Source" form shall mean the preferred form for making modifications,
|
28 |
+
including but not limited to software source code, documentation
|
29 |
+
source, and configuration files.
|
30 |
+
|
31 |
+
"Object" form shall mean any form resulting from mechanical
|
32 |
+
transformation or translation of a Source form, including but
|
33 |
+
not limited to compiled object code, generated documentation,
|
34 |
+
and conversions to other media types.
|
35 |
+
|
36 |
+
"Work" shall mean the work of authorship, whether in Source or
|
37 |
+
Object form, made available under the License, as indicated by a
|
38 |
+
copyright notice that is included in or attached to the work
|
39 |
+
(an example is provided in the Appendix below).
|
40 |
+
|
41 |
+
"Derivative Works" shall mean any work, whether in Source or Object
|
42 |
+
form, that is based on (or derived from) the Work and for which the
|
43 |
+
editorial revisions, annotations, elaborations, or other modifications
|
44 |
+
represent, as a whole, an original work of authorship. For the purposes
|
45 |
+
of this License, Derivative Works shall not include works that remain
|
46 |
+
separable from, or merely link (or bind by name) to the interfaces of,
|
47 |
+
the Work and Derivative Works thereof.
|
48 |
+
|
49 |
+
"Contribution" shall mean any work of authorship, including
|
50 |
+
the original version of the Work and any modifications or additions
|
51 |
+
to that Work or Derivative Works thereof, that is intentionally
|
52 |
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
53 |
+
or by an individual or Legal Entity authorized to submit on behalf of
|
54 |
+
the copyright owner. For the purposes of this definition, "submitted"
|
55 |
+
means any form of electronic, verbal, or written communication sent
|
56 |
+
to the Licensor or its representatives, including but not limited to
|
57 |
+
communication on electronic mailing lists, source code control systems,
|
58 |
+
and issue tracking systems that are managed by, or on behalf of, the
|
59 |
+
Licensor for the purpose of discussing and improving the Work, but
|
60 |
+
excluding communication that is conspicuously marked or otherwise
|
61 |
+
designated in writing by the copyright owner as "Not a Contribution."
|
62 |
+
|
63 |
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
64 |
+
on behalf of whom a Contribution has been received by Licensor and
|
65 |
+
subsequently incorporated within the Work.
|
66 |
+
|
67 |
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
68 |
+
this License, each Contributor hereby grants to You a perpetual,
|
69 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
70 |
+
copyright license to reproduce, prepare Derivative Works of,
|
71 |
+
publicly display, publicly perform, sublicense, and distribute the
|
72 |
+
Work and such Derivative Works in Source or Object form.
|
73 |
+
|
74 |
+
3. Grant of Patent License. Subject to the terms and conditions of
|
75 |
+
this License, each Contributor hereby grants to You a perpetual,
|
76 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
77 |
+
(except as stated in this section) patent license to make, have made,
|
78 |
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
79 |
+
where such license applies only to those patent claims licensable
|
80 |
+
by such Contributor that are necessarily infringed by their
|
81 |
+
Contribution(s) alone or by combination of their Contribution(s)
|
82 |
+
with the Work to which such Contribution(s) was submitted. If You
|
83 |
+
institute patent litigation against any entity (including a
|
84 |
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
85 |
+
or a Contribution incorporated within the Work constitutes direct
|
86 |
+
or contributory patent infringement, then any patent licenses
|
87 |
+
granted to You under this License for that Work shall terminate
|
88 |
+
as of the date such litigation is filed.
|
89 |
+
|
90 |
+
4. Redistribution. You may reproduce and distribute copies of the
|
91 |
+
Work or Derivative Works thereof in any medium, with or without
|
92 |
+
modifications, and in Source or Object form, provided that You
|
93 |
+
meet the following conditions:
|
94 |
+
|
95 |
+
(a) You must give any other recipients of the Work or
|
96 |
+
Derivative Works a copy of this License; and
|
97 |
+
|
98 |
+
(b) You must cause any modified files to carry prominent notices
|
99 |
+
stating that You changed the files; and
|
100 |
+
|
101 |
+
(c) You must retain, in the Source form of any Derivative Works
|
102 |
+
that You distribute, all copyright, patent, trademark, and
|
103 |
+
attribution notices from the Source form of the Work,
|
104 |
+
excluding those notices that do not pertain to any part of
|
105 |
+
the Derivative Works; and
|
106 |
+
|
107 |
+
(d) If the Work includes a "NOTICE" text file as part of its
|
108 |
+
distribution, then any Derivative Works that You distribute must
|
109 |
+
include a readable copy of the attribution notices contained
|
110 |
+
within such NOTICE file, excluding those notices that do not
|
111 |
+
pertain to any part of the Derivative Works, in at least one
|
112 |
+
of the following places: within a NOTICE text file distributed
|
113 |
+
as part of the Derivative Works; within the Source form or
|
114 |
+
documentation, if provided along with the Derivative Works; or,
|
115 |
+
within a display generated by the Derivative Works, if and
|
116 |
+
wherever such third-party notices normally appear. The contents
|
117 |
+
of the NOTICE file are for informational purposes only and
|
118 |
+
do not modify the License. You may add Your own attribution
|
119 |
+
notices within Derivative Works that You distribute, alongside
|
120 |
+
or as an addendum to the NOTICE text from the Work, provided
|
121 |
+
that such additional attribution notices cannot be construed
|
122 |
+
as modifying the License.
|
123 |
+
|
124 |
+
You may add Your own copyright statement to Your modifications and
|
125 |
+
may provide additional or different license terms and conditions
|
126 |
+
for use, reproduction, or distribution of Your modifications, or
|
127 |
+
for any such Derivative Works as a whole, provided Your use,
|
128 |
+
reproduction, and distribution of the Work otherwise complies with
|
129 |
+
the conditions stated in this License.
|
130 |
+
|
131 |
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
132 |
+
any Contribution intentionally submitted for inclusion in the Work
|
133 |
+
by You to the Licensor shall be under the terms and conditions of
|
134 |
+
this License, without any additional terms or conditions.
|
135 |
+
Notwithstanding the above, nothing herein shall supersede or modify
|
136 |
+
the terms of any separate license agreement you may have executed
|
137 |
+
with Licensor regarding such Contributions.
|
138 |
+
|
139 |
+
6. Trademarks. This License does not grant permission to use the trade
|
140 |
+
names, trademarks, service marks, or product names of the Licensor,
|
141 |
+
except as required for reasonable and customary use in describing the
|
142 |
+
origin of the Work and reproducing the content of the NOTICE file.
|
143 |
+
|
144 |
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
145 |
+
agreed to in writing, Licensor provides the Work (and each
|
146 |
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
147 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
148 |
+
implied, including, without limitation, any warranties or conditions
|
149 |
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
150 |
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
151 |
+
appropriateness of using or redistributing the Work and assume any
|
152 |
+
risks associated with Your exercise of permissions under this License.
|
153 |
+
|
154 |
+
8. Limitation of Liability. In no event and under no legal theory,
|
155 |
+
whether in tort (including negligence), contract, or otherwise,
|
156 |
+
unless required by applicable law (such as deliberate and grossly
|
157 |
+
negligent acts) or agreed to in writing, shall any Contributor be
|
158 |
+
liable to You for damages, including any direct, indirect, special,
|
159 |
+
incidental, or consequential damages of any character arising as a
|
160 |
+
result of this License or out of the use or inability to use the
|
161 |
+
Work (including but not limited to damages for loss of goodwill,
|
162 |
+
work stoppage, computer failure or malfunction, or any and all
|
163 |
+
other commercial damages or losses), even if such Contributor
|
164 |
+
has been advised of the possibility of such damages.
|
165 |
+
|
166 |
+
9. Accepting Warranty or Additional Liability. While redistributing
|
167 |
+
the Work or Derivative Works thereof, You may choose to offer,
|
168 |
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
169 |
+
or other liability obligations and/or rights consistent with this
|
170 |
+
License. However, in accepting such obligations, You may act only
|
171 |
+
on Your own behalf and on Your sole responsibility, not on behalf
|
172 |
+
of any other Contributor, and only if You agree to indemnify,
|
173 |
+
defend, and hold each Contributor harmless for any liability
|
174 |
+
incurred by, or claims asserted against, such Contributor by reason
|
175 |
+
of your accepting any such warranty or additional liability.
|
176 |
+
|
177 |
+
END OF TERMS AND CONDITIONS
|
178 |
+
|
179 |
+
APPENDIX: How to apply the Apache License to your work.
|
180 |
+
|
181 |
+
To apply the Apache License to your work, attach the following
|
182 |
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
183 |
+
replaced with your own identifying information. (Don't include
|
184 |
+
the brackets!) The text should be enclosed in the appropriate
|
185 |
+
comment syntax for the file format. We also recommend that a
|
186 |
+
file or class name and description of purpose be included on the
|
187 |
+
same "printed page" as the copyright notice for easier
|
188 |
+
identification within third-party archives.
|
189 |
+
|
190 |
+
Copyright [yyyy] [name of copyright owner]
|
191 |
+
|
192 |
+
Licensed under the Apache License, Version 2.0 (the "License");
|
193 |
+
you may not use this file except in compliance with the License.
|
194 |
+
You may obtain a copy of the License at
|
195 |
+
|
196 |
+
http://www.apache.org/licenses/LICENSE-2.0
|
197 |
+
|
198 |
+
Unless required by applicable law or agreed to in writing, software
|
199 |
+
distributed under the License is distributed on an "AS IS" BASIS,
|
200 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
201 |
+
See the License for the specific language governing permissions and
|
202 |
+
limitations under the License.
|
LICENSE.MIT
ADDED
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
The MIT License (MIT)
|
2 |
+
|
3 |
+
Permission is hereby granted, free of charge, to any person obtaining
|
4 |
+
a copy of this software and associated documentation files (the
|
5 |
+
"Software"), to deal in the Software without restriction, including
|
6 |
+
without limitation the rights to use, copy, modify, merge, publish,
|
7 |
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8 |
+
permit persons to whom the Software is furnished to do so, subject to
|
9 |
+
the following conditions:
|
10 |
+
|
11 |
+
The above copyright notice and this permission notice shall be
|
12 |
+
included in all copies or substantial portions of the Software.
|
13 |
+
|
14 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15 |
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16 |
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17 |
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18 |
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19 |
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20 |
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
LICENSE.PSF
ADDED
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2
|
2 |
+
--------------------------------------------
|
3 |
+
|
4 |
+
1. This LICENSE AGREEMENT is between the Python Software Foundation
|
5 |
+
("PSF"), and the Individual or Organization ("Licensee") accessing and
|
6 |
+
otherwise using this software ("Python") in source or binary form and
|
7 |
+
its associated documentation.
|
8 |
+
|
9 |
+
2. Subject to the terms and conditions of this License Agreement, PSF hereby
|
10 |
+
grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce,
|
11 |
+
analyze, test, perform and/or display publicly, prepare derivative works,
|
12 |
+
distribute, and otherwise use Python alone or in any derivative version,
|
13 |
+
provided, however, that PSF's License Agreement and PSF's notice of copyright,
|
14 |
+
i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010,
|
15 |
+
2011 Python Software Foundation; All Rights Reserved" are retained in Python
|
16 |
+
alone or in any derivative version prepared by Licensee.
|
17 |
+
|
18 |
+
3. In the event Licensee prepares a derivative work that is based on
|
19 |
+
or incorporates Python or any part thereof, and wants to make
|
20 |
+
the derivative work available to others as provided herein, then
|
21 |
+
Licensee hereby agrees to include in any such work a brief summary of
|
22 |
+
the changes made to Python.
|
23 |
+
|
24 |
+
4. PSF is making Python available to Licensee on an "AS IS"
|
25 |
+
basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
|
26 |
+
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND
|
27 |
+
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
|
28 |
+
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT
|
29 |
+
INFRINGE ANY THIRD PARTY RIGHTS.
|
30 |
+
|
31 |
+
5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
|
32 |
+
FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
|
33 |
+
A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON,
|
34 |
+
OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
|
35 |
+
|
36 |
+
6. This License Agreement will automatically terminate upon a material
|
37 |
+
breach of its terms and conditions.
|
38 |
+
|
39 |
+
7. Nothing in this License Agreement shall be deemed to create any
|
40 |
+
relationship of agency, partnership, or joint venture between PSF and
|
41 |
+
Licensee. This License Agreement does not grant permission to use PSF
|
42 |
+
trademarks or trade name in a trademark sense to endorse or promote
|
43 |
+
products or services of Licensee, or any third party.
|
44 |
+
|
45 |
+
8. By copying, installing or otherwise using Python, Licensee
|
46 |
+
agrees to be bound by the terms and conditions of this License
|
47 |
+
Agreement.
|
LICENSE.md
ADDED
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
BSD 3-Clause License
|
2 |
+
|
3 |
+
Copyright (c) 2013-2024, Kim Davies and contributors.
|
4 |
+
All rights reserved.
|
5 |
+
|
6 |
+
Redistribution and use in source and binary forms, with or without
|
7 |
+
modification, are permitted provided that the following conditions are
|
8 |
+
met:
|
9 |
+
|
10 |
+
1. Redistributions of source code must retain the above copyright
|
11 |
+
notice, this list of conditions and the following disclaimer.
|
12 |
+
|
13 |
+
2. Redistributions in binary form must reproduce the above copyright
|
14 |
+
notice, this list of conditions and the following disclaimer in the
|
15 |
+
documentation and/or other materials provided with the distribution.
|
16 |
+
|
17 |
+
3. Neither the name of the copyright holder nor the names of its
|
18 |
+
contributors may be used to endorse or promote products derived from
|
19 |
+
this software without specific prior written permission.
|
20 |
+
|
21 |
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
22 |
+
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
23 |
+
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
24 |
+
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
25 |
+
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
26 |
+
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
|
27 |
+
TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
28 |
+
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
29 |
+
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
30 |
+
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
31 |
+
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
LICENSE.txt
ADDED
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
Copyright (c) 2008-2021 The pip developers (see AUTHORS.txt file)
|
2 |
+
|
3 |
+
Permission is hereby granted, free of charge, to any person obtaining
|
4 |
+
a copy of this software and associated documentation files (the
|
5 |
+
"Software"), to deal in the Software without restriction, including
|
6 |
+
without limitation the rights to use, copy, modify, merge, publish,
|
7 |
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8 |
+
permit persons to whom the Software is furnished to do so, subject to
|
9 |
+
the following conditions:
|
10 |
+
|
11 |
+
The above copyright notice and this permission notice shall be
|
12 |
+
included in all copies or substantial portions of the Software.
|
13 |
+
|
14 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15 |
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16 |
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17 |
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18 |
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19 |
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20 |
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
Layout.tsx
ADDED
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// /home/ubuntu/visionos-frontend/src/components/Layout.tsx
|
2 |
+
import React from 'react';
|
3 |
+
import Link from 'next/link'; // Import Link for navigation
|
4 |
+
|
5 |
+
interface LayoutProps {
|
6 |
+
children: React.ReactNode;
|
7 |
+
}
|
8 |
+
|
9 |
+
const Layout: React.FC<LayoutProps> = ({ children }) => {
|
10 |
+
return (
|
11 |
+
<div className="flex flex-col min-h-screen">
|
12 |
+
{/* Header/Navigation */}
|
13 |
+
<header className="bg-gray-800 text-white p-4">
|
14 |
+
<nav className="container mx-auto flex justify-between items-center">
|
15 |
+
<h1 className="text-xl font-bold">
|
16 |
+
<Link href="/">VisionOS UI</Link>
|
17 |
+
</h1>
|
18 |
+
<ul className="flex space-x-4">
|
19 |
+
<li><Link href="/" className="hover:text-gray-300">Chat</Link></li>
|
20 |
+
<li><Link href="/workflow" className="hover:text-gray-300">Workflow</Link></li>
|
21 |
+
<li><Link href="/settings" className="hover:text-gray-300">Settings</Link></li>
|
22 |
+
{/* Add more navigation links here */}
|
23 |
+
</ul>
|
24 |
+
</nav>
|
25 |
+
</header>
|
26 |
+
|
27 |
+
{/* Main Content Area */}
|
28 |
+
<main className="flex-grow p-4 container mx-auto">
|
29 |
+
{children}
|
30 |
+
</main>
|
31 |
+
|
32 |
+
{/* Footer */}
|
33 |
+
<footer className="bg-gray-200 p-4 text-center text-sm text-gray-600">
|
34 |
+
© 2025 VisionOS
|
35 |
+
</footer>
|
36 |
+
</div>
|
37 |
+
);
|
38 |
+
};
|
39 |
+
|
40 |
+
export default Layout;
|
41 |
+
|
LocalProvidersTab.tsx
ADDED
@@ -0,0 +1,777 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useEffect, useState, useCallback } from 'react';
|
2 |
+
import { Switch } from '~/components/ui/Switch';
|
3 |
+
import { useSettings } from '~/lib/hooks/useSettings';
|
4 |
+
import { LOCAL_PROVIDERS, URL_CONFIGURABLE_PROVIDERS } from '~/lib/stores/settings';
|
5 |
+
import type { IProviderConfig } from '~/types/model';
|
6 |
+
import { logStore } from '~/lib/stores/logs';
|
7 |
+
import { motion, AnimatePresence } from 'framer-motion';
|
8 |
+
import { classNames } from '~/utils/classNames';
|
9 |
+
import { BsRobot } from 'react-icons/bs';
|
10 |
+
import type { IconType } from 'react-icons';
|
11 |
+
import { BiChip } from 'react-icons/bi';
|
12 |
+
import { TbBrandOpenai } from 'react-icons/tb';
|
13 |
+
import { providerBaseUrlEnvKeys } from '~/utils/constants';
|
14 |
+
import { useToast } from '~/components/ui/use-toast';
|
15 |
+
import { Progress } from '~/components/ui/Progress';
|
16 |
+
import OllamaModelInstaller from './OllamaModelInstaller';
|
17 |
+
|
18 |
+
// Add type for provider names to ensure type safety
|
19 |
+
type ProviderName = 'Ollama' | 'LMStudio' | 'OpenAILike';
|
20 |
+
|
21 |
+
// Update the PROVIDER_ICONS type to use the ProviderName type
|
22 |
+
const PROVIDER_ICONS: Record<ProviderName, IconType> = {
|
23 |
+
Ollama: BsRobot,
|
24 |
+
LMStudio: BsRobot,
|
25 |
+
OpenAILike: TbBrandOpenai,
|
26 |
+
};
|
27 |
+
|
28 |
+
// Update PROVIDER_DESCRIPTIONS to use the same type
|
29 |
+
const PROVIDER_DESCRIPTIONS: Record<ProviderName, string> = {
|
30 |
+
Ollama: 'Run open-source models locally on your machine',
|
31 |
+
LMStudio: 'Local model inference with LM Studio',
|
32 |
+
OpenAILike: 'Connect to OpenAI-compatible API endpoints',
|
33 |
+
};
|
34 |
+
|
35 |
+
// Add a constant for the Ollama API base URL
|
36 |
+
const OLLAMA_API_URL = 'http://127.0.0.1:11434';
|
37 |
+
|
38 |
+
interface OllamaModel {
|
39 |
+
name: string;
|
40 |
+
digest: string;
|
41 |
+
size: number;
|
42 |
+
modified_at: string;
|
43 |
+
details?: {
|
44 |
+
family: string;
|
45 |
+
parameter_size: string;
|
46 |
+
quantization_level: string;
|
47 |
+
};
|
48 |
+
status?: 'idle' | 'updating' | 'updated' | 'error' | 'checking';
|
49 |
+
error?: string;
|
50 |
+
newDigest?: string;
|
51 |
+
progress?: {
|
52 |
+
current: number;
|
53 |
+
total: number;
|
54 |
+
status: string;
|
55 |
+
};
|
56 |
+
}
|
57 |
+
|
58 |
+
interface OllamaPullResponse {
|
59 |
+
status: string;
|
60 |
+
completed?: number;
|
61 |
+
total?: number;
|
62 |
+
digest?: string;
|
63 |
+
}
|
64 |
+
|
65 |
+
const isOllamaPullResponse = (data: unknown): data is OllamaPullResponse => {
|
66 |
+
return (
|
67 |
+
typeof data === 'object' &&
|
68 |
+
data !== null &&
|
69 |
+
'status' in data &&
|
70 |
+
typeof (data as OllamaPullResponse).status === 'string'
|
71 |
+
);
|
72 |
+
};
|
73 |
+
|
74 |
+
export default function LocalProvidersTab() {
|
75 |
+
const { providers, updateProviderSettings } = useSettings();
|
76 |
+
const [filteredProviders, setFilteredProviders] = useState<IProviderConfig[]>([]);
|
77 |
+
const [categoryEnabled, setCategoryEnabled] = useState(false);
|
78 |
+
const [ollamaModels, setOllamaModels] = useState<OllamaModel[]>([]);
|
79 |
+
const [isLoadingModels, setIsLoadingModels] = useState(false);
|
80 |
+
const [editingProvider, setEditingProvider] = useState<string | null>(null);
|
81 |
+
const { toast } = useToast();
|
82 |
+
|
83 |
+
// Effect to filter and sort providers
|
84 |
+
useEffect(() => {
|
85 |
+
const newFilteredProviders = Object.entries(providers || {})
|
86 |
+
.filter(([key]) => [...LOCAL_PROVIDERS, 'OpenAILike'].includes(key))
|
87 |
+
.map(([key, value]) => {
|
88 |
+
const provider = value as IProviderConfig;
|
89 |
+
const envKey = providerBaseUrlEnvKeys[key]?.baseUrlKey;
|
90 |
+
const envUrl = envKey ? (import.meta.env[envKey] as string | undefined) : undefined;
|
91 |
+
|
92 |
+
// Set base URL if provided by environment
|
93 |
+
if (envUrl && !provider.settings.baseUrl) {
|
94 |
+
updateProviderSettings(key, {
|
95 |
+
...provider.settings,
|
96 |
+
baseUrl: envUrl,
|
97 |
+
});
|
98 |
+
}
|
99 |
+
|
100 |
+
return {
|
101 |
+
name: key,
|
102 |
+
settings: {
|
103 |
+
...provider.settings,
|
104 |
+
baseUrl: provider.settings.baseUrl || envUrl,
|
105 |
+
},
|
106 |
+
staticModels: provider.staticModels || [],
|
107 |
+
getDynamicModels: provider.getDynamicModels,
|
108 |
+
getApiKeyLink: provider.getApiKeyLink,
|
109 |
+
labelForGetApiKey: provider.labelForGetApiKey,
|
110 |
+
icon: provider.icon,
|
111 |
+
} as IProviderConfig;
|
112 |
+
});
|
113 |
+
|
114 |
+
// Custom sort function to ensure LMStudio appears before OpenAILike
|
115 |
+
const sorted = newFilteredProviders.sort((a, b) => {
|
116 |
+
if (a.name === 'LMStudio') {
|
117 |
+
return -1;
|
118 |
+
}
|
119 |
+
|
120 |
+
if (b.name === 'LMStudio') {
|
121 |
+
return 1;
|
122 |
+
}
|
123 |
+
|
124 |
+
if (a.name === 'OpenAILike') {
|
125 |
+
return 1;
|
126 |
+
}
|
127 |
+
|
128 |
+
if (b.name === 'OpenAILike') {
|
129 |
+
return -1;
|
130 |
+
}
|
131 |
+
|
132 |
+
return a.name.localeCompare(b.name);
|
133 |
+
});
|
134 |
+
setFilteredProviders(sorted);
|
135 |
+
}, [providers, updateProviderSettings]);
|
136 |
+
|
137 |
+
// Add effect to update category toggle state based on provider states
|
138 |
+
useEffect(() => {
|
139 |
+
const newCategoryState = filteredProviders.every((p) => p.settings.enabled);
|
140 |
+
setCategoryEnabled(newCategoryState);
|
141 |
+
}, [filteredProviders]);
|
142 |
+
|
143 |
+
// Fetch Ollama models when enabled
|
144 |
+
useEffect(() => {
|
145 |
+
const ollamaProvider = filteredProviders.find((p) => p.name === 'Ollama');
|
146 |
+
|
147 |
+
if (ollamaProvider?.settings.enabled) {
|
148 |
+
fetchOllamaModels();
|
149 |
+
}
|
150 |
+
}, [filteredProviders]);
|
151 |
+
|
152 |
+
const fetchOllamaModels = async () => {
|
153 |
+
try {
|
154 |
+
setIsLoadingModels(true);
|
155 |
+
|
156 |
+
const response = await fetch('http://127.0.0.1:11434/api/tags');
|
157 |
+
const data = (await response.json()) as { models: OllamaModel[] };
|
158 |
+
|
159 |
+
setOllamaModels(
|
160 |
+
data.models.map((model) => ({
|
161 |
+
...model,
|
162 |
+
status: 'idle' as const,
|
163 |
+
})),
|
164 |
+
);
|
165 |
+
} catch (error) {
|
166 |
+
console.error('Error fetching Ollama models:', error);
|
167 |
+
} finally {
|
168 |
+
setIsLoadingModels(false);
|
169 |
+
}
|
170 |
+
};
|
171 |
+
|
172 |
+
const updateOllamaModel = async (modelName: string): Promise<boolean> => {
|
173 |
+
try {
|
174 |
+
const response = await fetch(`${OLLAMA_API_URL}/api/pull`, {
|
175 |
+
method: 'POST',
|
176 |
+
headers: { 'Content-Type': 'application/json' },
|
177 |
+
body: JSON.stringify({ name: modelName }),
|
178 |
+
});
|
179 |
+
|
180 |
+
if (!response.ok) {
|
181 |
+
throw new Error(`Failed to update ${modelName}`);
|
182 |
+
}
|
183 |
+
|
184 |
+
const reader = response.body?.getReader();
|
185 |
+
|
186 |
+
if (!reader) {
|
187 |
+
throw new Error('No response reader available');
|
188 |
+
}
|
189 |
+
|
190 |
+
while (true) {
|
191 |
+
const { done, value } = await reader.read();
|
192 |
+
|
193 |
+
if (done) {
|
194 |
+
break;
|
195 |
+
}
|
196 |
+
|
197 |
+
const text = new TextDecoder().decode(value);
|
198 |
+
const lines = text.split('\n').filter(Boolean);
|
199 |
+
|
200 |
+
for (const line of lines) {
|
201 |
+
const rawData = JSON.parse(line);
|
202 |
+
|
203 |
+
if (!isOllamaPullResponse(rawData)) {
|
204 |
+
console.error('Invalid response format:', rawData);
|
205 |
+
continue;
|
206 |
+
}
|
207 |
+
|
208 |
+
setOllamaModels((current) =>
|
209 |
+
current.map((m) =>
|
210 |
+
m.name === modelName
|
211 |
+
? {
|
212 |
+
...m,
|
213 |
+
progress: {
|
214 |
+
current: rawData.completed || 0,
|
215 |
+
total: rawData.total || 0,
|
216 |
+
status: rawData.status,
|
217 |
+
},
|
218 |
+
newDigest: rawData.digest,
|
219 |
+
}
|
220 |
+
: m,
|
221 |
+
),
|
222 |
+
);
|
223 |
+
}
|
224 |
+
}
|
225 |
+
|
226 |
+
const updatedResponse = await fetch('http://127.0.0.1:11434/api/tags');
|
227 |
+
const updatedData = (await updatedResponse.json()) as { models: OllamaModel[] };
|
228 |
+
const updatedModel = updatedData.models.find((m) => m.name === modelName);
|
229 |
+
|
230 |
+
return updatedModel !== undefined;
|
231 |
+
} catch (error) {
|
232 |
+
console.error(`Error updating ${modelName}:`, error);
|
233 |
+
return false;
|
234 |
+
}
|
235 |
+
};
|
236 |
+
|
237 |
+
const handleToggleCategory = useCallback(
|
238 |
+
async (enabled: boolean) => {
|
239 |
+
filteredProviders.forEach((provider) => {
|
240 |
+
updateProviderSettings(provider.name, { ...provider.settings, enabled });
|
241 |
+
});
|
242 |
+
toast(enabled ? 'All local providers enabled' : 'All local providers disabled');
|
243 |
+
},
|
244 |
+
[filteredProviders, updateProviderSettings],
|
245 |
+
);
|
246 |
+
|
247 |
+
const handleToggleProvider = (provider: IProviderConfig, enabled: boolean) => {
|
248 |
+
updateProviderSettings(provider.name, {
|
249 |
+
...provider.settings,
|
250 |
+
enabled,
|
251 |
+
});
|
252 |
+
|
253 |
+
if (enabled) {
|
254 |
+
logStore.logProvider(`Provider ${provider.name} enabled`, { provider: provider.name });
|
255 |
+
toast(`${provider.name} enabled`);
|
256 |
+
} else {
|
257 |
+
logStore.logProvider(`Provider ${provider.name} disabled`, { provider: provider.name });
|
258 |
+
toast(`${provider.name} disabled`);
|
259 |
+
}
|
260 |
+
};
|
261 |
+
|
262 |
+
const handleUpdateBaseUrl = (provider: IProviderConfig, newBaseUrl: string) => {
|
263 |
+
updateProviderSettings(provider.name, {
|
264 |
+
...provider.settings,
|
265 |
+
baseUrl: newBaseUrl,
|
266 |
+
});
|
267 |
+
toast(`${provider.name} base URL updated`);
|
268 |
+
setEditingProvider(null);
|
269 |
+
};
|
270 |
+
|
271 |
+
const handleUpdateOllamaModel = async (modelName: string) => {
|
272 |
+
const updateSuccess = await updateOllamaModel(modelName);
|
273 |
+
|
274 |
+
if (updateSuccess) {
|
275 |
+
toast(`Updated ${modelName}`);
|
276 |
+
} else {
|
277 |
+
toast(`Failed to update ${modelName}`);
|
278 |
+
}
|
279 |
+
};
|
280 |
+
|
281 |
+
const handleDeleteOllamaModel = async (modelName: string) => {
|
282 |
+
try {
|
283 |
+
const response = await fetch(`${OLLAMA_API_URL}/api/delete`, {
|
284 |
+
method: 'DELETE',
|
285 |
+
headers: {
|
286 |
+
'Content-Type': 'application/json',
|
287 |
+
},
|
288 |
+
body: JSON.stringify({ name: modelName }),
|
289 |
+
});
|
290 |
+
|
291 |
+
if (!response.ok) {
|
292 |
+
throw new Error(`Failed to delete ${modelName}`);
|
293 |
+
}
|
294 |
+
|
295 |
+
setOllamaModels((current) => current.filter((m) => m.name !== modelName));
|
296 |
+
toast(`Deleted ${modelName}`);
|
297 |
+
} catch (err) {
|
298 |
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
299 |
+
console.error(`Error deleting ${modelName}:`, errorMessage);
|
300 |
+
toast(`Failed to delete ${modelName}`);
|
301 |
+
}
|
302 |
+
};
|
303 |
+
|
304 |
+
// Update model details display
|
305 |
+
const ModelDetails = ({ model }: { model: OllamaModel }) => (
|
306 |
+
<div className="flex items-center gap-3 text-xs text-bolt-elements-textSecondary">
|
307 |
+
<div className="flex items-center gap-1">
|
308 |
+
<div className="i-ph:code text-purple-500" />
|
309 |
+
<span>{model.digest.substring(0, 7)}</span>
|
310 |
+
</div>
|
311 |
+
{model.details && (
|
312 |
+
<>
|
313 |
+
<div className="flex items-center gap-1">
|
314 |
+
<div className="i-ph:database text-purple-500" />
|
315 |
+
<span>{model.details.parameter_size}</span>
|
316 |
+
</div>
|
317 |
+
<div className="flex items-center gap-1">
|
318 |
+
<div className="i-ph:cube text-purple-500" />
|
319 |
+
<span>{model.details.quantization_level}</span>
|
320 |
+
</div>
|
321 |
+
</>
|
322 |
+
)}
|
323 |
+
</div>
|
324 |
+
);
|
325 |
+
|
326 |
+
// Update model actions to not use Tooltip
|
327 |
+
const ModelActions = ({
|
328 |
+
model,
|
329 |
+
onUpdate,
|
330 |
+
onDelete,
|
331 |
+
}: {
|
332 |
+
model: OllamaModel;
|
333 |
+
onUpdate: () => void;
|
334 |
+
onDelete: () => void;
|
335 |
+
}) => (
|
336 |
+
<div className="flex items-center gap-2">
|
337 |
+
<motion.button
|
338 |
+
onClick={onUpdate}
|
339 |
+
disabled={model.status === 'updating'}
|
340 |
+
className={classNames(
|
341 |
+
'rounded-lg p-2',
|
342 |
+
'bg-purple-500/10 text-purple-500',
|
343 |
+
'hover:bg-purple-500/20',
|
344 |
+
'transition-all duration-200',
|
345 |
+
{ 'opacity-50 cursor-not-allowed': model.status === 'updating' },
|
346 |
+
)}
|
347 |
+
whileHover={{ scale: 1.05 }}
|
348 |
+
whileTap={{ scale: 0.95 }}
|
349 |
+
title="Update model"
|
350 |
+
>
|
351 |
+
{model.status === 'updating' ? (
|
352 |
+
<div className="flex items-center gap-2">
|
353 |
+
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
354 |
+
<span className="text-sm">Updating...</span>
|
355 |
+
</div>
|
356 |
+
) : (
|
357 |
+
<div className="i-ph:arrows-clockwise text-lg" />
|
358 |
+
)}
|
359 |
+
</motion.button>
|
360 |
+
<motion.button
|
361 |
+
onClick={onDelete}
|
362 |
+
disabled={model.status === 'updating'}
|
363 |
+
className={classNames(
|
364 |
+
'rounded-lg p-2',
|
365 |
+
'bg-red-500/10 text-red-500',
|
366 |
+
'hover:bg-red-500/20',
|
367 |
+
'transition-all duration-200',
|
368 |
+
{ 'opacity-50 cursor-not-allowed': model.status === 'updating' },
|
369 |
+
)}
|
370 |
+
whileHover={{ scale: 1.05 }}
|
371 |
+
whileTap={{ scale: 0.95 }}
|
372 |
+
title="Delete model"
|
373 |
+
>
|
374 |
+
<div className="i-ph:trash text-lg" />
|
375 |
+
</motion.button>
|
376 |
+
</div>
|
377 |
+
);
|
378 |
+
|
379 |
+
return (
|
380 |
+
<div
|
381 |
+
className={classNames(
|
382 |
+
'rounded-lg bg-bolt-elements-background text-bolt-elements-textPrimary shadow-sm p-4',
|
383 |
+
'hover:bg-bolt-elements-background-depth-2',
|
384 |
+
'transition-all duration-200',
|
385 |
+
)}
|
386 |
+
role="region"
|
387 |
+
aria-label="Local Providers Configuration"
|
388 |
+
>
|
389 |
+
<motion.div
|
390 |
+
className="space-y-6"
|
391 |
+
initial={{ opacity: 0, y: 20 }}
|
392 |
+
animate={{ opacity: 1, y: 0 }}
|
393 |
+
transition={{ duration: 0.3 }}
|
394 |
+
>
|
395 |
+
{/* Header section */}
|
396 |
+
<div className="flex items-center justify-between gap-4 border-b border-bolt-elements-borderColor pb-4">
|
397 |
+
<div className="flex items-center gap-3">
|
398 |
+
<motion.div
|
399 |
+
className={classNames(
|
400 |
+
'w-10 h-10 flex items-center justify-center rounded-xl',
|
401 |
+
'bg-purple-500/10 text-purple-500',
|
402 |
+
)}
|
403 |
+
whileHover={{ scale: 1.05 }}
|
404 |
+
>
|
405 |
+
<BiChip className="w-6 h-6" />
|
406 |
+
</motion.div>
|
407 |
+
<div>
|
408 |
+
<div className="flex items-center gap-2">
|
409 |
+
<h2 className="text-lg font-semibold text-bolt-elements-textPrimary">Local AI Models</h2>
|
410 |
+
</div>
|
411 |
+
<p className="text-sm text-bolt-elements-textSecondary">Configure and manage your local AI providers</p>
|
412 |
+
</div>
|
413 |
+
</div>
|
414 |
+
|
415 |
+
<div className="flex items-center gap-2">
|
416 |
+
<span className="text-sm text-bolt-elements-textSecondary">Enable All</span>
|
417 |
+
<Switch
|
418 |
+
checked={categoryEnabled}
|
419 |
+
onCheckedChange={handleToggleCategory}
|
420 |
+
aria-label="Toggle all local providers"
|
421 |
+
/>
|
422 |
+
</div>
|
423 |
+
</div>
|
424 |
+
|
425 |
+
{/* Ollama Section */}
|
426 |
+
{filteredProviders
|
427 |
+
.filter((provider) => provider.name === 'Ollama')
|
428 |
+
.map((provider) => (
|
429 |
+
<motion.div
|
430 |
+
key={provider.name}
|
431 |
+
className={classNames(
|
432 |
+
'bg-bolt-elements-background-depth-2 rounded-xl',
|
433 |
+
'hover:bg-bolt-elements-background-depth-3',
|
434 |
+
'transition-all duration-200 p-5',
|
435 |
+
'relative overflow-hidden group',
|
436 |
+
)}
|
437 |
+
initial={{ opacity: 0, y: 20 }}
|
438 |
+
animate={{ opacity: 1, y: 0 }}
|
439 |
+
whileHover={{ scale: 1.01 }}
|
440 |
+
>
|
441 |
+
{/* Provider Header */}
|
442 |
+
<div className="flex items-start justify-between gap-4">
|
443 |
+
<div className="flex items-start gap-4">
|
444 |
+
<motion.div
|
445 |
+
className={classNames(
|
446 |
+
'w-12 h-12 flex items-center justify-center rounded-xl',
|
447 |
+
'bg-bolt-elements-background-depth-3',
|
448 |
+
provider.settings.enabled ? 'text-purple-500' : 'text-bolt-elements-textSecondary',
|
449 |
+
)}
|
450 |
+
whileHover={{ scale: 1.1, rotate: 5 }}
|
451 |
+
>
|
452 |
+
{React.createElement(PROVIDER_ICONS[provider.name as ProviderName] || BsRobot, {
|
453 |
+
className: 'w-7 h-7',
|
454 |
+
'aria-label': `${provider.name} icon`,
|
455 |
+
})}
|
456 |
+
</motion.div>
|
457 |
+
<div>
|
458 |
+
<div className="flex items-center gap-2">
|
459 |
+
<h3 className="text-md font-semibold text-bolt-elements-textPrimary">{provider.name}</h3>
|
460 |
+
<span className="px-2 py-0.5 text-xs rounded-full bg-green-500/10 text-green-500">Local</span>
|
461 |
+
</div>
|
462 |
+
<p className="text-sm text-bolt-elements-textSecondary mt-1">
|
463 |
+
{PROVIDER_DESCRIPTIONS[provider.name as ProviderName]}
|
464 |
+
</p>
|
465 |
+
</div>
|
466 |
+
</div>
|
467 |
+
<Switch
|
468 |
+
checked={provider.settings.enabled}
|
469 |
+
onCheckedChange={(checked) => handleToggleProvider(provider, checked)}
|
470 |
+
aria-label={`Toggle ${provider.name} provider`}
|
471 |
+
/>
|
472 |
+
</div>
|
473 |
+
|
474 |
+
{/* URL Configuration Section */}
|
475 |
+
<AnimatePresence>
|
476 |
+
{provider.settings.enabled && (
|
477 |
+
<motion.div
|
478 |
+
initial={{ opacity: 0, height: 0 }}
|
479 |
+
animate={{ opacity: 1, height: 'auto' }}
|
480 |
+
exit={{ opacity: 0, height: 0 }}
|
481 |
+
className="mt-4"
|
482 |
+
>
|
483 |
+
<div className="flex flex-col gap-2">
|
484 |
+
<label className="text-sm text-bolt-elements-textSecondary">API Endpoint</label>
|
485 |
+
{editingProvider === provider.name ? (
|
486 |
+
<input
|
487 |
+
type="text"
|
488 |
+
defaultValue={provider.settings.baseUrl || OLLAMA_API_URL}
|
489 |
+
placeholder="Enter Ollama base URL"
|
490 |
+
className={classNames(
|
491 |
+
'w-full px-3 py-2 rounded-lg text-sm',
|
492 |
+
'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
|
493 |
+
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
494 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
|
495 |
+
'transition-all duration-200',
|
496 |
+
)}
|
497 |
+
onKeyDown={(e) => {
|
498 |
+
if (e.key === 'Enter') {
|
499 |
+
handleUpdateBaseUrl(provider, e.currentTarget.value);
|
500 |
+
} else if (e.key === 'Escape') {
|
501 |
+
setEditingProvider(null);
|
502 |
+
}
|
503 |
+
}}
|
504 |
+
onBlur={(e) => handleUpdateBaseUrl(provider, e.target.value)}
|
505 |
+
autoFocus
|
506 |
+
/>
|
507 |
+
) : (
|
508 |
+
<div
|
509 |
+
onClick={() => setEditingProvider(provider.name)}
|
510 |
+
className={classNames(
|
511 |
+
'w-full px-3 py-2 rounded-lg text-sm cursor-pointer',
|
512 |
+
'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
|
513 |
+
'hover:border-purple-500/30 hover:bg-bolt-elements-background-depth-4',
|
514 |
+
'transition-all duration-200',
|
515 |
+
)}
|
516 |
+
>
|
517 |
+
<div className="flex items-center gap-2 text-bolt-elements-textSecondary">
|
518 |
+
<div className="i-ph:link text-sm" />
|
519 |
+
<span>{provider.settings.baseUrl || OLLAMA_API_URL}</span>
|
520 |
+
</div>
|
521 |
+
</div>
|
522 |
+
)}
|
523 |
+
</div>
|
524 |
+
</motion.div>
|
525 |
+
)}
|
526 |
+
</AnimatePresence>
|
527 |
+
|
528 |
+
{/* Ollama Models Section */}
|
529 |
+
{provider.settings.enabled && (
|
530 |
+
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="mt-6 space-y-4">
|
531 |
+
<div className="flex items-center justify-between">
|
532 |
+
<div className="flex items-center gap-2">
|
533 |
+
<div className="i-ph:cube-duotone text-purple-500" />
|
534 |
+
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">Installed Models</h4>
|
535 |
+
</div>
|
536 |
+
{isLoadingModels ? (
|
537 |
+
<div className="flex items-center gap-2">
|
538 |
+
<div className="i-ph:spinner-gap-bold animate-spin w-4 h-4" />
|
539 |
+
<span className="text-sm text-bolt-elements-textSecondary">Loading models...</span>
|
540 |
+
</div>
|
541 |
+
) : (
|
542 |
+
<span className="text-sm text-bolt-elements-textSecondary">
|
543 |
+
{ollamaModels.length} models available
|
544 |
+
</span>
|
545 |
+
)}
|
546 |
+
</div>
|
547 |
+
|
548 |
+
<div className="space-y-3">
|
549 |
+
{isLoadingModels ? (
|
550 |
+
<div className="space-y-3">
|
551 |
+
{Array.from({ length: 3 }).map((_, i) => (
|
552 |
+
<div
|
553 |
+
key={i}
|
554 |
+
className="h-20 w-full bg-bolt-elements-background-depth-3 rounded-lg animate-pulse"
|
555 |
+
/>
|
556 |
+
))}
|
557 |
+
</div>
|
558 |
+
) : ollamaModels.length === 0 ? (
|
559 |
+
<div className="text-center py-8 text-bolt-elements-textSecondary">
|
560 |
+
<div className="i-ph:cube-transparent text-4xl mx-auto mb-2" />
|
561 |
+
<p>No models installed yet</p>
|
562 |
+
<p className="text-sm text-bolt-elements-textTertiary px-1">
|
563 |
+
Browse models at{' '}
|
564 |
+
<a
|
565 |
+
href="https://ollama.com/library"
|
566 |
+
target="_blank"
|
567 |
+
rel="noopener noreferrer"
|
568 |
+
className="text-purple-500 hover:underline inline-flex items-center gap-0.5 text-base font-medium"
|
569 |
+
>
|
570 |
+
ollama.com/library
|
571 |
+
<div className="i-ph:arrow-square-out text-xs" />
|
572 |
+
</a>{' '}
|
573 |
+
and copy model names to install
|
574 |
+
</p>
|
575 |
+
</div>
|
576 |
+
) : (
|
577 |
+
ollamaModels.map((model) => (
|
578 |
+
<motion.div
|
579 |
+
key={model.name}
|
580 |
+
className={classNames(
|
581 |
+
'p-4 rounded-xl',
|
582 |
+
'bg-bolt-elements-background-depth-3',
|
583 |
+
'hover:bg-bolt-elements-background-depth-4',
|
584 |
+
'transition-all duration-200',
|
585 |
+
)}
|
586 |
+
whileHover={{ scale: 1.01 }}
|
587 |
+
>
|
588 |
+
<div className="flex items-center justify-between">
|
589 |
+
<div className="space-y-2">
|
590 |
+
<div className="flex items-center gap-2">
|
591 |
+
<h5 className="text-sm font-medium text-bolt-elements-textPrimary">{model.name}</h5>
|
592 |
+
<ModelStatusBadge status={model.status} />
|
593 |
+
</div>
|
594 |
+
<ModelDetails model={model} />
|
595 |
+
</div>
|
596 |
+
<ModelActions
|
597 |
+
model={model}
|
598 |
+
onUpdate={() => handleUpdateOllamaModel(model.name)}
|
599 |
+
onDelete={() => {
|
600 |
+
if (window.confirm(`Are you sure you want to delete ${model.name}?`)) {
|
601 |
+
handleDeleteOllamaModel(model.name);
|
602 |
+
}
|
603 |
+
}}
|
604 |
+
/>
|
605 |
+
</div>
|
606 |
+
{model.progress && (
|
607 |
+
<div className="mt-3">
|
608 |
+
<Progress
|
609 |
+
value={Math.round((model.progress.current / model.progress.total) * 100)}
|
610 |
+
className="h-1"
|
611 |
+
/>
|
612 |
+
<div className="flex justify-between mt-1 text-xs text-bolt-elements-textSecondary">
|
613 |
+
<span>{model.progress.status}</span>
|
614 |
+
<span>{Math.round((model.progress.current / model.progress.total) * 100)}%</span>
|
615 |
+
</div>
|
616 |
+
</div>
|
617 |
+
)}
|
618 |
+
</motion.div>
|
619 |
+
))
|
620 |
+
)}
|
621 |
+
</div>
|
622 |
+
|
623 |
+
{/* Model Installation Section */}
|
624 |
+
<OllamaModelInstaller onModelInstalled={fetchOllamaModels} />
|
625 |
+
</motion.div>
|
626 |
+
)}
|
627 |
+
</motion.div>
|
628 |
+
))}
|
629 |
+
|
630 |
+
{/* Other Providers Section */}
|
631 |
+
<div className="border-t border-bolt-elements-borderColor pt-6 mt-8">
|
632 |
+
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary mb-4">Other Local Providers</h3>
|
633 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
634 |
+
{filteredProviders
|
635 |
+
.filter((provider) => provider.name !== 'Ollama')
|
636 |
+
.map((provider, index) => (
|
637 |
+
<motion.div
|
638 |
+
key={provider.name}
|
639 |
+
className={classNames(
|
640 |
+
'bg-bolt-elements-background-depth-2 rounded-xl',
|
641 |
+
'hover:bg-bolt-elements-background-depth-3',
|
642 |
+
'transition-all duration-200 p-5',
|
643 |
+
'relative overflow-hidden group',
|
644 |
+
)}
|
645 |
+
initial={{ opacity: 0, y: 20 }}
|
646 |
+
animate={{ opacity: 1, y: 0 }}
|
647 |
+
transition={{ delay: index * 0.1 }}
|
648 |
+
whileHover={{ scale: 1.01 }}
|
649 |
+
>
|
650 |
+
{/* Provider Header */}
|
651 |
+
<div className="flex items-start justify-between gap-4">
|
652 |
+
<div className="flex items-start gap-4">
|
653 |
+
<motion.div
|
654 |
+
className={classNames(
|
655 |
+
'w-12 h-12 flex items-center justify-center rounded-xl',
|
656 |
+
'bg-bolt-elements-background-depth-3',
|
657 |
+
provider.settings.enabled ? 'text-purple-500' : 'text-bolt-elements-textSecondary',
|
658 |
+
)}
|
659 |
+
whileHover={{ scale: 1.1, rotate: 5 }}
|
660 |
+
>
|
661 |
+
{React.createElement(PROVIDER_ICONS[provider.name as ProviderName] || BsRobot, {
|
662 |
+
className: 'w-7 h-7',
|
663 |
+
'aria-label': `${provider.name} icon`,
|
664 |
+
})}
|
665 |
+
</motion.div>
|
666 |
+
<div>
|
667 |
+
<div className="flex items-center gap-2">
|
668 |
+
<h3 className="text-md font-semibold text-bolt-elements-textPrimary">{provider.name}</h3>
|
669 |
+
<div className="flex gap-1">
|
670 |
+
<span className="px-2 py-0.5 text-xs rounded-full bg-green-500/10 text-green-500">
|
671 |
+
Local
|
672 |
+
</span>
|
673 |
+
{URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
|
674 |
+
<span className="px-2 py-0.5 text-xs rounded-full bg-purple-500/10 text-purple-500">
|
675 |
+
Configurable
|
676 |
+
</span>
|
677 |
+
)}
|
678 |
+
</div>
|
679 |
+
</div>
|
680 |
+
<p className="text-sm text-bolt-elements-textSecondary mt-1">
|
681 |
+
{PROVIDER_DESCRIPTIONS[provider.name as ProviderName]}
|
682 |
+
</p>
|
683 |
+
</div>
|
684 |
+
</div>
|
685 |
+
<Switch
|
686 |
+
checked={provider.settings.enabled}
|
687 |
+
onCheckedChange={(checked) => handleToggleProvider(provider, checked)}
|
688 |
+
aria-label={`Toggle ${provider.name} provider`}
|
689 |
+
/>
|
690 |
+
</div>
|
691 |
+
|
692 |
+
{/* URL Configuration Section */}
|
693 |
+
<AnimatePresence>
|
694 |
+
{provider.settings.enabled && URL_CONFIGURABLE_PROVIDERS.includes(provider.name) && (
|
695 |
+
<motion.div
|
696 |
+
initial={{ opacity: 0, height: 0 }}
|
697 |
+
animate={{ opacity: 1, height: 'auto' }}
|
698 |
+
exit={{ opacity: 0, height: 0 }}
|
699 |
+
className="mt-4"
|
700 |
+
>
|
701 |
+
<div className="flex flex-col gap-2">
|
702 |
+
<label className="text-sm text-bolt-elements-textSecondary">API Endpoint</label>
|
703 |
+
{editingProvider === provider.name ? (
|
704 |
+
<input
|
705 |
+
type="text"
|
706 |
+
defaultValue={provider.settings.baseUrl}
|
707 |
+
placeholder={`Enter ${provider.name} base URL`}
|
708 |
+
className={classNames(
|
709 |
+
'w-full px-3 py-2 rounded-lg text-sm',
|
710 |
+
'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
|
711 |
+
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
|
712 |
+
'focus:outline-none focus:ring-2 focus:ring-purple-500/30',
|
713 |
+
'transition-all duration-200',
|
714 |
+
)}
|
715 |
+
onKeyDown={(e) => {
|
716 |
+
if (e.key === 'Enter') {
|
717 |
+
handleUpdateBaseUrl(provider, e.currentTarget.value);
|
718 |
+
} else if (e.key === 'Escape') {
|
719 |
+
setEditingProvider(null);
|
720 |
+
}
|
721 |
+
}}
|
722 |
+
onBlur={(e) => handleUpdateBaseUrl(provider, e.target.value)}
|
723 |
+
autoFocus
|
724 |
+
/>
|
725 |
+
) : (
|
726 |
+
<div
|
727 |
+
onClick={() => setEditingProvider(provider.name)}
|
728 |
+
className={classNames(
|
729 |
+
'w-full px-3 py-2 rounded-lg text-sm cursor-pointer',
|
730 |
+
'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor',
|
731 |
+
'hover:border-purple-500/30 hover:bg-bolt-elements-background-depth-4',
|
732 |
+
'transition-all duration-200',
|
733 |
+
)}
|
734 |
+
>
|
735 |
+
<div className="flex items-center gap-2 text-bolt-elements-textSecondary">
|
736 |
+
<div className="i-ph:link text-sm" />
|
737 |
+
<span>{provider.settings.baseUrl || 'Click to set base URL'}</span>
|
738 |
+
</div>
|
739 |
+
</div>
|
740 |
+
)}
|
741 |
+
</div>
|
742 |
+
</motion.div>
|
743 |
+
)}
|
744 |
+
</AnimatePresence>
|
745 |
+
</motion.div>
|
746 |
+
))}
|
747 |
+
</div>
|
748 |
+
</div>
|
749 |
+
</motion.div>
|
750 |
+
</div>
|
751 |
+
);
|
752 |
+
}
|
753 |
+
|
754 |
+
// Helper component for model status badge
|
755 |
+
function ModelStatusBadge({ status }: { status?: string }) {
|
756 |
+
if (!status || status === 'idle') {
|
757 |
+
return null;
|
758 |
+
}
|
759 |
+
|
760 |
+
const statusConfig = {
|
761 |
+
updating: { bg: 'bg-yellow-500/10', text: 'text-yellow-500', label: 'Updating' },
|
762 |
+
updated: { bg: 'bg-green-500/10', text: 'text-green-500', label: 'Updated' },
|
763 |
+
error: { bg: 'bg-red-500/10', text: 'text-red-500', label: 'Error' },
|
764 |
+
};
|
765 |
+
|
766 |
+
const config = statusConfig[status as keyof typeof statusConfig];
|
767 |
+
|
768 |
+
if (!config) {
|
769 |
+
return null;
|
770 |
+
}
|
771 |
+
|
772 |
+
return (
|
773 |
+
<span className={classNames('px-2 py-0.5 rounded-full text-xs font-medium', config.bg, config.text)}>
|
774 |
+
{config.label}
|
775 |
+
</span>
|
776 |
+
);
|
777 |
+
}
|