@nextclaw/ui 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.cjs +28 -0
- package/CHANGELOG.md +7 -0
- package/dist/assets/index-BrN4G7FO.js +240 -0
- package/dist/assets/index-VjHB2nG6.css +1 -0
- package/dist/index.html +14 -0
- package/index.html +13 -0
- package/package.json +50 -0
- package/postcss.config.js +6 -0
- package/src/App.tsx +51 -0
- package/src/api/client.ts +40 -0
- package/src/api/config.ts +86 -0
- package/src/api/types.ts +78 -0
- package/src/api/websocket.ts +77 -0
- package/src/components/common/KeyValueEditor.tsx +65 -0
- package/src/components/common/MaskedInput.tsx +39 -0
- package/src/components/common/StatusBadge.tsx +56 -0
- package/src/components/common/TagInput.tsx +56 -0
- package/src/components/config/ChannelForm.tsx +259 -0
- package/src/components/config/ChannelsList.tsx +102 -0
- package/src/components/config/ModelConfig.tsx +181 -0
- package/src/components/config/ProviderForm.tsx +147 -0
- package/src/components/config/ProvidersList.tsx +90 -0
- package/src/components/config/UiConfig.tsx +189 -0
- package/src/components/layout/AppLayout.tsx +20 -0
- package/src/components/layout/Header.tsx +36 -0
- package/src/components/layout/Sidebar.tsx +103 -0
- package/src/components/ui/HighlightCard.tsx +40 -0
- package/src/components/ui/button.tsx +50 -0
- package/src/components/ui/card.tsx +78 -0
- package/src/components/ui/dialog.tsx +120 -0
- package/src/components/ui/input.tsx +23 -0
- package/src/components/ui/label.tsx +20 -0
- package/src/components/ui/scroll-area.tsx +21 -0
- package/src/components/ui/skeleton.tsx +15 -0
- package/src/components/ui/switch.tsx +37 -0
- package/src/components/ui/tabs-custom.tsx +45 -0
- package/src/components/ui/tabs.tsx +88 -0
- package/src/hooks/useConfig.ts +95 -0
- package/src/hooks/useWebSocket.ts +38 -0
- package/src/index.css +177 -0
- package/src/lib/i18n.ts +119 -0
- package/src/lib/utils.ts +6 -0
- package/src/main.tsx +10 -0
- package/src/stores/ui.store.ts +39 -0
- package/src/vite-env.d.ts +9 -0
- package/tailwind.config.js +43 -0
- package/tsconfig.json +18 -0
- package/vite.config.ts +25 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { X } from 'lucide-react';
|
|
3
|
+
import { cn } from '@/lib/utils';
|
|
4
|
+
|
|
5
|
+
interface TagInputProps {
|
|
6
|
+
value: string[];
|
|
7
|
+
onChange: (tags: string[]) => void;
|
|
8
|
+
className?: string;
|
|
9
|
+
placeholder?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function TagInput({ value, onChange, className, placeholder = 'Type and press Enter...' }: TagInputProps) {
|
|
13
|
+
const [input, setInput] = useState('');
|
|
14
|
+
|
|
15
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
16
|
+
if (e.key === 'Enter' && input.trim()) {
|
|
17
|
+
e.preventDefault();
|
|
18
|
+
onChange([...value, input.trim()]);
|
|
19
|
+
setInput('');
|
|
20
|
+
} else if (e.key === 'Backspace' && !input && value.length > 0) {
|
|
21
|
+
onChange(value.slice(0, -1));
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const removeTag = (index: number) => {
|
|
26
|
+
onChange(value.filter((_, i) => i !== index));
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className={cn('flex flex-wrap gap-2 p-2 border rounded-md min-h-[42px]', className)}>
|
|
31
|
+
{value.map((tag, index) => (
|
|
32
|
+
<span
|
|
33
|
+
key={index}
|
|
34
|
+
className="inline-flex items-center gap-1 px-2 py-1 bg-primary text-primary-foreground rounded text-sm"
|
|
35
|
+
>
|
|
36
|
+
{tag}
|
|
37
|
+
<button
|
|
38
|
+
type="button"
|
|
39
|
+
onClick={() => removeTag(index)}
|
|
40
|
+
className="hover:text-red-300 transition-colors"
|
|
41
|
+
>
|
|
42
|
+
<X className="h-3 w-3" />
|
|
43
|
+
</button>
|
|
44
|
+
</span>
|
|
45
|
+
))}
|
|
46
|
+
<input
|
|
47
|
+
type="text"
|
|
48
|
+
value={input}
|
|
49
|
+
onChange={(e) => setInput(e.target.value)}
|
|
50
|
+
onKeyDown={handleKeyDown}
|
|
51
|
+
className="flex-1 outline-none min-w-[100px] bg-transparent text-sm"
|
|
52
|
+
placeholder={placeholder}
|
|
53
|
+
/>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { useConfig, useUpdateChannel } from '@/hooks/useConfig';
|
|
3
|
+
import { useUiStore } from '@/stores/ui.store';
|
|
4
|
+
import {
|
|
5
|
+
Dialog,
|
|
6
|
+
DialogContent,
|
|
7
|
+
DialogHeader,
|
|
8
|
+
DialogTitle,
|
|
9
|
+
DialogDescription,
|
|
10
|
+
DialogFooter,
|
|
11
|
+
} from '@/components/ui/dialog';
|
|
12
|
+
import { Button } from '@/components/ui/button';
|
|
13
|
+
import { Input } from '@/components/ui/input';
|
|
14
|
+
import { Label } from '@/components/ui/label';
|
|
15
|
+
import { Switch } from '@/components/ui/switch';
|
|
16
|
+
import { TagInput } from '@/components/common/TagInput';
|
|
17
|
+
import { t } from '@/lib/i18n';
|
|
18
|
+
import { MessageCircle, Settings, ToggleLeft, Hash, Mail, Globe, KeyRound } from 'lucide-react';
|
|
19
|
+
|
|
20
|
+
// Field icon mapping
|
|
21
|
+
const getFieldIcon = (fieldName: string) => {
|
|
22
|
+
if (fieldName.includes('token') || fieldName.includes('secret') || fieldName.includes('password')) {
|
|
23
|
+
return <KeyRound className="h-3.5 w-3.5 text-[hsl(30,8%,45%)]" />;
|
|
24
|
+
}
|
|
25
|
+
if (fieldName.includes('url') || fieldName.includes('host')) {
|
|
26
|
+
return <Globe className="h-3.5 w-3.5 text-[hsl(30,8%,45%)]" />;
|
|
27
|
+
}
|
|
28
|
+
if (fieldName.includes('email') || fieldName.includes('mail')) {
|
|
29
|
+
return <Mail className="h-3.5 w-3.5 text-[hsl(30,8%,45%)]" />;
|
|
30
|
+
}
|
|
31
|
+
if (fieldName.includes('id') || fieldName.includes('from')) {
|
|
32
|
+
return <Hash className="h-3.5 w-3.5 text-[hsl(30,8%,45%)]" />;
|
|
33
|
+
}
|
|
34
|
+
if (fieldName === 'enabled' || fieldName === 'consentGranted') {
|
|
35
|
+
return <ToggleLeft className="h-3.5 w-3.5 text-[hsl(30,8%,45%)]" />;
|
|
36
|
+
}
|
|
37
|
+
return <Settings className="h-3.5 w-3.5 text-[hsl(30,8%,45%)]" />;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Channel field definitions
|
|
41
|
+
const CHANNEL_FIELDS: Record<string, Array<{ name: string; type: string; label: string }>> = {
|
|
42
|
+
telegram: [
|
|
43
|
+
{ name: 'enabled', type: 'boolean', label: t('enabled') },
|
|
44
|
+
{ name: 'token', type: 'password', label: t('botToken') },
|
|
45
|
+
{ name: 'allowFrom', type: 'tags', label: t('allowFrom') },
|
|
46
|
+
{ name: 'proxy', type: 'text', label: t('proxy') }
|
|
47
|
+
],
|
|
48
|
+
discord: [
|
|
49
|
+
{ name: 'enabled', type: 'boolean', label: t('enabled') },
|
|
50
|
+
{ name: 'token', type: 'password', label: t('botToken') },
|
|
51
|
+
{ name: 'allowFrom', type: 'tags', label: t('allowFrom') },
|
|
52
|
+
{ name: 'gatewayUrl', type: 'text', label: t('gatewayUrl') },
|
|
53
|
+
{ name: 'intents', type: 'number', label: t('intents') }
|
|
54
|
+
],
|
|
55
|
+
whatsapp: [
|
|
56
|
+
{ name: 'enabled', type: 'boolean', label: t('enabled') },
|
|
57
|
+
{ name: 'bridgeUrl', type: 'text', label: t('bridgeUrl') },
|
|
58
|
+
{ name: 'allowFrom', type: 'tags', label: t('allowFrom') }
|
|
59
|
+
],
|
|
60
|
+
feishu: [
|
|
61
|
+
{ name: 'enabled', type: 'boolean', label: t('enabled') },
|
|
62
|
+
{ name: 'appId', type: 'text', label: t('appId') },
|
|
63
|
+
{ name: 'appSecret', type: 'password', label: t('appSecret') },
|
|
64
|
+
{ name: 'encryptKey', type: 'password', label: t('encryptKey') },
|
|
65
|
+
{ name: 'verificationToken', type: 'password', label: t('verificationToken') },
|
|
66
|
+
{ name: 'allowFrom', type: 'tags', label: t('allowFrom') }
|
|
67
|
+
],
|
|
68
|
+
dingtalk: [
|
|
69
|
+
{ name: 'enabled', type: 'boolean', label: t('enabled') },
|
|
70
|
+
{ name: 'clientId', type: 'text', label: t('clientId') },
|
|
71
|
+
{ name: 'clientSecret', type: 'password', label: t('clientSecret') },
|
|
72
|
+
{ name: 'allowFrom', type: 'tags', label: t('allowFrom') }
|
|
73
|
+
],
|
|
74
|
+
slack: [
|
|
75
|
+
{ name: 'enabled', type: 'boolean', label: t('enabled') },
|
|
76
|
+
{ name: 'mode', type: 'text', label: t('mode') },
|
|
77
|
+
{ name: 'webhookPath', type: 'text', label: t('webhookPath') },
|
|
78
|
+
{ name: 'botToken', type: 'password', label: t('botToken') },
|
|
79
|
+
{ name: 'appToken', type: 'password', label: t('appToken') }
|
|
80
|
+
],
|
|
81
|
+
email: [
|
|
82
|
+
{ name: 'enabled', type: 'boolean', label: t('enabled') },
|
|
83
|
+
{ name: 'consentGranted', type: 'boolean', label: t('consentGranted') },
|
|
84
|
+
{ name: 'imapHost', type: 'text', label: t('imapHost') },
|
|
85
|
+
{ name: 'imapPort', type: 'number', label: t('imapPort') },
|
|
86
|
+
{ name: 'imapUsername', type: 'text', label: t('imapUsername') },
|
|
87
|
+
{ name: 'imapPassword', type: 'password', label: t('imapPassword') },
|
|
88
|
+
{ name: 'fromAddress', type: 'email', label: t('fromAddress') }
|
|
89
|
+
],
|
|
90
|
+
mochat: [
|
|
91
|
+
{ name: 'enabled', type: 'boolean', label: t('enabled') },
|
|
92
|
+
{ name: 'baseUrl', type: 'text', label: t('baseUrl') },
|
|
93
|
+
{ name: 'clawToken', type: 'password', label: t('clawToken') },
|
|
94
|
+
{ name: 'agentUserId', type: 'text', label: t('agentUserId') },
|
|
95
|
+
{ name: 'allowFrom', type: 'tags', label: t('allowFrom') }
|
|
96
|
+
],
|
|
97
|
+
qq: [
|
|
98
|
+
{ name: 'enabled', type: 'boolean', label: t('enabled') },
|
|
99
|
+
{ name: 'appId', type: 'text', label: t('appId') },
|
|
100
|
+
{ name: 'secret', type: 'password', label: t('secret') },
|
|
101
|
+
{ name: 'allowFrom', type: 'tags', label: t('allowFrom') }
|
|
102
|
+
]
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const channelIcons: Record<string, typeof MessageCircle> = {
|
|
106
|
+
telegram: MessageCircle,
|
|
107
|
+
slack: MessageCircle,
|
|
108
|
+
email: Mail,
|
|
109
|
+
default: MessageCircle
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const channelColors: Record<string, string> = {
|
|
113
|
+
telegram: 'from-sky-400 to-blue-500',
|
|
114
|
+
slack: 'from-purple-400 to-indigo-500',
|
|
115
|
+
email: 'from-rose-400 to-pink-500',
|
|
116
|
+
default: 'from-slate-400 to-gray-500'
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export function ChannelForm() {
|
|
120
|
+
const { channelModal, closeChannelModal } = useUiStore();
|
|
121
|
+
const { data: config } = useConfig();
|
|
122
|
+
const updateChannel = useUpdateChannel();
|
|
123
|
+
|
|
124
|
+
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
|
125
|
+
|
|
126
|
+
const channelName = channelModal.channel;
|
|
127
|
+
const channelConfig = channelName ? config?.channels[channelName] : null;
|
|
128
|
+
const fields = channelName ? CHANNEL_FIELDS[channelName] : [];
|
|
129
|
+
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
if (channelConfig) {
|
|
132
|
+
setFormData({ ...channelConfig });
|
|
133
|
+
} else {
|
|
134
|
+
setFormData({});
|
|
135
|
+
}
|
|
136
|
+
}, [channelConfig, channelName]);
|
|
137
|
+
|
|
138
|
+
const updateField = (name: string, value: unknown) => {
|
|
139
|
+
setFormData((prev) => ({ ...prev, [name]: value }));
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
143
|
+
e.preventDefault();
|
|
144
|
+
|
|
145
|
+
if (!channelName) return;
|
|
146
|
+
|
|
147
|
+
updateChannel.mutate(
|
|
148
|
+
{ channel: channelName, data: formData },
|
|
149
|
+
{ onSuccess: () => closeChannelModal() }
|
|
150
|
+
);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const Icon = channelIcons[channelName || ''] || channelIcons.default;
|
|
154
|
+
const gradientClass = channelColors[channelName || ''] || channelColors.default;
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<Dialog open={channelModal.open} onOpenChange={closeChannelModal}>
|
|
158
|
+
<DialogContent className="sm:max-w-[550px] max-h-[85vh] overflow-hidden flex flex-col">
|
|
159
|
+
<DialogHeader>
|
|
160
|
+
<div className="flex items-center gap-3">
|
|
161
|
+
<div className={`h-10 w-10 rounded-xl bg-gradient-to-br ${gradientClass} flex items-center justify-center`}>
|
|
162
|
+
<Icon className="h-5 w-5 text-white" />
|
|
163
|
+
</div>
|
|
164
|
+
<div>
|
|
165
|
+
<DialogTitle className="capitalize">{channelName}</DialogTitle>
|
|
166
|
+
<DialogDescription>Configure message channel parameters</DialogDescription>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
</DialogHeader>
|
|
170
|
+
|
|
171
|
+
<div className="flex-1 overflow-y-auto custom-scrollbar py-2">
|
|
172
|
+
<form onSubmit={handleSubmit} className="space-y-5 pr-2">
|
|
173
|
+
{fields.map((field) => (
|
|
174
|
+
<div key={field.name} className="space-y-2.5">
|
|
175
|
+
<Label
|
|
176
|
+
htmlFor={field.name}
|
|
177
|
+
className="text-sm font-medium text-[hsl(30,20%,12%)] flex items-center gap-2"
|
|
178
|
+
>
|
|
179
|
+
{getFieldIcon(field.name)}
|
|
180
|
+
{field.label}
|
|
181
|
+
</Label>
|
|
182
|
+
|
|
183
|
+
{field.type === 'boolean' && (
|
|
184
|
+
<div className="flex items-center justify-between p-3 rounded-xl bg-[hsl(40,20%,96%)]">
|
|
185
|
+
<span className="text-sm text-[hsl(30,8%,45%)]">
|
|
186
|
+
{(formData[field.name] as boolean) ? t('enabled') : t('disabled')}
|
|
187
|
+
</span>
|
|
188
|
+
<Switch
|
|
189
|
+
id={field.name}
|
|
190
|
+
checked={(formData[field.name] as boolean) || false}
|
|
191
|
+
onCheckedChange={(checked) => updateField(field.name, checked)}
|
|
192
|
+
className="data-[state=checked]:bg-emerald-500"
|
|
193
|
+
/>
|
|
194
|
+
</div>
|
|
195
|
+
)}
|
|
196
|
+
|
|
197
|
+
{(field.type === 'text' || field.type === 'email') && (
|
|
198
|
+
<Input
|
|
199
|
+
id={field.name}
|
|
200
|
+
type={field.type}
|
|
201
|
+
value={(formData[field.name] as string) || ''}
|
|
202
|
+
onChange={(e) => updateField(field.name, e.target.value)}
|
|
203
|
+
className="rounded-xl border-[hsl(40,20%,90%)] bg-[hsl(40,20%,98%)] focus:bg-white"
|
|
204
|
+
/>
|
|
205
|
+
)}
|
|
206
|
+
|
|
207
|
+
{field.type === 'password' && (
|
|
208
|
+
<Input
|
|
209
|
+
id={field.name}
|
|
210
|
+
type="password"
|
|
211
|
+
value={(formData[field.name] as string) || ''}
|
|
212
|
+
onChange={(e) => updateField(field.name, e.target.value)}
|
|
213
|
+
placeholder="Leave blank to keep unchanged"
|
|
214
|
+
className="rounded-xl border-[hsl(40,20%,90%)] bg-[hsl(40,20%,98%)] focus:bg-white"
|
|
215
|
+
/>
|
|
216
|
+
)}
|
|
217
|
+
|
|
218
|
+
{field.type === 'number' && (
|
|
219
|
+
<Input
|
|
220
|
+
id={field.name}
|
|
221
|
+
type="number"
|
|
222
|
+
value={(formData[field.name] as number) || 0}
|
|
223
|
+
onChange={(e) => updateField(field.name, parseInt(e.target.value) || 0)}
|
|
224
|
+
className="rounded-xl border-[hsl(40,20%,90%)] bg-[hsl(40,20%,98%)] focus:bg-white"
|
|
225
|
+
/>
|
|
226
|
+
)}
|
|
227
|
+
|
|
228
|
+
{field.type === 'tags' && (
|
|
229
|
+
<TagInput
|
|
230
|
+
value={(formData[field.name] as string[]) || []}
|
|
231
|
+
onChange={(tags) => updateField(field.name, tags)}
|
|
232
|
+
/>
|
|
233
|
+
)}
|
|
234
|
+
</div>
|
|
235
|
+
))}
|
|
236
|
+
|
|
237
|
+
<DialogFooter className="pt-4">
|
|
238
|
+
<Button
|
|
239
|
+
type="button"
|
|
240
|
+
variant="outline"
|
|
241
|
+
onClick={closeChannelModal}
|
|
242
|
+
className="rounded-xl border-[hsl(40,20%,90%)] bg-white hover:bg-[hsl(40,20%,96%)]"
|
|
243
|
+
>
|
|
244
|
+
{t('cancel')}
|
|
245
|
+
</Button>
|
|
246
|
+
<Button
|
|
247
|
+
type="submit"
|
|
248
|
+
disabled={updateChannel.isPending}
|
|
249
|
+
className="rounded-xl bg-gradient-to-r from-orange-400 to-amber-500 hover:from-orange-500 hover:to-amber-600 text-white border-0"
|
|
250
|
+
>
|
|
251
|
+
{updateChannel.isPending ? 'Saving...' : t('save')}
|
|
252
|
+
</Button>
|
|
253
|
+
</DialogFooter>
|
|
254
|
+
</form>
|
|
255
|
+
</div>
|
|
256
|
+
</DialogContent>
|
|
257
|
+
</Dialog>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { useConfig, useConfigMeta } from '@/hooks/useConfig';
|
|
2
|
+
import { Button } from '@/components/ui/button';
|
|
3
|
+
import { Skeleton } from '@/components/ui/skeleton';
|
|
4
|
+
import { MessageCircle, Settings2, Bell, Mail, MessageSquare, Slack, MoreHorizontal, Plus } from 'lucide-react';
|
|
5
|
+
import { useState } from 'react';
|
|
6
|
+
import { ChannelForm } from './ChannelForm';
|
|
7
|
+
import { useUiStore } from '@/stores/ui.store';
|
|
8
|
+
import { cn } from '@/lib/utils';
|
|
9
|
+
import { Tabs } from '@/components/ui/tabs-custom';
|
|
10
|
+
|
|
11
|
+
const channelIcons: Record<string, typeof MessageCircle> = {
|
|
12
|
+
telegram: MessageCircle,
|
|
13
|
+
slack: Slack,
|
|
14
|
+
email: Mail,
|
|
15
|
+
webhook: Bell,
|
|
16
|
+
default: MessageSquare
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function ChannelsList() {
|
|
20
|
+
const { data: config } = useConfig();
|
|
21
|
+
const { data: meta } = useConfigMeta();
|
|
22
|
+
const { openChannelModal } = useUiStore();
|
|
23
|
+
const [activeTab, setActiveTab] = useState('active');
|
|
24
|
+
|
|
25
|
+
if (!config || !meta) {
|
|
26
|
+
return <div className="p-8 text-[hsl(30,8%,55%)]">Loading channels...</div>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const tabs = [
|
|
30
|
+
{ id: 'active', label: 'Enabled', count: meta.channels.filter(c => config.channels[c.name]?.enabled).length },
|
|
31
|
+
{ id: 'all', label: 'All Channels', count: meta.channels.length }
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className="animate-fade-in pb-20">
|
|
36
|
+
<div className="flex items-center justify-between mb-8">
|
|
37
|
+
<h2 className="text-2xl font-bold text-[hsl(30,15%,10%)]">Message Channels</h2>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<Tabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} />
|
|
41
|
+
|
|
42
|
+
<div className="space-y-1">
|
|
43
|
+
{meta.channels.map((channel, index) => {
|
|
44
|
+
const channelConfig = config.channels[channel.name];
|
|
45
|
+
const enabled = channelConfig?.enabled || false;
|
|
46
|
+
const Icon = channelIcons[channel.name] || channelIcons.default;
|
|
47
|
+
|
|
48
|
+
return (activeTab === 'all' || enabled) && (
|
|
49
|
+
<div
|
|
50
|
+
key={channel.name}
|
|
51
|
+
className="group flex items-center gap-5 p-3 rounded-2xl hover:bg-[hsl(40,10%,96%)] transition-all cursor-pointer border border-transparent hover:border-[hsl(40,10%,94%)]"
|
|
52
|
+
onClick={() => openChannelModal(channel.name)}
|
|
53
|
+
>
|
|
54
|
+
{/* Icon */}
|
|
55
|
+
<div className={cn(
|
|
56
|
+
'h-10 w-10 flex items-center justify-center rounded-xl transition-all group-hover:scale-105',
|
|
57
|
+
enabled ? 'bg-[hsl(30,15%,10%)] text-white' : 'bg-transparent border border-[hsl(40,10%,92%)] text-[hsl(30,8%,55%)]'
|
|
58
|
+
)}>
|
|
59
|
+
<Icon className="h-5 w-5" />
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
{/* Info */}
|
|
63
|
+
<div className="flex-1 min-w-0">
|
|
64
|
+
<div className="flex items-center gap-2">
|
|
65
|
+
<h3 className="text-[14px] font-bold text-[hsl(30,15%,10%)] truncate">
|
|
66
|
+
{channel.displayName || channel.name}
|
|
67
|
+
</h3>
|
|
68
|
+
{enabled && (
|
|
69
|
+
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]" />
|
|
70
|
+
)}
|
|
71
|
+
</div>
|
|
72
|
+
<p className="text-[12px] text-[hsl(30,8%,55%)] truncate leading-tight mt-0.5">
|
|
73
|
+
{enabled ? 'Channel is active and processing messages' : 'Click to configure this communication channel'}
|
|
74
|
+
</p>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
{/* Status/Actions */}
|
|
78
|
+
<div className="flex items-center gap-4">
|
|
79
|
+
<Button
|
|
80
|
+
variant="ghost"
|
|
81
|
+
size="sm"
|
|
82
|
+
className="rounded-xl bg-[hsl(40,10%,92%)] hover:bg-[hsl(40,10%,90%)] text-[hsl(30,10%,35%)] text-[11px] font-bold px-4 h-8"
|
|
83
|
+
onClick={(e) => {
|
|
84
|
+
e.stopPropagation();
|
|
85
|
+
openChannelModal(channel.name);
|
|
86
|
+
}}
|
|
87
|
+
>
|
|
88
|
+
Configure
|
|
89
|
+
</Button>
|
|
90
|
+
<button className="h-8 w-8 flex items-center justify-center text-[hsl(30,8%,45%)]">
|
|
91
|
+
<MoreHorizontal className="h-4 w-4" />
|
|
92
|
+
</button>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
})}
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
<ChannelForm />
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { useConfig, useUpdateModel } from '@/hooks/useConfig';
|
|
3
|
+
import { Button } from '@/components/ui/button';
|
|
4
|
+
import { Input } from '@/components/ui/input';
|
|
5
|
+
import { Label } from '@/components/ui/label';
|
|
6
|
+
import { Card, CardContent, CardTitle, CardDescription } from '@/components/ui/card';
|
|
7
|
+
import { Skeleton } from '@/components/ui/skeleton';
|
|
8
|
+
import { Loader2, Save, Sparkles, Sliders, Folder } from 'lucide-react';
|
|
9
|
+
|
|
10
|
+
export function ModelConfig() {
|
|
11
|
+
const { data: config, isLoading } = useConfig();
|
|
12
|
+
const updateModel = useUpdateModel();
|
|
13
|
+
|
|
14
|
+
const [model, setModel] = useState('');
|
|
15
|
+
const [workspace, setWorkspace] = useState('');
|
|
16
|
+
const [maxTokens, setMaxTokens] = useState(8192);
|
|
17
|
+
const [temperature, setTemperature] = useState(0.7);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (config?.agents?.defaults) {
|
|
21
|
+
setModel(config.agents.defaults.model || '');
|
|
22
|
+
setWorkspace(config.agents.defaults.workspace || '');
|
|
23
|
+
setMaxTokens(config.agents.defaults.maxTokens || 8192);
|
|
24
|
+
setTemperature(config.agents.defaults.temperature || 0.7);
|
|
25
|
+
}
|
|
26
|
+
}, [config]);
|
|
27
|
+
|
|
28
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
29
|
+
e.preventDefault();
|
|
30
|
+
updateModel.mutate({ model });
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
if (isLoading) {
|
|
34
|
+
return (
|
|
35
|
+
<div className="max-w-2xl space-y-6">
|
|
36
|
+
<div className="space-y-2">
|
|
37
|
+
<Skeleton className="h-8 w-32" />
|
|
38
|
+
<Skeleton className="h-4 w-48" />
|
|
39
|
+
</div>
|
|
40
|
+
<Card className="rounded-2xl border-[hsl(40,20%,90%)] p-6">
|
|
41
|
+
<div className="flex items-center gap-4 mb-6">
|
|
42
|
+
<Skeleton className="h-12 w-12 rounded-xl" />
|
|
43
|
+
<div className="space-y-2">
|
|
44
|
+
<Skeleton className="h-5 w-24" />
|
|
45
|
+
<Skeleton className="h-3 w-32" />
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
<Skeleton className="h-4 w-20 mb-2" />
|
|
49
|
+
<Skeleton className="h-10 w-full rounded-xl" />
|
|
50
|
+
</Card>
|
|
51
|
+
<Card className="rounded-2xl border-[hsl(40,20%,90%)] p-6">
|
|
52
|
+
<Skeleton className="h-5 w-24 mb-2" />
|
|
53
|
+
<Skeleton className="h-3 w-40 mb-6" />
|
|
54
|
+
<div className="space-y-6">
|
|
55
|
+
<div>
|
|
56
|
+
<Skeleton className="h-4 w-28 mb-3" />
|
|
57
|
+
<Skeleton className="h-2 w-full rounded-full" />
|
|
58
|
+
</div>
|
|
59
|
+
<div>
|
|
60
|
+
<Skeleton className="h-4 w-32 mb-3" />
|
|
61
|
+
<Skeleton className="h-2 w-full rounded-full" />
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
</Card>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div className="max-w-4xl animate-fade-in pb-20">
|
|
71
|
+
<div className="mb-10">
|
|
72
|
+
<h2 className="text-2xl font-bold text-[hsl(30,15%,10%)]">Model Configuration</h2>
|
|
73
|
+
<p className="text-[14px] text-[hsl(30,8%,55%)] mt-1">Configure default AI model and behavior parameters</p>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<form onSubmit={handleSubmit} className="space-y-8">
|
|
77
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
|
78
|
+
{/* Model Card */}
|
|
79
|
+
<div className="p-8 rounded-[2rem] bg-[hsl(40,10%,98%)] border border-[hsl(40,10%,94%)]">
|
|
80
|
+
<div className="flex items-center gap-4 mb-8">
|
|
81
|
+
<div className="h-10 w-10 rounded-xl bg-[hsl(30,15%,10%)] flex items-center justify-center text-white">
|
|
82
|
+
<Sparkles className="h-5 w-5" />
|
|
83
|
+
</div>
|
|
84
|
+
<h3 className="text-lg font-bold text-[hsl(30,15%,10%)]">Default Model</h3>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<div className="space-y-2">
|
|
88
|
+
<Label htmlFor="model" className="text-[12px] font-bold text-[hsl(30,8%,45%)] uppercase tracking-wider">Model Name</Label>
|
|
89
|
+
<Input
|
|
90
|
+
id="model"
|
|
91
|
+
value={model}
|
|
92
|
+
onChange={(e) => setModel(e.target.value)}
|
|
93
|
+
placeholder="e.g. gpt-4, claude-3"
|
|
94
|
+
className="h-12 px-4 rounded-xl border-[hsl(40,10%,92%)] bg-white focus:ring-1 focus:ring-[hsl(30,15%,10%)] transition-all"
|
|
95
|
+
/>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
{/* Workspace Card */}
|
|
100
|
+
<div className="p-8 rounded-[2rem] bg-[hsl(40,10%,98%)] border border-[hsl(40,10%,94%)]">
|
|
101
|
+
<div className="flex items-center gap-4 mb-8">
|
|
102
|
+
<div className="h-10 w-10 rounded-xl bg-[hsl(30,15%,10%)] flex items-center justify-center text-white">
|
|
103
|
+
<Folder className="h-5 w-5" />
|
|
104
|
+
</div>
|
|
105
|
+
<h3 className="text-lg font-bold text-[hsl(30,15%,10%)]">Workspace</h3>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<div className="space-y-2">
|
|
109
|
+
<Label htmlFor="workspace" className="text-[12px] font-bold text-[hsl(30,8%,45%)] uppercase tracking-wider">Default Path</Label>
|
|
110
|
+
<Input
|
|
111
|
+
id="workspace"
|
|
112
|
+
value={workspace}
|
|
113
|
+
onChange={(e) => setWorkspace(e.target.value)}
|
|
114
|
+
placeholder="/path/to/workspace"
|
|
115
|
+
className="h-12 px-4 rounded-xl border-[hsl(40,10%,92%)] bg-white focus:ring-1 focus:ring-[hsl(30,15%,10%)] transition-all"
|
|
116
|
+
/>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
{/* Parameters Section */}
|
|
122
|
+
<div className="p-8 rounded-[2.5rem] bg-white border border-[hsl(40,10%,94%)] shadow-sm">
|
|
123
|
+
<div className="flex items-center gap-4 mb-10">
|
|
124
|
+
<div className="h-10 w-10 rounded-xl bg-[hsl(30,15%,10%)] flex items-center justify-center text-white">
|
|
125
|
+
<Sliders className="h-5 w-5" />
|
|
126
|
+
</div>
|
|
127
|
+
<h3 className="text-lg font-bold text-[hsl(30,15%,10%)]">Generation Parameters</h3>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-12">
|
|
131
|
+
<div className="space-y-4">
|
|
132
|
+
<div className="flex justify-between items-center mb-2">
|
|
133
|
+
<Label className="text-[12px] font-bold text-[hsl(30,8%,45%)] uppercase tracking-wider">Max Tokens</Label>
|
|
134
|
+
<span className="text-[13px] font-bold text-[hsl(30,15%,10%)]">{maxTokens.toLocaleString()}</span>
|
|
135
|
+
</div>
|
|
136
|
+
<input
|
|
137
|
+
type="range"
|
|
138
|
+
min="1000"
|
|
139
|
+
max="32000"
|
|
140
|
+
step="1000"
|
|
141
|
+
value={maxTokens}
|
|
142
|
+
onChange={(e) => setMaxTokens(parseInt(e.target.value))}
|
|
143
|
+
className="w-full h-1 bg-[hsl(40,10%,92%)] rounded-full appearance-none cursor-pointer accent-[hsl(30,15%,10%)]"
|
|
144
|
+
/>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<div className="space-y-4">
|
|
148
|
+
<div className="flex justify-between items-center mb-2">
|
|
149
|
+
<Label className="text-[12px] font-bold text-[hsl(30,8%,45%)] uppercase tracking-wider">Temperature</Label>
|
|
150
|
+
<span className="text-[13px] font-bold text-[hsl(30,15%,10%)]">{temperature}</span>
|
|
151
|
+
</div>
|
|
152
|
+
<input
|
|
153
|
+
type="range"
|
|
154
|
+
min="0"
|
|
155
|
+
max="2"
|
|
156
|
+
step="0.1"
|
|
157
|
+
value={temperature}
|
|
158
|
+
onChange={(e) => setTemperature(parseFloat(e.target.value))}
|
|
159
|
+
className="w-full h-1 bg-[hsl(40,10%,92%)] rounded-full appearance-none cursor-pointer accent-[hsl(30,15%,10%)]"
|
|
160
|
+
/>
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<div className="flex justify-end pt-4">
|
|
166
|
+
<Button
|
|
167
|
+
type="submit"
|
|
168
|
+
disabled={updateModel.isPending}
|
|
169
|
+
className="h-12 px-8 rounded-2xl bg-[hsl(30,15%,10%)] text-white hover:bg-[hsl(30,15%,20%)] transition-all font-bold shadow-md active:scale-95"
|
|
170
|
+
>
|
|
171
|
+
{updateModel.isPending ? (
|
|
172
|
+
<Loader2 className="h-5 w-5 animate-spin" />
|
|
173
|
+
) : (
|
|
174
|
+
'Save Changes'
|
|
175
|
+
)}
|
|
176
|
+
</Button>
|
|
177
|
+
</div>
|
|
178
|
+
</form>
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
}
|