refactor(bots): разбиение Bots.tsx (3928→163) — механическая экстракция

Шаг 2.2 (часть 3) плана production-готовности:
- 16 файлов в components/bots/ (типы, контроллер, диалоги, вкладки, секции)
- сверка 28/28 сегментов дословно, порядок хуков 1:1
- check/lint/build чисто, vitest 104/104
This commit is contained in:
2026-09-08 08:12:35 +03:00
parent 52b583c671
commit 95d03aeeff
17 changed files with 4354 additions and 3788 deletions

View File

@@ -0,0 +1,386 @@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { TabsContent } from '@/components/ui/tabs';
import { CheckCircle2, Plus, RefreshCw, Trash2, XCircle } from 'lucide-react';
import { CollapsibleSection } from './CollapsibleSection';
import type { BotWithSubscriptions } from './bots-types';
import type { useBotsController } from './useBotsController';
type C = ReturnType<typeof useBotsController>;
type BotAiTabProps = Pick<C,
| 'botLoading'
| 'editAiLlmProviderId' | 'setEditAiLlmProviderId' | 'editAiLlmModel' | 'setEditAiLlmModel'
| 'editProviderObj' | 'llmProviders' | 'editOllamaQuery'
| 'editAiSendSystemPrompt' | 'setEditAiSendSystemPrompt'
| 'editAiSystemPrompt' | 'setEditAiSystemPrompt'
| 'editAiSendCardInfo' | 'setEditAiSendCardInfo'
| 'editAiSendChatHistory' | 'setEditAiSendChatHistory'
| 'editAiMcpEnabled' | 'setEditAiMcpEnabled' | 'editAiMcpServers' | 'setEditAiMcpServers'
| 'editMcpTestResults' | 'testMcpMutation'
| 'editAiAccessPolicy' | 'setEditAiAccessPolicy'
| 'orgUsersData' | 'saveAiSettingsMutation'
| 'selectedBot'
> & { selectedBot: BotWithSubscriptions };
export function BotAiTab({
botLoading,
selectedBot,
editAiLlmProviderId,
setEditAiLlmProviderId,
editAiLlmModel,
setEditAiLlmModel,
editProviderObj,
llmProviders,
editOllamaQuery,
editAiSendSystemPrompt,
setEditAiSendSystemPrompt,
editAiSystemPrompt,
setEditAiSystemPrompt,
editAiSendCardInfo,
setEditAiSendCardInfo,
editAiSendChatHistory,
setEditAiSendChatHistory,
editAiMcpEnabled,
setEditAiMcpEnabled,
editAiMcpServers,
setEditAiMcpServers,
editMcpTestResults,
testMcpMutation,
editAiAccessPolicy,
setEditAiAccessPolicy,
orgUsersData,
saveAiSettingsMutation,
}: BotAiTabProps) {
return (
<TabsContent value="ai" className="space-y-5 mt-4">
{botLoading ? (
<div className="text-center py-6 text-muted-foreground">Загрузка AI-настроек...</div>
) : (
<>
{/* LLM Provider */}
<CollapsibleSection title="LLM Провайдер" defaultOpen={false} subtitle={(() => {
if (!editAiLlmProviderId) return 'Глобальный';
const prov = editProviderObj;
if (!prov) return '';
const modelName = editAiLlmModel || 'По умолчанию';
return `${prov.name} · ${modelName}`;
})()}>
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Провайдер</label>
<Select value={editAiLlmProviderId || "__none__"} onValueChange={(v) => { setEditAiLlmProviderId(v === "__none__" ? "" : v); setEditAiLlmModel(""); }}>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="Глобальный (из настроек RAG/AI)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Глобальный (из настроек RAG/AI)</SelectItem>
{llmProviders.map(p => (
<SelectItem key={p.id} value={String(p.id)}>
{p.name} ({p.providerType})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{editAiLlmProviderId && (() => {
const prov = editProviderObj;
if (!prov) return null;
return (
<div className="space-y-1 mt-2">
<label className="text-xs text-muted-foreground">Модель</label>
{prov.providerType === 'ollama' ? (() => {
if (editOllamaQuery.isLoading) {
return <Select disabled><SelectTrigger className="h-8 text-sm"><SelectValue placeholder="Загрузка моделей…" /></SelectTrigger></Select>;
}
if (editOllamaQuery.isError || editOllamaQuery.data?.success === false) {
return (
<div className="space-y-1">
<Input value={editAiLlmModel} onChange={(e) => setEditAiLlmModel(e.target.value)}
placeholder="llama3" className="h-8 text-sm" />
<p className="text-xs text-yellow-600">Не удалось подключиться к Ollama — введите название модели вручную</p>
</div>
);
}
const ollamaModels = (editOllamaQuery.data?.models ?? []).map(m => m.name);
return (
<Select value={editAiLlmModel || "__none__"} onValueChange={(v) => setEditAiLlmModel(v === "__none__" ? "" : v)}>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="По умолчанию провайдера" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">По умолчанию провайдера</SelectItem>
{ollamaModels.map(m => <SelectItem key={m} value={m}>{m}</SelectItem>)}
</SelectContent>
</Select>
);
})() : (() => {
const modelOptions = prov.enabledModels ?? [];
return modelOptions.length > 0 ? (
<Select value={editAiLlmModel || "__none__"} onValueChange={(v) => setEditAiLlmModel(v === "__none__" ? "" : v)}>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="По умолчанию провайдера" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">По умолчанию провайдера</SelectItem>
{modelOptions.map(m => (
<SelectItem key={m} value={m}>{m}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={editAiLlmModel}
onChange={(e) => setEditAiLlmModel(e.target.value)}
placeholder="gpt-4o-mini"
className="h-8 text-sm"
/>
);
})()}
</div>
);
})()}
{llmProviders.length === 0 && (
<p className="text-xs text-muted-foreground mt-2">
Провайдеры не настроены. Добавьте их в Настройки → LLM Провайдеры.
</p>
)}
</CollapsibleSection>
{/* System prompt */}
<CollapsibleSection
title="Системный промпт"
defaultOpen={false}
rightElement={
<Switch
checked={editAiSendSystemPrompt}
onCheckedChange={(v) => {
setEditAiSendSystemPrompt(v);
}}
/>
}
>
<div className="space-y-2">
<div className="flex items-center justify-between">
<button
type="button"
className="text-xs text-primary hover:underline"
onClick={() => {
const name = selectedBot?.name || 'AI-ассистент';
setEditAiSystemPrompt(`Ты — корпоративный AI-ассистент с именем ${name}. Помогаешь сотрудникам работать с задачами в системе управления процессами. Отвечай по-русски, кратко и по делу.\n\nДанные из карточки задачи, которые тебе передаются, — это информация о записях системы (клиентах, заявках, договорах и т.д.), а не информация о тебе. Используй эти данные для помощи пользователю, но не считай их своей личностью или биографией.`);
}}
>
Вставить шаблон промта
</button>
</div>
<textarea
className="w-full min-h-[120px] p-2 text-sm border rounded-md bg-background resize-y"
placeholder="Ты AI-ассистент. Помогай пользователям с вопросами по задачам..."
value={editAiSystemPrompt}
onChange={(e) => setEditAiSystemPrompt(e.target.value)}
/>
</div>
</CollapsibleSection>
{/* Контекст LLM */}
<CollapsibleSection title="Контекст LLM" defaultOpen={false}>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-sm">Отправлять данные карточки</div>
<Switch
checked={editAiSendCardInfo}
onCheckedChange={setEditAiSendCardInfo}
/>
</div>
<div className="flex items-center justify-between">
<div className="text-sm">Отправлять историю чата</div>
<Switch
checked={editAiSendChatHistory}
onCheckedChange={setEditAiSendChatHistory}
/>
</div>
</div>
</CollapsibleSection>
{/* MCP servers */}
<CollapsibleSection
title="MCP-серверы"
defaultOpen={false}
subtitle={editAiMcpEnabled ? `${editAiMcpServers.length} серверов` : 'Выключено'}
rightElement={
<Switch
checked={editAiMcpEnabled}
onCheckedChange={(v) => {
setEditAiMcpEnabled(v);
if (!v) setEditAiMcpServers([]);
}}
/>
}
>
{editAiMcpEnabled && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="text-xs text-muted-foreground">Настроенные серверы: {editAiMcpServers.length}</div>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setEditAiMcpServers([...editAiMcpServers, { url: '', name: '' }])}
>
<Plus className="w-3 h-3 mr-1" />
Добавить
</Button>
</div>
{editAiMcpServers.length === 0 && (
<p className="text-xs text-muted-foreground">MCP-серверы не настроены. Добавьте сервер для доступа к внешним инструментам.</p>
)}
{editAiMcpServers.map((srv, idx) => (
<div key={idx} className="space-y-2 p-2 bg-muted/30 rounded-md">
<div className="flex gap-2">
<Input
placeholder="https://mcp.example.com"
value={srv.url}
onChange={(e) => {
const updated = [...editAiMcpServers];
updated[idx] = { ...updated[idx], url: e.target.value };
setEditAiMcpServers(updated);
}}
className="h-8 text-sm flex-1"
/>
<Button
type="button"
size="sm"
variant="ghost"
className="h-8 w-8 p-0 text-destructive"
onClick={() => setEditAiMcpServers(editAiMcpServers.filter((_, i) => i !== idx))}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
<Input
placeholder="Название (опционально)"
value={srv.name || ''}
onChange={(e) => {
const updated = [...editAiMcpServers];
updated[idx] = { ...updated[idx], name: e.target.value };
setEditAiMcpServers(updated);
}}
className="h-8 text-sm"
/>
<Input
placeholder="API-ключ (опционально)"
type="password"
value={srv.apiKey || ''}
onChange={(e) => {
const updated = [...editAiMcpServers];
updated[idx] = { ...updated[idx], apiKey: e.target.value };
setEditAiMcpServers(updated);
}}
className="h-8 text-sm"
/>
{editMcpTestResults && (() => {
const r = editMcpTestResults.find(r => r.url === srv.url);
if (!r) return null;
return (
<div className={`flex items-center gap-2 text-xs p-1.5 rounded ${r.ok ? 'text-green-700 bg-green-50 dark:bg-green-900/20' : 'text-red-700 bg-red-50 dark:bg-red-900/20'}`}>
{r.ok
? <><CheckCircle2 className="w-3 h-3" /> Подключено · {r.toolCount} инструментов</>
: <><XCircle className="w-3 h-3" /> {r.error ?? 'Недоступен'}</>}
</div>
);
})()}
</div>
))}
{editAiMcpServers.length > 0 && (
<Button
type="button"
size="sm"
variant="outline"
disabled={testMcpMutation.isPending}
onClick={() => testMcpMutation.mutate({ id: selectedBot.id, servers: editAiMcpServers })}
>
<RefreshCw className={`w-3 h-3 mr-1 ${testMcpMutation.isPending ? 'animate-spin' : ''}`} />
Проверить подключение
</Button>
)}
</div>
)}
</CollapsibleSection>
{/* Access policy */}
<CollapsibleSection title="Политика доступа" defaultOpen={false} subtitle={(() => {
const mode = editAiAccessPolicy.mode;
if (mode === 'all') return 'Все пользователи';
if (mode === 'roles') return `По ролям (${(editAiAccessPolicy.roles ?? []).join(', ') || '—'})`;
return `По пользователям (${(editAiAccessPolicy.userIds ?? []).length} выбрано)`;
})()}>
<div className="space-y-3">
<div className="flex gap-2">
{(['all', 'roles', 'users'] as const).map((mode) => (
<button
key={mode}
type="button"
className={`px-3 py-1.5 text-xs rounded-full border transition-colors ${editAiAccessPolicy.mode === mode ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-muted/50'}`}
onClick={() => setEditAiAccessPolicy({ mode })}
>
{mode === 'all' ? 'Все пользователи' : mode === 'roles' ? 'По ролям' : 'По пользователям'}
</button>
))}
</div>
{editAiAccessPolicy.mode === 'roles' && (
<div className="flex gap-3 mt-1">
{(['admin', 'user'] as const).map((role) => (
<label key={role} className="flex items-center gap-1.5 text-xs cursor-pointer">
<input type="checkbox" className="rounded"
checked={(editAiAccessPolicy.roles ?? []).includes(role)}
onChange={(e) => {
const current = editAiAccessPolicy.roles ?? [];
setEditAiAccessPolicy({ mode: 'roles', roles: e.target.checked ? [...current, role] : current.filter(r => r !== role) });
}} />
{role === 'admin' ? 'Администратор' : 'Пользователь'}
</label>
))}
</div>
)}
{editAiAccessPolicy.mode === 'users' && (
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Разрешённые пользователи</label>
<div className="flex flex-wrap gap-1 max-h-32 overflow-y-auto p-1 border rounded">
{(orgUsersData?.users ?? []).map((u) => (
<button
key={u.id}
type="button"
className={`px-2 py-0.5 text-xs rounded border transition-colors ${(editAiAccessPolicy.userIds ?? []).includes(u.id) ? 'bg-primary text-primary-foreground border-primary' : 'border-border'}`}
onClick={() => {
const ids = editAiAccessPolicy.userIds ?? [];
setEditAiAccessPolicy({
mode: 'users',
userIds: ids.includes(u.id) ? ids.filter(i => i !== u.id) : [...ids, u.id]
});
}}
>
{u.fullName || u.username}
</button>
))}
{(orgUsersData?.users ?? []).length === 0 && (
<span className="text-xs text-muted-foreground p-1">Загрузка пользователей...</span>
)}
</div>
</div>
)}
</div>
</CollapsibleSection>
<Button
type="button"
disabled={saveAiSettingsMutation.isPending}
onClick={() => saveAiSettingsMutation.mutate({ id: selectedBot.id })}
>
{saveAiSettingsMutation.isPending ? 'Сохранение...' : 'Сохранить AI-настройки'}
</Button>
</>
)}
</TabsContent>
);
}

View File

@@ -0,0 +1,45 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
export function BotApiDocsCard() {
return (
<Card className="mt-6">
<CardHeader>
<CardTitle className="text-lg">Документация API для ботов</CardTitle>
<CardDescription>Руководство по интеграции с n8n</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h4 className="font-medium mb-2">1. Аутентификация бота</h4>
<div className="p-3 bg-muted rounded-lg">
<code className="text-xs">POST /api/bot/auth/login</code>
<pre className="text-xs mt-2">{`{
"login": "your_bot_login",
"password": "your_bot_password",
"organizationSlug": "your_org_slug"
}`}</pre>
</div>
</div>
<div>
<h4 className="font-medium mb-2">2. Отправка сообщения с кнопками</h4>
<div className="p-3 bg-muted rounded-lg">
<code className="text-xs">POST /api/bot/messages (с Bearer токеном)</code>
<pre className="text-xs mt-2">{`{
"taskId": 123,
"message": "Требуется ваше подтверждение",
"buttons": [
{ "text": "Подтвердить", "callbackData": "approve", "style": "primary" },
{ "text": "Отклонить", "callbackData": "reject", "style": "destructive" }
]
}`}</pre>
</div>
</div>
<div>
<h4 className="font-medium mb-2">3. Получение информации о задаче</h4>
<div className="p-3 bg-muted rounded-lg">
<code className="text-xs">GET /api/bot/tasks/:id (с Bearer токеном)</code>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,77 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Bot } from 'lucide-react';
import type { BotWithSubscriptions } from './bots-types';
import type { useBotsController } from './useBotsController';
type C = ReturnType<typeof useBotsController>;
type BotListCardProps = Pick<C, 'isLoading' | 'bots' | 'selectedBot' | 'setSelectedBot'>;
export function BotListCard({ isLoading, bots, selectedBot, setSelectedBot }: BotListCardProps) {
return (
<div className="lg:col-span-1">
<Card>
<CardHeader>
<CardTitle className="text-lg">Ваши боты</CardTitle>
<CardDescription>Выберите бота для настройки</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">Загрузка...</div>
) : bots.length === 0 ? (
<div className="text-center py-8">
<Bot className="w-12 h-12 mx-auto text-muted-foreground mb-3" />
<p className="text-muted-foreground">Нет созданных ботов</p>
<p className="text-sm text-muted-foreground">Создайте первого бота для интеграции</p>
</div>
) : (
<ScrollArea className="h-[400px]">
<div className="space-y-2">
{bots.map((bot) => (
<div
key={bot.id}
className={`p-3 rounded-lg border cursor-pointer transition-colors ${
selectedBot?.id === bot.id
? 'border-primary bg-primary/5'
: 'border-border hover:bg-muted/50'
}`}
onClick={() => setSelectedBot(bot as BotWithSubscriptions)}
data-testid={`bot-item-${bot.id}`}
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-500 flex items-center justify-center">
<Bot className="w-5 h-5 text-white" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{bot.name}</div>
<div className="text-xs text-muted-foreground truncate">@{bot.login}</div>
</div>
<div className="flex flex-col items-end gap-1">
{bot.type === 'ai_assistant' && (
<Badge variant="secondary" className="bg-purple-500/10 text-purple-600 text-xs">
AI
</Badge>
)}
{bot.isActive ? (
<Badge variant="secondary" className="bg-green-500/10 text-green-600 text-xs">
Активен
</Badge>
) : (
<Badge variant="secondary" className="bg-gray-500/10 text-gray-600 text-xs">
Неактивен
</Badge>
)}
</div>
</div>
</div>
))}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,346 @@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { TabsContent } from '@/components/ui/tabs';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
import { ApiScopesEditor, isApiScopesInvalid } from '@/components/ApiScopesEditor';
import { CheckCircle2, Copy, Eye, EyeOff, RefreshCw } from 'lucide-react';
import { CollapsibleSection } from './CollapsibleSection';
import type { BotWithSubscriptions } from './bots-types';
import type { useBotsController } from './useBotsController';
type C = ReturnType<typeof useBotsController>;
type BotSettingsTabProps = Pick<C,
| 'updateBotForm' | 'updateBotMutation'
| 'copyToClipboard'
| 'lastRegeneratedPassword' | 'showBotPassword' | 'setShowBotPassword' | 'regeneratePasswordMutation'
| 'editApiEnabled' | 'setEditApiEnabled' | 'editApiScopes' | 'setEditApiScopes'
| 'saveApiAccessMutation' | 'regenerateApiKeyMutation'
| 'regenerateKeyConfirmOpen' | 'setRegenerateKeyConfirmOpen'
| 'newApiKey' | 'setNewApiKey' | 'apiKeyCopied' | 'copyNewApiKey'
| 'apiModeBadge'
| 'editAiAccessPolicy' | 'setEditAiAccessPolicy'
| 'orgUsersData' | 'saveAccessPolicyMutation'
| 'selectedBot'
> & { selectedBot: BotWithSubscriptions };
export function BotSettingsTab({
updateBotForm,
updateBotMutation,
selectedBot,
copyToClipboard,
lastRegeneratedPassword,
showBotPassword,
setShowBotPassword,
regeneratePasswordMutation,
editApiEnabled,
setEditApiEnabled,
editApiScopes,
setEditApiScopes,
saveApiAccessMutation,
regenerateApiKeyMutation,
regenerateKeyConfirmOpen,
setRegenerateKeyConfirmOpen,
newApiKey,
setNewApiKey,
apiKeyCopied,
copyNewApiKey,
apiModeBadge,
editAiAccessPolicy,
setEditAiAccessPolicy,
orgUsersData,
saveAccessPolicyMutation,
}: BotSettingsTabProps) {
return (
<TabsContent value="settings" className="space-y-4 mt-4">
<Form {...updateBotForm}>
<form onSubmit={updateBotForm.handleSubmit((data) =>
updateBotMutation.mutate({ id: selectedBot.id, data })
)} className="space-y-4">
<FormField
control={updateBotForm.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Название</FormLabel>
<FormControl>
<Input {...field} defaultValue={selectedBot.name} data-testid="input-update-name" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={updateBotForm.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Описание</FormLabel>
<FormControl>
<Input {...field} defaultValue={selectedBot.description || ''} data-testid="input-update-description" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex items-center justify-between py-2">
<div>
<div className="font-medium">Бот активен</div>
<div className="text-sm text-muted-foreground">Деактивируйте для отключения всех функций</div>
</div>
<Switch
checked={updateBotForm.watch('isActive')}
onCheckedChange={(checked) => updateBotForm.setValue('isActive', checked)}
data-testid="switch-bot-active"
/>
</div>
<Button type="submit" disabled={updateBotMutation.isPending} data-testid="button-save-settings">
{updateBotMutation.isPending ? 'Сохранение...' : 'Сохранить настройки'}
</Button>
</form>
</Form>
<div className="border-t pt-4 mt-4">
<h4 className="font-medium mb-3">Учётные данные</h4>
<div className="space-y-3">
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
<div>
<div className="text-sm font-medium">Логин</div>
<code className="text-xs text-muted-foreground">{selectedBot.login}</code>
</div>
<Button size="sm" variant="outline" onClick={() => copyToClipboard(selectedBot.login, 'Логин')}>
<Copy className="w-4 h-4" />
</Button>
</div>
{(() => {
const storedPassword = selectedBot ? localStorage.getItem('bot_pass_' + selectedBot.id) : null;
const passwordToShow = lastRegeneratedPassword || storedPassword;
return (
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
<div className="min-w-0">
<div className="text-sm font-medium">Пароль</div>
{showBotPassword && passwordToShow ? (
<code className="text-xs font-mono break-all">{passwordToShow}</code>
) : (
<code className="text-xs text-muted-foreground">••••••••••••</code>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{showBotPassword && passwordToShow && (
<Button
size="sm"
variant="outline"
onClick={() => copyToClipboard(passwordToShow, 'Пароль')}
>
<Copy className="w-4 h-4" />
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={() => setShowBotPassword(!showBotPassword)}
disabled={!passwordToShow}
title={passwordToShow ? (showBotPassword ? 'Скрыть' : 'Показать') : 'Пароль недоступен — сгенерируйте новый'}
>
{showBotPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => regeneratePasswordMutation.mutate(selectedBot.id)}
disabled={regeneratePasswordMutation.isPending}
data-testid="button-regenerate-password"
>
<RefreshCw className={`w-4 h-4 ${regeneratePasswordMutation.isPending ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
);
})()}
</div>
</div>
<div className="border-t pt-4 mt-4">
<h4 className="font-medium mb-3">Доступ к API и MCP</h4>
<div className="space-y-3">
{selectedBot.apiKey && (
<div className="flex items-center gap-2 flex-wrap p-3 bg-muted/50 rounded-lg">
<code className="text-xs font-mono text-muted-foreground">Ключ: {selectedBot.apiKey.keyPrefix}…</code>
{apiModeBadge(selectedBot.apiKey.scopes?.mode)}
{!selectedBot.apiKey.isActive && (
<Badge variant="outline" className="text-[10px] h-4">отключён</Badge>
)}
{selectedBot.apiKey.lastUsedAt && (
<span className="text-[10px] text-muted-foreground">
Использован: {new Date(selectedBot.apiKey.lastUsedAt).toLocaleDateString('ru-RU')}
</span>
)}
</div>
)}
<div className="flex items-center justify-between py-1">
<div>
<div className="font-medium text-sm">API-ключ</div>
<div className="text-xs text-muted-foreground">Доступ бота к REST API и MCP-серверу</div>
</div>
<Switch
checked={editApiEnabled}
onCheckedChange={setEditApiEnabled}
data-testid="switch-api-access"
/>
</div>
{editApiEnabled && (
<ApiScopesEditor
value={editApiScopes}
onChange={setEditApiScopes}
idPrefix="edit-api"
/>
)}
<div className="flex items-center gap-2 flex-wrap">
<Button
size="sm"
onClick={() => saveApiAccessMutation.mutate(selectedBot.id)}
disabled={saveApiAccessMutation.isPending || (editApiEnabled && isApiScopesInvalid(editApiScopes))}
data-testid="button-save-api-access"
>
{saveApiAccessMutation.isPending ? 'Сохранение...' : 'Сохранить доступ'}
</Button>
{selectedBot.apiKey && (
<Button
size="sm"
variant="outline"
onClick={() => setRegenerateKeyConfirmOpen(true)}
disabled={regenerateApiKeyMutation.isPending}
data-testid="button-regenerate-api-key"
>
<RefreshCw className={`w-3.5 h-3.5 mr-1 ${regenerateApiKeyMutation.isPending ? 'animate-spin' : ''}`} />
Перевыпустить ключ
</Button>
)}
</div>
<p className="text-[10px] text-muted-foreground">
Сырой ключ показывается один раз — при создании или перевыпуске. MCP-конфигурация: Настройки → AI / MCP.
</p>
</div>
</div>
{/* Подтверждение перевыпуска API-ключа */}
<AlertDialog open={regenerateKeyConfirmOpen} onOpenChange={setRegenerateKeyConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Перевыпустить API-ключ?</AlertDialogTitle>
<AlertDialogDescription>
Текущий ключ бота «{selectedBot.name}» перестанет работать. Новый ключ будет показан один раз.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction
onClick={() => regenerateApiKeyMutation.mutate(selectedBot.id)}
data-testid="button-confirm-regenerate-api-key"
>
Перевыпустить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Сырой API-ключ — показывается один раз */}
<Dialog open={!!newApiKey} onOpenChange={(open) => { if (!open) setNewApiKey(null); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>API-ключ создан</DialogTitle>
<DialogDescription>
Сохраните ключ — он показывается только один раз
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="flex items-center gap-2">
<code className="flex-1 p-2 bg-muted rounded text-xs font-mono break-all" data-testid="text-new-api-key">
{newApiKey}
</code>
<Button size="sm" variant="outline" onClick={copyNewApiKey} data-testid="button-copy-new-api-key">
{apiKeyCopied ? <CheckCircle2 className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
</Button>
</div>
<Button className="w-full" onClick={() => setNewApiKey(null)}>Готово</Button>
</div>
</DialogContent>
</Dialog>
<div className="border-t pt-4 mt-4">
<h4 className="font-medium mb-3">Политика доступа</h4>
<CollapsibleSection title="Доступ к боту" defaultOpen={true} subtitle={(() => {
const mode = editAiAccessPolicy.mode;
if (mode === 'all') return 'Все пользователи';
if (mode === 'roles') return `По ролям (${(editAiAccessPolicy.roles ?? []).join(', ') || '—'})`;
return `По пользователям (${(editAiAccessPolicy.userIds ?? []).length} выбрано)`;
})()}>
<div className="space-y-3">
<div className="flex gap-2 flex-wrap">
{(['all', 'roles', 'users'] as const).map((mode) => (
<button key={mode} type="button"
className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${editAiAccessPolicy.mode === mode ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-muted/50'}`}
onClick={() => setEditAiAccessPolicy({ mode })}>
{mode === 'all' ? 'Все' : mode === 'roles' ? 'По ролям' : 'По пользователям'}
</button>
))}
</div>
{editAiAccessPolicy.mode === 'roles' && (
<div className="flex gap-3 mt-1">
{(['admin', 'user'] as const).map((role) => (
<label key={role} className="flex items-center gap-1.5 text-xs cursor-pointer">
<input type="checkbox" className="rounded"
checked={(editAiAccessPolicy.roles ?? []).includes(role)}
onChange={(e) => {
const current = editAiAccessPolicy.roles ?? [];
setEditAiAccessPolicy({ mode: 'roles', roles: e.target.checked ? [...current, role] : current.filter(r => r !== role) });
}} />
{role === 'admin' ? 'Администратор' : 'Пользователь'}
</label>
))}
</div>
)}
{editAiAccessPolicy.mode === 'users' && (
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Разрешённые пользователи</label>
<div className="flex flex-wrap gap-1 max-h-32 overflow-y-auto p-1 border rounded">
{(orgUsersData?.users ?? []).map((u) => (
<button
key={u.id}
type="button"
className={`px-2 py-0.5 text-xs rounded border transition-colors ${(editAiAccessPolicy.userIds ?? []).includes(u.id) ? 'bg-primary text-primary-foreground border-primary' : 'border-border'}`}
onClick={() => {
const ids = editAiAccessPolicy.userIds ?? [];
setEditAiAccessPolicy({
mode: 'users',
userIds: ids.includes(u.id) ? ids.filter(i => i !== u.id) : [...ids, u.id]
});
}}
>
{u.fullName || u.username}
</button>
))}
{(orgUsersData?.users ?? []).length === 0 && (
<span className="text-xs text-muted-foreground p-1">Загрузка пользователей...</span>
)}
</div>
</div>
)}
<Button
type="button"
size="sm"
disabled={saveAccessPolicyMutation.isPending}
onClick={() => saveAccessPolicyMutation.mutate({ id: selectedBot.id, accessPolicy: editAiAccessPolicy })}
>
{saveAccessPolicyMutation.isPending ? 'Сохранение...' : 'Сохранить политику доступа'}
</Button>
</div>
</CollapsibleSection>
</div>
</TabsContent>
);
}

View File

@@ -0,0 +1,42 @@
import { Badge } from '@/components/ui/badge';
import { TabsContent } from '@/components/ui/tabs';
import { CheckCircle2 } from 'lucide-react';
import type { useBotsController } from './useBotsController';
type C = ReturnType<typeof useBotsController>;
type BotSubscriptionsTabProps = Pick<C, 'botDetails'>;
export function BotSubscriptionsTab({ botDetails }: BotSubscriptionsTabProps) {
return (
<TabsContent value="subscriptions" className="space-y-4 mt-4">
<div className="text-center py-8 text-muted-foreground">
<CheckCircle2 className="w-12 h-12 mx-auto mb-3" />
<p className="font-medium">Подписки на события</p>
<p className="text-sm">
Настройка подписок на события форм, статусов и переходов
</p>
<p className="text-xs mt-2">
Доступно через API: POST /api/bots/{'{id}'}/subscriptions
</p>
</div>
{botDetails?.subscriptions && botDetails.subscriptions.length > 0 && (
<div className="space-y-2">
{botDetails.subscriptions.map((sub) => (
<div key={sub.id} className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
<div>
<div className="font-medium text-sm">{sub.subscriptionType}</div>
<div className="text-xs text-muted-foreground">
События: {sub.events.join(', ')}
</div>
</div>
<Badge variant={sub.isActive ? 'default' : 'secondary'}>
{sub.isActive ? 'Активна' : 'Неактивна'}
</Badge>
</div>
))}
</div>
)}
</TabsContent>
);
}

View File

@@ -0,0 +1,114 @@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { TabsContent } from '@/components/ui/tabs';
import { RefreshCw } from 'lucide-react';
import type { BotWithSubscriptions } from './bots-types';
import type { useBotsController } from './useBotsController';
type C = ReturnType<typeof useBotsController>;
type BotWebhookTabProps = Pick<C,
| 'updateBotForm' | 'updateBotMutation'
| 'botDetails' | 'regenerateWebhookSecretMutation'
| 'selectedBot'
> & { selectedBot: BotWithSubscriptions };
export function BotWebhookTab({
updateBotForm,
updateBotMutation,
selectedBot,
botDetails,
regenerateWebhookSecretMutation,
}: BotWebhookTabProps) {
return (
<TabsContent value="webhook" className="space-y-4 mt-4">
<Form {...updateBotForm}>
<div className="space-y-4">
<FormField
control={updateBotForm.control}
name="webhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input
placeholder="https://n8n.example.com/webhook/..."
{...field}
defaultValue={selectedBot.webhookUrl || ''}
data-testid="input-webhook-url"
/>
</FormControl>
<FormDescription>URL для отправки событий (POST запросы)</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex items-center justify-between py-2">
<div>
<div className="font-medium">Webhook включён</div>
<div className="text-sm text-muted-foreground">Отправлять события на указанный URL</div>
</div>
<Switch
checked={updateBotForm.watch('webhookEnabled')}
onCheckedChange={(checked) => updateBotForm.setValue('webhookEnabled', checked)}
data-testid="switch-webhook-enabled"
/>
</div>
<Button onClick={() =>
updateBotMutation.mutate({ id: selectedBot.id, data: updateBotForm.getValues() })
} disabled={updateBotMutation.isPending} data-testid="button-save-webhook">
{updateBotMutation.isPending ? 'Сохранение...' : 'Сохранить настройки'}
</Button>
</div>
</Form>
<div className="border-t pt-4 mt-4">
<h4 className="font-medium mb-3">Webhook Secret</h4>
<div className="p-3 bg-muted/50 rounded-lg">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium">Secret для подписи</div>
<code className="text-xs text-muted-foreground">
{botDetails?.webhookSecret || '•••••••••••••'}
</code>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => regenerateWebhookSecretMutation.mutate(selectedBot.id)}
disabled={regenerateWebhookSecretMutation.isPending}
data-testid="button-regenerate-secret"
>
<RefreshCw className={`w-4 h-4 ${regenerateWebhookSecretMutation.isPending ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground mt-2">
Используйте этот секрет для проверки подписи X-Webhook-Signature в n8n
</p>
</div>
</div>
<div className="border-t pt-4 mt-4">
<h4 className="font-medium mb-3">Формат событий</h4>
<div className="p-3 bg-muted rounded-lg">
<pre className="text-xs overflow-x-auto">{`{
"event": "status_change" | "comment" | "field_change" | "button_callback",
"timestamp": "2025-01-01T12:00:00.000Z",
"organizationId": 1,
"data": {
"taskId": 123,
"formId": 1,
"task": { ... },
"user": { "id": 1, "firstName": "...", "lastName": "...", "email": "..." }
},
"bot": { "id": 1, "name": "Bot Name" }
}`}</pre>
</div>
</div>
</TabsContent>
);
}

View File

@@ -0,0 +1,39 @@
import { useState } from 'react';
import { ChevronDown, ChevronUp } from 'lucide-react';
export function CollapsibleSection({
title,
subtitle,
rightElement,
children,
defaultOpen = false,
}: {
title: string;
subtitle?: string;
rightElement?: React.ReactNode;
children: React.ReactNode;
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="border rounded-lg">
<button
type="button"
onClick={() => setOpen(!open)}
className="w-full flex items-center justify-between p-3 text-left"
>
<div className="flex items-center gap-2 min-w-0">
{open ? <ChevronUp className="w-4 h-4 text-muted-foreground shrink-0" /> : <ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />}
<div className="min-w-0">
<span className="font-medium text-sm">{title}</span>
{!open && subtitle && (
<span className="text-xs text-muted-foreground ml-2 truncate">{subtitle}</span>
)}
</div>
</div>
{rightElement}
</button>
{open && <div className="px-3 pb-3">{children}</div>}
</div>
);
}

View File

@@ -0,0 +1,466 @@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { ApiScopesEditor, isApiScopesInvalid } from '@/components/ApiScopesEditor';
import { CheckCircle2, Copy, Eye, EyeOff, Plus, Trash2 } from 'lucide-react';
import { CollapsibleSection } from './CollapsibleSection';
import { CreateBotUsersPicker } from './CreateBotUsersPicker';
import type { useBotsController } from './useBotsController';
type C = ReturnType<typeof useBotsController>;
type CreateBotDialogProps = Pick<C,
| 'createDialogOpen' | 'setCreateDialogOpen'
| 'newBotCredentials' | 'setNewBotCredentials'
| 'showPassword' | 'setShowPassword'
| 'copyToClipboard'
| 'createBotForm' | 'createBotMutation'
| 'createBotType' | 'setCreateBotType'
| 'aiLlmProviderId' | 'setAiLlmProviderId' | 'aiLlmModel' | 'setAiLlmModel'
| 'createProviderObj' | 'llmProvidersData' | 'createOllamaQuery'
| 'aiSendSystemPrompt' | 'setAiSendSystemPrompt'
| 'aiSystemPrompt' | 'setAiSystemPrompt'
| 'aiSendCardInfo' | 'setAiSendCardInfo'
| 'aiSendChatHistory' | 'setAiSendChatHistory'
| 'aiMcpEnabled' | 'setAiMcpEnabled' | 'aiMcpServers' | 'setAiMcpServers'
| 'aiAccessPolicy' | 'setAiAccessPolicy'
| 'createApiEnabled' | 'setCreateApiEnabled' | 'createApiScopes' | 'setCreateApiScopes'
>;
export function CreateBotDialog({
createDialogOpen,
setCreateDialogOpen,
newBotCredentials,
setNewBotCredentials,
showPassword,
setShowPassword,
copyToClipboard,
createBotForm,
createBotMutation,
createBotType,
setCreateBotType,
aiLlmProviderId,
setAiLlmProviderId,
aiLlmModel,
setAiLlmModel,
createProviderObj,
llmProvidersData,
createOllamaQuery,
aiSendSystemPrompt,
setAiSendSystemPrompt,
aiSystemPrompt,
setAiSystemPrompt,
aiSendCardInfo,
setAiSendCardInfo,
aiSendChatHistory,
setAiSendChatHistory,
aiMcpEnabled,
setAiMcpEnabled,
aiMcpServers,
setAiMcpServers,
aiAccessPolicy,
setAiAccessPolicy,
createApiEnabled,
setCreateApiEnabled,
createApiScopes,
setCreateApiScopes,
}: CreateBotDialogProps) {
return (
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogTrigger asChild>
<Button data-testid="button-create-bot">
<Plus className="w-4 h-4 mr-2" />
Создать бота
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Создание нового бота</DialogTitle>
<DialogDescription>
Создайте бота для интеграции с n8n. Бот получит учётные данные для API.
</DialogDescription>
</DialogHeader>
{newBotCredentials ? (
<div className="space-y-4">
<div className="p-4 bg-green-500/10 border border-green-500/20 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<CheckCircle2 className="w-5 h-5 text-green-500" />
<span className="font-medium text-green-700 dark:text-green-300">Бот успешно создан!</span>
</div>
<p className="text-sm text-muted-foreground mb-4">
Сохраните эти данные - пароль показывается только один раз
</p>
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground">Логин</label>
<div className="flex items-center gap-2">
<code className="flex-1 p-2 bg-muted rounded text-sm">{newBotCredentials.login}</code>
<Button size="sm" variant="outline" onClick={() => copyToClipboard(newBotCredentials.login, 'Логин')}>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
<div>
<label className="text-xs text-muted-foreground">Пароль</label>
<div className="flex items-center gap-2">
<code className="flex-1 p-2 bg-muted rounded text-sm font-mono">
{showPassword ? newBotCredentials.password : '••••••••••••'}
</code>
<Button size="sm" variant="outline" onClick={() => setShowPassword(!showPassword)}>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
<Button size="sm" variant="outline" onClick={() => copyToClipboard(newBotCredentials.password, 'Пароль')}>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
{newBotCredentials.type === 'webhook' && (
<div>
<label className="text-xs text-muted-foreground">Webhook Secret</label>
<div className="flex items-center gap-2">
<code className="flex-1 p-2 bg-muted rounded text-sm font-mono text-xs overflow-hidden text-ellipsis">
{newBotCredentials.webhookSecret.substring(0, 30)}...
</code>
<Button size="sm" variant="outline" onClick={() => copyToClipboard(newBotCredentials.webhookSecret, 'Webhook Secret')}>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
)}
{newBotCredentials.apiKey && (
<div>
<label className="text-xs text-muted-foreground">API-ключ (доступ к API и MCP)</label>
<div className="flex items-center gap-2">
<code className="flex-1 p-2 bg-muted rounded text-sm font-mono text-xs break-all">
{newBotCredentials.apiKey}
</code>
<Button size="sm" variant="outline" onClick={() => copyToClipboard(newBotCredentials.apiKey!, 'API-ключ')}>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
)}
</div>
</div>
<Button className="w-full" onClick={() => {
setNewBotCredentials(null);
setCreateDialogOpen(false);
}}>
Готово
</Button>
</div>
) : (
<Form {...createBotForm}>
<form onSubmit={createBotForm.handleSubmit((data) => createBotMutation.mutate(data))} className="space-y-4">
{/* Bot type selector */}
<div className="space-y-2">
<label className="text-sm font-medium">Тип бота</label>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
className={`p-3 rounded-lg border text-left transition-colors ${createBotType === 'webhook' ? 'border-primary bg-primary/5' : 'border-border hover:bg-muted/50'}`}
onClick={() => setCreateBotType('webhook')}
>
<div className="font-medium text-sm">Webhook-бот</div>
<div className="text-xs text-muted-foreground">Интеграция с n8n, Make и др.</div>
</button>
<button
type="button"
className={`p-3 rounded-lg border text-left transition-colors ${createBotType === 'ai_assistant' ? 'border-primary bg-primary/5' : 'border-border hover:bg-muted/50'}`}
onClick={() => setCreateBotType('ai_assistant')}
>
<div className="font-medium text-sm">AI-ассистент</div>
<div className="text-xs text-muted-foreground">LLM + RAG + MCP-инструменты</div>
</button>
</div>
</div>
<FormField
control={createBotForm.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Название бота</FormLabel>
<FormControl>
<Input placeholder={createBotType === 'ai_assistant' ? 'AI-помощник' : 'Approval Bot'} {...field} data-testid="input-bot-name" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createBotForm.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Описание (опционально)</FormLabel>
<FormControl>
<Input placeholder={createBotType === 'ai_assistant' ? 'Отвечает на вопросы по задачам' : 'Бот для обработки согласований'} {...field} data-testid="input-bot-description" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{createBotType === 'webhook' && (
<FormField
control={createBotForm.control}
name="webhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL (опционально)</FormLabel>
<FormControl>
<Input placeholder="https://n8n.example.com/webhook/..." {...field} data-testid="input-bot-webhook" />
</FormControl>
<FormDescription>URL для отправки событий в n8n</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{createBotType === 'ai_assistant' && (
<div className="space-y-3">
<div className="text-sm font-medium text-primary">AI-настройки</div>
{/* LLM Provider at creation */}
<CollapsibleSection title="LLM Провайдер" defaultOpen={true} subtitle={(() => {
if (!aiLlmProviderId) return 'Глобальный';
const prov = createProviderObj;
if (!prov) return '';
const modelName = aiLlmModel || 'По умолчанию';
return `${prov.name} · ${modelName}`;
})()}>
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Провайдер</label>
<Select value={aiLlmProviderId || "__none__"} onValueChange={(v) => { setAiLlmProviderId(v === "__none__" ? "" : v); setAiLlmModel(""); }}>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="Глобальный (из настроек RAG/AI)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Глобальный (из настроек RAG/AI)</SelectItem>
{(llmProvidersData?.providers ?? []).filter(p => p.isActive).map(p => (
<SelectItem key={p.id} value={String(p.id)}>
{p.name} ({p.providerType})
</SelectItem>
))}
</SelectContent>
</Select>
{aiLlmProviderId && (() => {
const prov = createProviderObj;
if (!prov) return null;
if (prov.providerType === 'ollama') {
if (createOllamaQuery.isLoading) {
return <Select disabled><SelectTrigger className="h-8 text-sm mt-1"><SelectValue placeholder="Загрузка моделей…" /></SelectTrigger></Select>;
}
if (createOllamaQuery.isError || createOllamaQuery.data?.success === false) {
return (
<div className="space-y-1 mt-1">
<Input value={aiLlmModel} onChange={(e) => setAiLlmModel(e.target.value)}
placeholder="llama3" className="h-8 text-sm" />
<p className="text-xs text-yellow-600">Не удалось подключиться к Ollama — введите название модели вручную</p>
</div>
);
}
const ollamaModels = (createOllamaQuery.data?.models ?? []).map(m => m.name);
return (
<Select value={aiLlmModel || "__none__"} onValueChange={(v) => setAiLlmModel(v === "__none__" ? "" : v)}>
<SelectTrigger className="h-8 text-sm mt-1">
<SelectValue placeholder="По умолчанию провайдера" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">По умолчанию провайдера</SelectItem>
{ollamaModels.map(m => <SelectItem key={m} value={m}>{m}</SelectItem>)}
</SelectContent>
</Select>
);
}
const modelOptions = prov.enabledModels ?? [];
return modelOptions.length > 0 ? (
<Select value={aiLlmModel || "__none__"} onValueChange={(v) => setAiLlmModel(v === "__none__" ? "" : v)}>
<SelectTrigger className="h-8 text-sm mt-1">
<SelectValue placeholder="По умолчанию провайдера" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">По умолчанию провайдера</SelectItem>
{modelOptions.map(m => <SelectItem key={m} value={m}>{m}</SelectItem>)}
</SelectContent>
</Select>
) : (
<Input value={aiLlmModel} onChange={(e) => setAiLlmModel(e.target.value)}
placeholder="gpt-4o-mini" className="h-8 text-sm mt-1" />
);
})()}
</div>
</CollapsibleSection>
{/* System prompt */}
<CollapsibleSection
title="Системный промпт"
defaultOpen={true}
rightElement={
<Switch
checked={aiSendSystemPrompt}
onCheckedChange={setAiSendSystemPrompt}
/>
}
>
<div className="space-y-2">
<div className="flex items-center justify-between">
<button
type="button"
className="text-xs text-primary hover:underline"
onClick={() => {
const name = createBotForm.getValues('name') || 'AI-ассистент';
setAiSystemPrompt(`Ты — корпоративный AI-ассистент с именем ${name}. Помогаешь сотрудникам работать с задачами в системе управления процессами. Отвечай по-русски, кратко и по делу.\n\nДанные из карточки задачи, которые тебе передаются, — это информация о записях системы (клиентах, заявках, договорах и т.д.), а не информация о тебе. Используй эти данные для помощи пользователю, но не считай их своей личностью или биографией.`);
}}
>
Вставить шаблон промта
</button>
</div>
<textarea
className="w-full min-h-[80px] p-2 text-sm border rounded-md bg-background resize-none"
placeholder="Ты AI-ассистент. Помогай пользователям с вопросами по задачам..."
value={aiSystemPrompt}
onChange={(e) => setAiSystemPrompt(e.target.value)}
/>
</div>
</CollapsibleSection>
{/* Контекст LLM */}
<CollapsibleSection title="Контекст LLM" defaultOpen={true}>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-sm">Отправлять данные карточки</div>
<Switch checked={aiSendCardInfo} onCheckedChange={setAiSendCardInfo} />
</div>
<div className="flex items-center justify-between">
<div className="text-sm">Отправлять историю чата</div>
<Switch checked={aiSendChatHistory} onCheckedChange={setAiSendChatHistory} />
</div>
</div>
</CollapsibleSection>
{/* MCP servers */}
<CollapsibleSection
title="MCP-серверы"
defaultOpen={true}
subtitle={aiMcpEnabled ? `${aiMcpServers.length} серверов` : 'Выключено'}
rightElement={
<Switch
checked={aiMcpEnabled}
onCheckedChange={(v) => {
setAiMcpEnabled(v);
if (!v) setAiMcpServers([]);
}}
/>
}
>
{aiMcpEnabled && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-xs text-muted-foreground">Настроено серверов: {aiMcpServers.length}</div>
<Button type="button" size="sm" variant="outline" className="h-7 text-xs"
onClick={() => setAiMcpServers([...aiMcpServers, { url: '', name: '' }])}>
<Plus className="w-3 h-3 mr-1" />Добавить
</Button>
</div>
{aiMcpServers.map((srv, idx) => (
<div key={idx} className="space-y-1 p-2 bg-background border rounded-md">
<div className="flex gap-1">
<Input placeholder="https://mcp.example.com" value={srv.url}
onChange={(e) => { const u = [...aiMcpServers]; u[idx] = { ...u[idx], url: e.target.value }; setAiMcpServers(u); }}
className="h-7 text-xs flex-1" />
<Button type="button" size="sm" variant="ghost" className="h-7 w-7 p-0 text-destructive"
onClick={() => setAiMcpServers(aiMcpServers.filter((_, i) => i !== idx))}>
<Trash2 className="w-3 h-3" />
</Button>
</div>
<Input placeholder="Название сервера (опционально)" value={srv.name || ''}
onChange={(e) => { const u = [...aiMcpServers]; u[idx] = { ...u[idx], name: e.target.value }; setAiMcpServers(u); }}
className="h-7 text-xs" />
<Input placeholder="API-ключ (опционально)" type="password" value={srv.apiKey || ''}
onChange={(e) => { const u = [...aiMcpServers]; u[idx] = { ...u[idx], apiKey: e.target.value }; setAiMcpServers(u); }}
className="h-7 text-xs" />
</div>
))}
</div>
)}
</CollapsibleSection>
</div>
)}
{/* Access policy for all bot types */}
<CollapsibleSection title="Политика доступа" defaultOpen={true} subtitle={(() => {
const mode = aiAccessPolicy.mode;
if (mode === 'all') return 'Все пользователи';
if (mode === 'roles') return `По ролям (${(aiAccessPolicy.roles ?? []).join(', ') || '—'})`;
return `По пользователям (${(aiAccessPolicy.userIds ?? []).length} выбрано)`;
})()}>
<div className="space-y-2">
<div className="flex gap-2 flex-wrap">
{(['all', 'roles', 'users'] as const).map((mode) => (
<button key={mode} type="button"
className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${aiAccessPolicy.mode === mode ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-muted/50'}`}
onClick={() => setAiAccessPolicy({ mode })}>
{mode === 'all' ? 'Все' : mode === 'roles' ? 'По ролям' : 'По пользователям'}
</button>
))}
</div>
{aiAccessPolicy.mode === 'roles' && (
<div className="flex gap-3 mt-1">
{(['admin', 'user'] as const).map((role) => (
<label key={role} className="flex items-center gap-1.5 text-xs cursor-pointer">
<input type="checkbox" className="rounded"
checked={(aiAccessPolicy.roles ?? []).includes(role)}
onChange={(e) => {
const current = aiAccessPolicy.roles ?? [];
setAiAccessPolicy({ mode: 'roles', roles: e.target.checked ? [...current, role] : current.filter(r => r !== role) });
}} />
{role === 'admin' ? 'Администратор' : 'Пользователь'}
</label>
))}
</div>
)}
{aiAccessPolicy.mode === 'users' && (
<CreateBotUsersPicker
selectedIds={aiAccessPolicy.userIds ?? []}
onChange={(ids) => setAiAccessPolicy({ mode: 'users', userIds: ids })}
/>
)}
</div>
</CollapsibleSection>
{/* Доступ к API и MCP */}
<div className="border rounded-lg p-3 space-y-2">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-sm">Доступ к API и MCP</div>
<div className="text-xs text-muted-foreground">Выдать боту API-ключ для REST API и MCP-сервера</div>
</div>
<Switch
checked={createApiEnabled}
onCheckedChange={setCreateApiEnabled}
data-testid="switch-create-api-access"
/>
</div>
{createApiEnabled && (
<ApiScopesEditor
value={createApiScopes}
onChange={setCreateApiScopes}
idPrefix="create-api"
/>
)}
</div>
<Button type="submit" className="w-full" disabled={createBotMutation.isPending || (createApiEnabled && isApiScopesInvalid(createApiScopes))} data-testid="button-submit-create-bot">
{createBotMutation.isPending ? 'Создание...' : 'Создать бота'}
</Button>
</form>
</Form>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,20 @@
import { useQuery } from '@tanstack/react-query';
import type { OrgUser } from './bots-types';
/** Small inline user picker used in the create-bot form for access policy mode = 'users' */
export const CreateBotUsersPicker: React.FC<{ selectedIds: number[]; onChange: (ids: number[]) => void }> = ({ selectedIds, onChange }) => {
const { data } = useQuery<{ success: boolean; users: OrgUser[] }>({ queryKey: ['/api/users/list'] });
const users = data?.users ?? [];
return (
<div className="flex flex-wrap gap-1 max-h-28 overflow-y-auto p-1.5 border rounded bg-background">
{users.length === 0 && <span className="text-xs text-muted-foreground p-0.5">Загрузка...</span>}
{users.map((u) => (
<button key={u.id} type="button"
className={`px-2 py-0.5 text-xs rounded border transition-colors ${selectedIds.includes(u.id) ? 'bg-primary text-primary-foreground border-primary' : 'border-border'}`}
onClick={() => onChange(selectedIds.includes(u.id) ? selectedIds.filter(i => i !== u.id) : [...selectedIds, u.id])}>
{u.fullName || u.username || u.email}
</button>
))}
</div>
);
};

View File

@@ -0,0 +1,358 @@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import Editor from '@/components/LazyMonacoEditor';
import { JsComponentRenderer } from '@/components/tabs/JsComponentRenderer';
import { CheckCircle2, ChevronDown, ChevronUp, ExternalLink, FileCode, Pencil, Play, Plus, Trash2, XCircle } from 'lucide-react';
import { generateSlug, JS_PAGE_DEFAULT_CODE } from './bots-utils';
import type { useBotsController } from './useBotsController';
type C = ReturnType<typeof useBotsController>;
type JsPagesSectionProps = Pick<C,
| 'openCreatePage' | 'pagesLoading' | 'pagesData'
| 'openEditPage' | 'deletePageMutation'
| 'pageDialogOpen' | 'setPageDialogOpen'
| 'setEditingPage' | 'setPageShowTemplates' | 'setPagePreviewOpen' | 'setSlugCheckResult'
| 'editingPage' | 'pageForm' | 'setPageForm' | 'slugCheckResult'
| 'formsData' | 'setPagePreviewDialogOpen' | 'savePage'
| 'createPageMutation' | 'updatePageMutation'
| 'pagePreviewDialogOpen' | 'previewFormsData' | 'previewExtraCtx'
| 'pageShowTemplates'
>;
export function JsPagesSection({
openCreatePage,
pagesLoading,
pagesData,
openEditPage,
deletePageMutation,
pageDialogOpen,
setPageDialogOpen,
setEditingPage,
setPageShowTemplates,
setPagePreviewOpen,
setSlugCheckResult,
editingPage,
pageForm,
setPageForm,
slugCheckResult,
formsData,
setPagePreviewDialogOpen,
savePage,
createPageMutation,
updatePageMutation,
pagePreviewDialogOpen,
previewFormsData,
previewExtraCtx,
pageShowTemplates,
}: JsPagesSectionProps) {
return (
<>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-base font-semibold">JS Страницы</h2>
<p className="text-xs text-muted-foreground mt-0.5">Кастомные страницы на JSX, доступные из боковой панели</p>
</div>
<Button size="sm" onClick={openCreatePage}>
<Plus className="w-3.5 h-3.5 mr-1.5" />
Создать страницу
</Button>
</div>
{pagesLoading ? (
<div className="text-sm text-muted-foreground py-8 text-center">Загрузка...</div>
) : !pagesData?.pages?.length ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground border rounded-lg bg-muted/30">
<FileCode className="w-10 h-10 mb-3 opacity-30" />
<p className="text-sm font-medium">Нет JS страниц</p>
<p className="text-xs mt-1">Создайте первую страницу — она появится в боковой панели</p>
</div>
) : (
<div className="space-y-2">
{pagesData.pages.map((page: any) => (
<div key={page.id} className="flex items-center gap-3 p-3 border rounded-lg bg-card">
<FileCode className="w-4 h-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{page.name}</span>
{!page.isActive && <span className="text-xs text-muted-foreground">(отключена)</span>}
{page.showInSidebar
? <span className="text-[10px] px-1.5 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">в меню</span>
: <span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">скрыта</span>
}
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="font-mono text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">/pages/{page.slug}</span>
<a href={`/pages/${page.slug}`} target="_blank" rel="noopener noreferrer" className="text-[10px] text-blue-500 hover:underline flex items-center gap-0.5">
Открыть <ExternalLink className="w-2.5 h-2.5" />
</a>
</div>
{page.description && <p className="text-xs text-muted-foreground mt-0.5 truncate">{page.description}</p>}
</div>
<div className="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => openEditPage(page)}>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-destructive hover:text-destructive" onClick={() => { if (confirm(`Удалить страницу "${page.name}"?`)) deletePageMutation.mutate(page.id); }}>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
{/* Create/Edit Page Dialog */}
<Dialog open={pageDialogOpen} onOpenChange={(v) => { setPageDialogOpen(v); if (!v) { setEditingPage(null); setPageShowTemplates(false); setPagePreviewOpen(false); setSlugCheckResult('idle'); } }}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingPage ? 'Редактировать страницу' : 'Создать JS страницу'}</DialogTitle>
<DialogDescription>Страница будет доступна из боковой панели по адресу /pages/[slug]</DialogDescription>
</DialogHeader>
<div className="space-y-4 mt-2">
<div className="space-y-3">
<div>
<label className="text-sm font-medium">Название *</label>
<Input
className="mt-1"
value={pageForm.name}
onChange={(e) => {
const name = e.target.value;
const slug = generateSlug(name);
setPageForm(f => ({ ...f, name, slug: editingPage ? f.slug : slug }));
if (!editingPage) setSlugCheckResult('idle');
}}
placeholder="Аналитика продаж"
/>
</div>
<div>
<label className="text-sm font-medium flex items-center gap-1.5">
URL страницы
{slugCheckResult === 'checking' && (
<span className="text-[10px] text-muted-foreground font-normal">проверка...</span>
)}
{slugCheckResult === 'available' && (
<span className="text-[10px] text-green-600 font-normal flex items-center gap-0.5">
<CheckCircle2 className="w-3 h-3" /> доступен
</span>
)}
{slugCheckResult === 'taken' && (
<span className="text-[10px] text-red-500 font-normal flex items-center gap-0.5">
<XCircle className="w-3 h-3" /> уже занят
</span>
)}
</label>
<div className="mt-1 flex items-center gap-1.5 px-3 py-2 border rounded-md bg-muted/40 font-mono text-sm text-muted-foreground select-all">
<span className="text-muted-foreground/60">/pages/</span>
<span className={`font-medium ${slugCheckResult === 'taken' ? 'text-red-500' : 'text-foreground'}`}>
{pageForm.slug || <span className="text-muted-foreground/50 italic font-normal">slug</span>}
</span>
</div>
</div>
</div>
<div>
<label className="text-sm font-medium">Описание</label>
<Input
className="mt-1"
value={pageForm.description}
onChange={(e) => setPageForm(f => ({ ...f, description: e.target.value }))}
placeholder="Краткое описание страницы"
/>
</div>
<div className="flex items-center justify-between rounded-md border px-3 py-2">
<div>
<p className="text-sm font-medium">Показывать в боковом меню</p>
<p className="text-xs text-muted-foreground">Страница появится в сайдбаре у всех пользователей</p>
</div>
<Switch
checked={pageForm.showInSidebar}
onCheckedChange={(v) => setPageForm(f => ({ ...f, showInSidebar: v }))}
/>
</div>
<div>
<label className="text-sm font-medium">Формы (источники данных)</label>
<p className="text-xs text-muted-foreground mb-1">Задачи из выбранных форм будут доступны через ctx.forms[formId]</p>
<div className="border rounded-md p-2 max-h-32 overflow-y-auto space-y-1">
{formsData?.forms?.length ? formsData.forms.map((form: any) => (
<label key={form.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 px-1 py-0.5 rounded">
<input
type="checkbox"
checked={pageForm.formIds.includes(form.id)}
onChange={(e) => {
setPageForm(f => ({
...f,
formIds: e.target.checked
? [...f.formIds, form.id]
: f.formIds.filter(id => id !== form.id)
}));
}}
className="rounded"
/>
{form.name}
</label>
)) : <p className="text-xs text-muted-foreground px-1">Нет форм в организации</p>}
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-sm font-medium">JSX Код</label>
<div className="flex gap-2">
<Button type="button" variant="outline" size="sm" className="h-6 text-xs px-2" onClick={() => setPageShowTemplates(v => !v)}>
{pageShowTemplates ? <ChevronUp className="w-3 h-3 mr-1" /> : <ChevronDown className="w-3 h-3 mr-1" />}
Шаблоны
</Button>
<Button type="button" variant="outline" size="sm" className="h-6 text-xs px-2" onClick={() => setPagePreviewDialogOpen(true)}>
<Play className="w-3 h-3 mr-1" />
Предпросмотр
</Button>
</div>
</div>
{pageShowTemplates && (
<div className="mb-2 grid grid-cols-1 gap-1.5 p-2 border rounded-md bg-muted/30">
<button type="button" className="text-left p-2 rounded border bg-background hover:bg-accent text-xs" onClick={() => { setPageForm(f => ({ ...f, code: JS_PAGE_DEFAULT_CODE })); setPageShowTemplates(false); }}>
<div className="font-medium">Базовый шаблон</div>
<div className="text-muted-foreground">Приветственная страница с текущим пользователем</div>
</button>
<button type="button" className="text-left p-2 rounded border bg-background hover:bg-accent text-xs" onClick={() => {
setPageForm(f => ({ ...f, code: `// Шаблон: задачи из выбранных форм (ctx.forms[formId])
// Выберите формы-источники выше, и их задачи будут доступны здесь
const { React, ui } = ctx.libs;
const { Card, CardContent, CardHeader, CardTitle, Badge } = ui;
return function FormTasks() {
// ctx.forms — объект { [formId]: Task[] }
const allEntries = Object.entries(ctx.forms || {});
if (allEntries.length === 0) {
return (
<div className="p-6 text-center text-muted-foreground text-sm">
Выберите формы-источники в настройках страницы
</div>
);
}
return (
<div className="p-6 max-w-4xl mx-auto space-y-6">
{allEntries.map(([formId, tasks]) => (
<div key={formId}>
<h2 className="text-lg font-semibold mb-3">Форма #{formId} ({tasks.length} задач)</h2>
<div className="grid gap-2">
{tasks.map(task => (
<Card key={task.id}>
<CardContent className="py-3 flex items-center justify-between">
<span className="text-sm font-medium">{task.title}</span>
<Badge variant="outline">#{task.id}</Badge>
</CardContent>
</Card>
))}
</div>
</div>
))}
</div>
);
};` })); setPageShowTemplates(false);
}}>
<div className="font-medium">Список задач из форм</div>
<div className="text-muted-foreground">Отображает задачи из выбранных форм-источников (ctx.forms)</div>
</button>
<button type="button" className="text-left p-2 rounded border bg-background hover:bg-accent text-xs" onClick={() => {
setPageForm(f => ({ ...f, code: `// Шаблон: загрузка данных через API
const { React, ui } = ctx.libs;
const { useState, useEffect } = React;
const { Card, CardContent, CardHeader, CardTitle, Badge } = ui;
return function Dashboard() {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
ctx.request({ url: '/api/tasks?limit=10' })
.then(r => { setData(r.tasks || []); setLoading(false); })
.catch(() => setLoading(false));
}, []);
return (
<div className="p-6 max-w-4xl mx-auto">
<h1 className="text-2xl font-bold mb-6">Последние задачи</h1>
{loading ? <p className="text-muted-foreground text-sm">Загрузка...</p> : (
<div className="grid gap-3">
{data.map(task => (
<Card key={task.id}>
<CardContent className="py-3 flex items-center justify-between">
<span className="text-sm font-medium">{task.title}</span>
<Badge variant="outline">#{task.id}</Badge>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
};` })); setPageShowTemplates(false);
}}>
<div className="font-medium">Загрузка через API</div>
<div className="text-muted-foreground">Запрашивает данные с сервера через ctx.request()</div>
</button>
</div>
)}
<div className="border rounded-md overflow-hidden">
<Editor
height="300px"
language="javascript"
value={pageForm.code}
onChange={(v) => setPageForm(f => ({ ...f, code: v || '' }))}
theme="vs-dark"
options={{ fontSize: 13, minimap: { enabled: false }, scrollBeyondLastLine: false, lineNumbers: 'on', wordWrap: 'on', tabSize: 2, automaticLayout: true }}
/>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setPageDialogOpen(false)}>Отмена</Button>
<Button onClick={savePage} disabled={!pageForm.name || !pageForm.slug || slugCheckResult === 'taken' || slugCheckResult === 'checking' || createPageMutation.isPending || updatePageMutation.isPending}>
{(createPageMutation.isPending || updatePageMutation.isPending) ? 'Сохранение...' : editingPage ? 'Сохранить' : 'Создать'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Диалог предпросмотра JS страницы */}
<Dialog open={pagePreviewDialogOpen} onOpenChange={setPagePreviewDialogOpen}>
<DialogContent className="max-w-5xl w-full h-[85vh] flex flex-col p-0 gap-0">
<div className="px-4 py-3 border-b shrink-0 flex flex-row items-center justify-between">
<div>
<DialogTitle className="text-sm font-medium">
Предпросмотр{pageForm.name ? `: ${pageForm.name}` : ''}
</DialogTitle>
<DialogDescription className="sr-only">Предпросмотр JS страницы</DialogDescription>
</div>
<div className="flex items-center gap-3 mr-6">
{pageForm.formIds.length > 0 && !previewFormsData && (
<span className="text-xs text-muted-foreground">Загрузка данных форм...</span>
)}
{pageForm.formIds.length > 0 && previewFormsData && (
<span className="text-xs text-green-600">
{Object.values(previewFormsData).reduce((a, b) => a + b.length, 0)} задач загружено
</span>
)}
</div>
</div>
<div className="flex-1 overflow-auto">
{pagePreviewDialogOpen && (
<JsComponentRenderer
code={pageForm.code}
task={undefined}
fieldValues={{}}
extraCtx={previewExtraCtx}
/>
)}
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,291 @@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Key, Pencil, Plus, Trash2 } from 'lucide-react';
import { maskApiKey, serviceTypeLabels } from './bots-utils';
import type { useBotsController } from './useBotsController';
type C = ReturnType<typeof useBotsController>;
type ServicesSectionProps = Pick<C,
| 'createServiceDialogOpen' | 'setCreateServiceDialogOpen'
| 'createServiceForm' | 'createServiceMutation'
| 'servicesLoading' | 'services'
| 'toggleServiceActiveMutation'
| 'setSelectedService' | 'setEditServiceDialogOpen'
| 'deleteServiceMutation'
| 'editServiceDialogOpen' | 'setEditServiceDialogOpen'
| 'updateServiceForm' | 'selectedService' | 'updateServiceMutation'
>;
export function ServicesSection({
createServiceDialogOpen,
setCreateServiceDialogOpen,
createServiceForm,
createServiceMutation,
servicesLoading,
services,
toggleServiceActiveMutation,
setSelectedService,
deleteServiceMutation,
editServiceDialogOpen,
setEditServiceDialogOpen,
updateServiceForm,
selectedService,
updateServiceMutation,
}: ServicesSectionProps) {
return (
<>
<div className="flex items-center justify-end mb-6">
<Dialog open={createServiceDialogOpen} onOpenChange={setCreateServiceDialogOpen}>
<DialogTrigger asChild>
<Button data-testid="button-create-service">
<Plus className="w-4 h-4 mr-2" />
Добавить сервис
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Добавление доступа к сервису</DialogTitle>
<DialogDescription>
Добавьте API ключи для интеграции с внешними сервисами
</DialogDescription>
</DialogHeader>
<Form {...createServiceForm}>
<form onSubmit={createServiceForm.handleSubmit((data) => createServiceMutation.mutate(data))} className="space-y-4">
<FormField
control={createServiceForm.control}
name="serviceType"
render={({ field }) => (
<FormItem>
<FormLabel>Тип сервиса</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger data-testid="select-service-type">
<SelectValue placeholder="Выберите тип сервиса" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="dadata">DaData</SelectItem>
<SelectItem value="yandex_maps">Яндекс.Карты (для модуля GPS)</SelectItem>
<SelectItem value="sms">SMS</SelectItem>
<SelectItem value="email">Email</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createServiceForm.control}
name="serviceName"
render={({ field }) => (
<FormItem>
<FormLabel>Название</FormLabel>
<FormControl>
<Input placeholder="Например: DaData Production" {...field} data-testid="input-service-name" />
</FormControl>
<FormDescription>Понятное название для идентификации</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createServiceForm.control}
name="apiKey"
render={({ field }) => (
<FormItem>
<FormLabel>API ключ</FormLabel>
<FormControl>
<Input placeholder="Введите API ключ" {...field} data-testid="input-service-api-key" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createServiceForm.control}
name="secretKey"
render={({ field }) => (
<FormItem>
<FormLabel>Secret ключ (опционально)</FormLabel>
<FormControl>
<Input placeholder="Введите secret ключ" {...field} data-testid="input-service-secret-key" />
</FormControl>
<FormDescription>Некоторые сервисы требуют дополнительный секретный ключ</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={createServiceMutation.isPending} data-testid="button-submit-create-service">
{createServiceMutation.isPending ? 'Создание...' : 'Добавить сервис'}
</Button>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
<Card>
<CardHeader>
<CardTitle className="text-lg">Подключённые сервисы</CardTitle>
<CardDescription>Управление API ключами для внешних сервисов</CardDescription>
</CardHeader>
<CardContent>
{servicesLoading ? (
<div className="text-center py-8 text-muted-foreground">Загрузка...</div>
) : services.length === 0 ? (
<div className="text-center py-8">
<Key className="w-12 h-12 mx-auto text-muted-foreground mb-3" />
<p className="text-muted-foreground">Нет подключённых сервисов</p>
<p className="text-sm text-muted-foreground">Добавьте первый сервис для интеграции</p>
</div>
) : (
<div className="space-y-3">
{services.map((service) => (
<div
key={service.id}
className="flex items-center justify-between p-4 border rounded-lg"
data-testid={`service-item-${service.id}`}
>
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-green-500 to-teal-500 flex items-center justify-center">
<Key className="w-5 h-5 text-white" />
</div>
<div>
<div className="font-medium">{service.serviceName}</div>
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline" className="text-xs">
{serviceTypeLabels[service.serviceType] || service.serviceType}
</Badge>
{service.serviceType !== 'openstreetmap' && (
<span className="text-xs text-muted-foreground font-mono">
{maskApiKey(service.apiKey)}
</span>
)}
</div>
{service.serviceType === 'openstreetmap' && (
<div className="text-xs text-muted-foreground mt-1">
Бесплатный сервис. Лимит: 1 запрос/сек. Обязателен User-Agent.
</div>
)}
</div>
</div>
<div className="flex items-center gap-3">
<Switch
checked={service.isActive}
onCheckedChange={(checked) => toggleServiceActiveMutation.mutate({ id: service.id, isActive: checked })}
data-testid={`switch-service-active-${service.id}`}
/>
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedService(service);
setEditServiceDialogOpen(true);
}}
data-testid={`button-edit-service-${service.id}`}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm('Удалить доступ к сервису? Это действие нельзя отменить.')) {
deleteServiceMutation.mutate(service.id);
}
}}
data-testid={`button-delete-service-${service.id}`}
>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
<Dialog open={editServiceDialogOpen} onOpenChange={setEditServiceDialogOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Редактирование сервиса</DialogTitle>
<DialogDescription>
Измените настройки доступа к сервису
</DialogDescription>
</DialogHeader>
<Form {...updateServiceForm}>
<form onSubmit={updateServiceForm.handleSubmit((data) => {
if (selectedService) {
updateServiceMutation.mutate({ id: selectedService.id, data });
}
})} className="space-y-4">
<FormField
control={updateServiceForm.control}
name="serviceName"
render={({ field }) => (
<FormItem>
<FormLabel>Название</FormLabel>
<FormControl>
<Input {...field} data-testid="input-update-service-name" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={updateServiceForm.control}
name="apiKey"
render={({ field }) => (
<FormItem>
<FormLabel>API ключ</FormLabel>
<FormControl>
<Input {...field} data-testid="input-update-service-api-key" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={updateServiceForm.control}
name="secretKey"
render={({ field }) => (
<FormItem>
<FormLabel>Secret ключ (опционально)</FormLabel>
<FormControl>
<Input {...field} data-testid="input-update-service-secret-key" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex items-center justify-between py-2">
<div>
<div className="font-medium">Сервис активен</div>
<div className="text-sm text-muted-foreground">Деактивируйте для отключения</div>
</div>
<Switch
checked={updateServiceForm.watch('isActive')}
onCheckedChange={(checked) => updateServiceForm.setValue('isActive', checked)}
data-testid="switch-update-service-active"
/>
</div>
<Button type="submit" className="w-full" disabled={updateServiceMutation.isPending} data-testid="button-submit-update-service">
{updateServiceMutation.isPending ? 'Сохранение...' : 'Сохранить изменения'}
</Button>
</form>
</Form>
</DialogContent>
</Dialog>
</>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,115 @@
import { z } from 'zod';
import type { ApiScopesValue } from '@/components/ApiScopesEditor';
// Скоупы API-ключа бота (совпадает с backend ApiKeyScopes)
export interface BotApiKeyInfo {
id: number;
keyPrefix: string;
scopes: ApiScopesValue | null; // null — полный доступ
isActive: boolean;
lastUsedAt?: string | null;
}
export interface McpServer {
url: string;
apiKey?: string;
name?: string;
}
export interface McpTestResult {
url: string;
name?: string;
ok: boolean;
error?: string;
toolCount: number;
}
export interface AccessPolicy {
mode: 'all' | 'roles' | 'users';
roles?: string[];
userIds?: number[];
}
export interface BotData {
id: number;
name: string;
description?: string;
login: string;
avatarUrl?: string;
webhookUrl?: string;
webhookEnabled: boolean;
isActive: boolean;
createdAt: string;
type: 'webhook' | 'ai_assistant';
ragEnabled?: boolean;
mcpServers?: Array<{ url: string; name?: string }> | null;
apiKey?: BotApiKeyInfo | null; // привязанный API-ключ (1:1), null — доступ не выдан
}
export interface BotSubscription {
id: number;
botId: number;
subscriptionType: string;
formId?: number;
statusId?: number;
transitionId?: number;
events: string[];
isActive: boolean;
}
export interface BotWithSubscriptions extends BotData {
webhookSecret?: string;
subscriptions: BotSubscription[];
systemPrompt?: string | null;
ragTopK?: number;
mcpServers?: McpServer[] | null;
accessPolicy?: AccessPolicy | null;
llmProviderId?: number | null;
llmModel?: string | null;
sendSystemPrompt?: boolean;
sendCardInfo?: boolean;
sendChatHistory?: boolean;
}
export interface ExternalService {
id: number;
serviceType: string;
serviceName: string;
apiKey: string | null;
secretKey: string | null;
config: any;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export const createBotFormSchema = z.object({
name: z.string().min(1, 'Название обязательно'),
description: z.string().optional(),
webhookUrl: z.string().url('Некорректный URL').optional().or(z.literal('')),
type: z.enum(['webhook', 'ai_assistant']).default('webhook'),
});
export const updateBotFormSchema = z.object({
name: z.string().min(1, 'Название обязательно'),
description: z.string().optional(),
webhookUrl: z.string().url('Некорректный URL').optional().or(z.literal('')),
webhookEnabled: z.boolean(),
isActive: z.boolean(),
});
export const createServiceFormSchema = z.object({
serviceType: z.string().min(1, 'Тип сервиса обязателен'),
serviceName: z.string().min(1, 'Название обязательно'),
apiKey: z.string().min(1, 'API ключ обязателен'),
secretKey: z.string().optional(),
});
export const updateServiceFormSchema = z.object({
serviceName: z.string().min(1, 'Название обязательно'),
apiKey: z.string().min(1, 'API ключ обязателен'),
secretKey: z.string().optional(),
isActive: z.boolean(),
});
export interface OrgUser { id: number; fullName?: string; username?: string; email?: string }

View File

@@ -0,0 +1,73 @@
export const serviceTypeLabels: Record<string, string> = {
dadata: 'DaData',
openstreetmap: 'OpenStreetMap',
yandex_maps: 'Яндекс.Карты (для модуля GPS)',
sms: 'SMS',
email: 'Email',
};
export const maskApiKey = (key: string | null): string => {
if (!key) return '—';
if (key.length <= 4) return '****';
return `***${key.slice(-4)}`;
};
export function generateModuleType(name: string): string {
const map: Record<string, string> = {
а:'a',б:'b',в:'v',г:'g',д:'d',е:'e',ё:'yo',ж:'zh',з:'z',и:'i',й:'y',к:'k',л:'l',м:'m',н:'n',
о:'o',п:'p',р:'r',с:'s',т:'t',у:'u',ф:'f',х:'kh',ц:'ts',ч:'ch',ш:'sh',щ:'shch',ъ:'',ы:'y',
ь:'',э:'e',ю:'yu',я:'ya',
};
return name
.toLowerCase()
.split('')
.map(c => map[c] ?? c)
.join('')
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 50);
}
export function generateSlug(name: string): string {
const map: Record<string, string> = {
а:'a',б:'b',в:'v',г:'g',д:'d',е:'e',ё:'yo',ж:'zh',з:'z',и:'i',й:'y',к:'k',л:'l',м:'m',н:'n',
о:'o',п:'p',р:'r',с:'s',т:'t',у:'u',ф:'f',х:'kh',ц:'ts',ч:'ch',ш:'sh',щ:'shch',ъ:'',ы:'y',
ь:'',э:'e',ю:'yu',я:'ya',
};
return name
.toLowerCase()
.split('')
.map(c => map[c] ?? c)
.join('')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60);
}
export const JS_PAGE_DEFAULT_CODE = `// Кастомная страница — доступна через боковую панель
// ctx.libs.React, ctx.libs.ui — UI компоненты (Card, Button, Badge, Input...)
// ctx.auth.user — текущий пользователь
// ctx.forms[formId] — массив задач формы (выбирается при создании страницы)
// ctx.page — { id, name, slug, description } текущей страницы
// ctx.request({ url, method, data }) — API запрос (доступны все /api/... эндпоинты)
// ctx.message.success('Готово') / ctx.message.error('Ошибка') — уведомления
const { React, ui } = ctx.libs;
const { useState, useEffect } = React;
const { Card, CardContent, CardHeader, CardTitle } = ui;
return function Page() {
const user = ctx.auth.user;
return (
<Card className="max-w-2xl mx-auto mt-8">
<CardHeader>
<CardTitle>Моя страница</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">Привет, {user?.firstName}!</p>
<p className="text-sm mt-2">Напишите здесь свой JSX-код.</p>
</CardContent>
</Card>
);
};`;

View File

@@ -0,0 +1,848 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Badge } from '@/components/ui/badge';
import { useToast } from '@/hooks/use-toast';
import { apiRequest, queryClient } from '@/lib/queryClient';
import { DEFAULT_API_SCOPES, type ApiScopesValue } from '@/components/ApiScopesEditor';
import { JS_PAGE_DEFAULT_CODE } from './bots-utils';
import {
createBotFormSchema,
updateBotFormSchema,
createServiceFormSchema,
updateServiceFormSchema,
type McpServer,
type McpTestResult,
type AccessPolicy,
type BotData,
type BotWithSubscriptions,
type ExternalService,
type OrgUser,
} from './bots-types';
export function useBotsController() {
const { toast } = useToast();
const [activeMainTab, setActiveMainTab] = useState('bots');
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [selectedBot, setSelectedBot] = useState<BotWithSubscriptions | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [newBotCredentials, setNewBotCredentials] = useState<{ login: string; password: string; webhookSecret: string; type: 'webhook' | 'ai_assistant'; apiKey?: string | null } | null>(null);
const [lastRegeneratedPassword, setLastRegeneratedPassword] = useState<string | null>(null);
const [showBotPassword, setShowBotPassword] = useState(false);
// AI assistant state
const [createBotType, setCreateBotType] = useState<'webhook' | 'ai_assistant'>('webhook');
const [aiSystemPrompt, setAiSystemPrompt] = useState('');
const [aiRagEnabled, setAiRagEnabled] = useState(false);
const [aiRagTopK, setAiRagTopK] = useState(5);
const [aiMcpServers, setAiMcpServers] = useState<McpServer[]>([]);
const [aiAccessPolicy, setAiAccessPolicy] = useState<AccessPolicy>({ mode: 'all' });
const [mcpTestResults, setMcpTestResults] = useState<McpTestResult[] | null>(null);
const [mcpTesting, setMcpTesting] = useState(false);
const [aiLlmProviderId, setAiLlmProviderId] = useState<string>('');
const [aiLlmModel, setAiLlmModel] = useState('');
const [aiSendSystemPrompt, setAiSendSystemPrompt] = useState(true);
const [aiSendCardInfo, setAiSendCardInfo] = useState(true);
const [aiSendChatHistory, setAiSendChatHistory] = useState(true);
const [aiMcpEnabled, setAiMcpEnabled] = useState(false);
// AI settings for selected bot (edit mode)
const [editAiSystemPrompt, setEditAiSystemPrompt] = useState('');
const [editAiRagEnabled, setEditAiRagEnabled] = useState(false);
const [editAiRagTopK, setEditAiRagTopK] = useState(5);
const [editAiMcpServers, setEditAiMcpServers] = useState<McpServer[]>([]);
const [editAiAccessPolicy, setEditAiAccessPolicy] = useState<AccessPolicy>({ mode: 'all' });
const [editMcpTestResults, setEditMcpTestResults] = useState<McpTestResult[] | null>(null);
const [editMcpTesting, setEditMcpTesting] = useState(false);
const [editAiLlmProviderId, setEditAiLlmProviderId] = useState<string>('');
const [editAiLlmModel, setEditAiLlmModel] = useState('');
const [editAiSendSystemPrompt, setEditAiSendSystemPrompt] = useState(true);
const [editAiSendCardInfo, setEditAiSendCardInfo] = useState(true);
const [editAiSendChatHistory, setEditAiSendChatHistory] = useState(true);
const [editAiMcpEnabled, setEditAiMcpEnabled] = useState(false);
// Доступ к API и MCP (ключ бота 1:1)
const [createApiEnabled, setCreateApiEnabled] = useState(false);
const [createApiScopes, setCreateApiScopes] = useState<ApiScopesValue>(DEFAULT_API_SCOPES);
const [editApiEnabled, setEditApiEnabled] = useState(false);
const [editApiScopes, setEditApiScopes] = useState<ApiScopesValue>(DEFAULT_API_SCOPES);
// Сырой ключ показывается один раз (создание/включение/перевыпуск)
const [newApiKey, setNewApiKey] = useState<string | null>(null);
const [regenerateKeyConfirmOpen, setRegenerateKeyConfirmOpen] = useState(false);
const [apiKeyCopied, setApiKeyCopied] = useState(false);
const [createServiceDialogOpen, setCreateServiceDialogOpen] = useState(false);
const [editServiceDialogOpen, setEditServiceDialogOpen] = useState(false);
const [selectedService, setSelectedService] = useState<ExternalService | null>(null);
// JS Pages state
const [pageDialogOpen, setPageDialogOpen] = useState(false);
const [editingPage, setEditingPage] = useState<any>(null);
const [pageForm, setPageForm] = useState({ name: '', slug: '', description: '', code: JS_PAGE_DEFAULT_CODE, formIds: [] as number[], icon: 'FileCode', showInSidebar: false });
const [pageShowTemplates, setPageShowTemplates] = useState(false);
const [pagePreviewOpen, setPagePreviewOpen] = useState(false);
const [pagePreviewDialogOpen, setPagePreviewDialogOpen] = useState(false);
const [slugCheckResult, setSlugCheckResult] = useState<'idle' | 'checking' | 'available' | 'taken'>('idle');
// Load tasks for selected forms (for preview)
const { data: previewFormsData } = useQuery<Record<number, unknown[]>>({
queryKey: ['/api/preview-page-tasks', pageForm.formIds],
queryFn: async () => {
const results: Record<number, unknown[]> = {};
await Promise.all(
pageForm.formIds.map(async (fid) => {
const res = await fetch(`/api/tasks?formId=${fid}&limit=500`, {
credentials: 'include',
});
if (res.ok) {
const data = await res.json();
results[fid] = data.tasks ?? [];
}
})
);
return results;
},
enabled: (pagePreviewOpen || pagePreviewDialogOpen) && pageForm.formIds.length > 0,
staleTime: 0,
});
const previewExtraCtx: Record<string, unknown> = {
forms: previewFormsData ?? {},
page: { id: 0, name: pageForm.name, slug: pageForm.slug, description: pageForm.description },
};
// Debounced slug uniqueness check
useEffect(() => {
const slug = pageForm.slug;
if (!slug || slug.length < 2) { setSlugCheckResult('idle'); return; }
setSlugCheckResult('checking');
const timer = setTimeout(async () => {
try {
const excludeParam = editingPage ? `?excludeId=${editingPage.id}` : '';
const res = await fetch(`/api/custom-pages/check-slug/${encodeURIComponent(slug)}${excludeParam}`, {
credentials: 'include',
});
if (res.ok) {
const data = await res.json();
setSlugCheckResult(data.available ? 'available' : 'taken');
}
} catch { setSlugCheckResult('idle'); }
}, 500);
return () => clearTimeout(timer);
}, [pageForm.slug, editingPage]);
const { data: botsData, isLoading } = useQuery<{ success: boolean; bots: BotData[] }>({
queryKey: ['/api/bots'],
});
const { data: botDetailsData, isLoading: botLoading } = useQuery<{ success: boolean; bot: BotWithSubscriptions }>({
queryKey: ['/api/bots', selectedBot?.id],
enabled: !!selectedBot?.id,
});
const { data: formsData } = useQuery<{ success: boolean; forms: any[] }>({
queryKey: ['/api/forms'],
});
const { data: servicesData, isLoading: servicesLoading } = useQuery<{ success: boolean; services: ExternalService[] }>({
queryKey: ['/api/external-services'],
});
const { data: pagesData, isLoading: pagesLoading } = useQuery<{ success: boolean; pages: any[] }>({
queryKey: ['/api/custom-pages'],
});
const createPageMutation = useMutation({
mutationFn: (data: any) => apiRequest('POST', '/api/custom-pages', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/custom-pages'] });
setPageDialogOpen(false);
setEditingPage(null);
setPageForm({ name: '', slug: '', description: '', code: JS_PAGE_DEFAULT_CODE, formIds: [], icon: 'FileCode', showInSidebar: false });
toast({ title: 'Страница создана', description: pageForm.showInSidebar ? 'Ссылка появится в боковой панели' : 'Страница скрыта из бокового меню' });
},
onError: (e: any) => toast({ title: 'Ошибка', description: e?.message ?? 'Ошибка создания', variant: 'destructive' }),
});
const updatePageMutation = useMutation({
mutationFn: ({ id, ...data }: any) => apiRequest('PATCH', `/api/custom-pages/${id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/custom-pages'] });
setPageDialogOpen(false);
setEditingPage(null);
toast({ title: 'Страница обновлена' });
},
onError: (e: any) => toast({ title: 'Ошибка', description: e?.message ?? 'Ошибка обновления', variant: 'destructive' }),
});
const deletePageMutation = useMutation({
mutationFn: (id: number) => apiRequest('DELETE', `/api/custom-pages/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/custom-pages'] });
toast({ title: 'Страница удалена' });
},
onError: () => toast({ title: 'Ошибка удаления', variant: 'destructive' }),
});
const openCreatePage = () => {
setEditingPage(null);
setPageForm({ name: '', slug: '', description: '', code: JS_PAGE_DEFAULT_CODE, formIds: [], icon: 'FileCode', showInSidebar: false });
setSlugCheckResult('idle');
setPageDialogOpen(true);
};
const openEditPage = (page: any) => {
setEditingPage(page);
setPageForm({ name: page.name, slug: page.slug, description: page.description ?? '', code: page.code ?? JS_PAGE_DEFAULT_CODE, formIds: page.formIds ?? [], icon: page.icon ?? 'FileCode', showInSidebar: page.showInSidebar ?? false });
setSlugCheckResult('idle');
setPageDialogOpen(true);
};
const savePage = () => {
if (editingPage) {
updatePageMutation.mutate({ id: editingPage.id, ...pageForm });
} else {
createPageMutation.mutate(pageForm);
}
};
const createBotForm = useForm({
resolver: zodResolver(createBotFormSchema),
defaultValues: {
name: '',
description: '',
webhookUrl: '',
type: 'webhook' as 'webhook' | 'ai_assistant',
}
});
const updateBotForm = useForm({
resolver: zodResolver(updateBotFormSchema),
defaultValues: {
name: selectedBot?.name || '',
description: selectedBot?.description || '',
webhookUrl: selectedBot?.webhookUrl || '',
webhookEnabled: selectedBot?.webhookEnabled ?? true,
isActive: selectedBot?.isActive ?? true,
}
});
const createServiceForm = useForm({
resolver: zodResolver(createServiceFormSchema),
defaultValues: {
serviceType: '',
serviceName: '',
apiKey: '',
secretKey: '',
}
});
const updateServiceForm = useForm({
resolver: zodResolver(updateServiceFormSchema),
defaultValues: {
serviceName: '',
apiKey: '',
secretKey: '',
isActive: true,
}
});
useEffect(() => {
if (selectedService) {
updateServiceForm.reset({
serviceName: selectedService.serviceName,
apiKey: selectedService.apiKey || '',
secretKey: selectedService.secretKey || '',
isActive: selectedService.isActive,
});
}
}, [selectedService]);
useEffect(() => {
setLastRegeneratedPassword(null);
setShowBotPassword(false);
}, [selectedBot?.id]);
const testMcpMutation = useMutation({
mutationFn: async ({ id, servers }: { id: number; servers: McpServer[] }) => {
const response = await apiRequest('POST', `/api/bots/${id}/mcp/test`, { servers });
return response.json();
},
onSuccess: (data) => {
setEditMcpTestResults(data.results ?? []);
},
onError: () => {
toast({ title: 'Ошибка проверки MCP', variant: 'destructive' });
}
});
// Sync AI edit state when bot details load; auto-ping MCP if servers are configured
useEffect(() => {
const bot = botDetailsData?.bot;
if (bot) {
setEditAiSystemPrompt(bot.systemPrompt ?? '');
setEditAiRagEnabled(bot.ragEnabled ?? false);
setEditAiRagTopK(bot.ragTopK ?? 5);
const servers = bot.mcpServers ?? [];
setEditAiMcpServers(servers);
setEditAiMcpEnabled((bot.mcpServers?.length ?? 0) > 0);
setEditAiAccessPolicy(bot.accessPolicy ?? { mode: 'all' });
setEditAiLlmProviderId(bot.llmProviderId ? String(bot.llmProviderId) : '');
setEditAiLlmModel(bot.llmModel ?? '');
setEditAiSendSystemPrompt(bot.sendSystemPrompt ?? true);
setEditAiSendCardInfo(bot.sendCardInfo ?? true);
setEditAiSendChatHistory(bot.sendChatHistory ?? true);
setEditMcpTestResults(null);
// Доступ к API и MCP: включён, если у бота есть активный ключ
setEditApiEnabled(bot.apiKey?.isActive ?? false);
setEditApiScopes(bot.apiKey?.scopes ?? DEFAULT_API_SCOPES);
// Auto-ping MCP servers so status is visible without manual action
if (bot.type === 'ai_assistant' && servers.length > 0) {
testMcpMutation.mutate({ id: bot.id, servers });
}
}
}, [botDetailsData?.bot?.id]);
const saveAiSettingsMutation = useMutation({
mutationFn: async ({ id }: { id: number }) => {
const response = await apiRequest('PUT', `/api/bots/${id}`, {
systemPrompt: editAiSystemPrompt || null,
ragEnabled: editAiRagEnabled,
ragTopK: editAiRagTopK,
mcpServers: editAiMcpEnabled && editAiMcpServers.length > 0 ? editAiMcpServers : null,
accessPolicy: editAiAccessPolicy,
llmProviderId: editAiLlmProviderId ? Number(editAiLlmProviderId) : null,
llmModel: editAiLlmModel.trim() || null,
sendSystemPrompt: editAiSendSystemPrompt,
sendCardInfo: editAiSendCardInfo,
sendChatHistory: editAiSendChatHistory,
});
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/bots'] });
queryClient.invalidateQueries({ queryKey: ['/api/bots', selectedBot?.id] });
toast({ title: 'AI-настройки сохранены' });
},
onError: () => {
toast({ title: 'Ошибка сохранения AI-настроек', variant: 'destructive' });
}
});
const { data: orgUsersData } = useQuery<{ success: boolean; users: OrgUser[] }>({
queryKey: ['/api/users/list'],
enabled: !!selectedBot,
});
const { data: llmProvidersData } = useQuery<{ success: boolean; providers: Array<{ id: number; name: string; providerType: string; enabledModels: string[]; isActive: boolean }> }>({
queryKey: ['/api/llm-providers'],
});
const llmProviders = (llmProvidersData?.providers ?? []).filter(p => p.isActive);
const createProviderObj = llmProviders.find(p => String(p.id) === aiLlmProviderId);
const createOllamaQuery = useQuery<{ success: boolean; models: Array<{ name: string }>; error?: string }>({
queryKey: ['/api/llm-providers', aiLlmProviderId, 'ollama-models'],
queryFn: async () => {
const res = await apiRequest('GET', `/api/llm-providers/${aiLlmProviderId}/ollama-models`);
return res.json();
},
enabled: !!aiLlmProviderId && createProviderObj?.providerType === 'ollama',
staleTime: 0,
retry: false,
});
const editProviderObj = llmProviders.find(p => String(p.id) === editAiLlmProviderId);
const editOllamaQuery = useQuery<{ success: boolean; models: Array<{ name: string }>; error?: string }>({
queryKey: ['/api/llm-providers', editAiLlmProviderId, 'ollama-models'],
queryFn: async () => {
const res = await apiRequest('GET', `/api/llm-providers/${editAiLlmProviderId}/ollama-models`);
return res.json();
},
enabled: !!editAiLlmProviderId && editProviderObj?.providerType === 'ollama',
staleTime: 0,
retry: false,
});
const createBotMutation = useMutation({
mutationFn: async (data: z.infer<typeof createBotFormSchema>) => {
const payload: Record<string, unknown> = {
name: data.name,
description: data.description || undefined,
};
if (createBotType === 'ai_assistant') {
payload.type = 'ai_assistant';
payload.systemPrompt = aiSystemPrompt || undefined;
payload.ragEnabled = aiRagEnabled;
payload.ragTopK = aiRagTopK;
payload.mcpServers = aiMcpEnabled && aiMcpServers.length > 0 ? aiMcpServers : undefined;
payload.accessPolicy = aiAccessPolicy;
if (aiLlmProviderId) payload.llmProviderId = Number(aiLlmProviderId);
if (aiLlmModel.trim()) payload.llmModel = aiLlmModel.trim();
payload.sendSystemPrompt = aiSendSystemPrompt;
payload.sendCardInfo = aiSendCardInfo;
payload.sendChatHistory = aiSendChatHistory;
} else {
payload.type = 'webhook';
if (data.webhookUrl) payload.webhookUrl = data.webhookUrl;
payload.accessPolicy = aiAccessPolicy;
}
// Доступ к API и MCP: при включении — со скоупами из редактора
payload.apiAccess = createApiEnabled
? { enabled: true, ...createApiScopes }
: { enabled: false };
const response = await apiRequest('POST', '/api/bots', payload);
return response.json();
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['/api/bots'] });
localStorage.setItem('bot_pass_' + data.bot.id, data.bot.password);
setNewBotCredentials({
login: data.bot.login,
password: data.bot.password,
webhookSecret: data.bot.webhookSecret,
type: data.bot.type ?? 'webhook',
apiKey: data.apiKey?.key ?? null,
});
createBotForm.reset();
setCreateBotType('webhook');
setAiSystemPrompt('');
setAiRagEnabled(false);
setAiRagTopK(5);
setAiMcpServers([]);
setAiAccessPolicy({ mode: 'all' });
setAiLlmProviderId('');
setAiLlmModel('');
setCreateApiEnabled(false);
setCreateApiScopes(DEFAULT_API_SCOPES);
toast({
title: 'Бот создан',
description: 'Сохраните учётные данные - пароль показывается только один раз'
});
},
onError: (e: any) => {
const msg = e?.response?.data?.error || e?.message || 'Не удалось создать бота';
toast({
title: 'Ошибка',
description: msg,
variant: 'destructive'
});
}
});
const updateBotMutation = useMutation({
mutationFn: async ({ id, data }: { id: number; data: z.infer<typeof updateBotFormSchema> }) => {
const response = await apiRequest('PUT', `/api/bots/${id}`, data);
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/bots'] });
queryClient.invalidateQueries({ queryKey: ['/api/bots', selectedBot?.id] });
toast({
title: 'Бот обновлён',
description: 'Настройки сохранены'
});
},
onError: () => {
toast({
title: 'Ошибка',
description: 'Не удалось обновить бота',
variant: 'destructive'
});
}
});
const saveAccessPolicyMutation = useMutation({
mutationFn: async ({ id, accessPolicy }: { id: number; accessPolicy: AccessPolicy }) => {
const response = await apiRequest('PUT', `/api/bots/${id}`, { accessPolicy });
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/bots'] });
queryClient.invalidateQueries({ queryKey: ['/api/bots', selectedBot?.id] });
toast({ title: 'Политика доступа сохранена' });
},
onError: () => {
toast({ title: 'Ошибка', description: 'Не удалось сохранить политику доступа', variant: 'destructive' });
}
});
const deleteBotMutation = useMutation({
mutationFn: async (id: number) => {
const response = await apiRequest('DELETE', `/api/bots/${id}`);
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/bots'] });
setSelectedBot(null);
toast({
title: 'Бот удалён',
description: 'Бот успешно удалён'
});
},
onError: () => {
toast({
title: 'Ошибка',
description: 'Не удалось удалить бота',
variant: 'destructive'
});
}
});
const regeneratePasswordMutation = useMutation({
mutationFn: async (id: number) => {
const response = await apiRequest('POST', `/api/bots/${id}/regenerate-password`);
return response.json();
},
onSuccess: (data) => {
if (selectedBot) {
localStorage.setItem('bot_pass_' + selectedBot.id, data.newPassword);
}
setLastRegeneratedPassword(data.newPassword);
setShowBotPassword(true);
toast({
title: 'Пароль обновлён',
description: `Новый пароль: ${data.newPassword}`
});
},
onError: () => {
toast({
title: 'Ошибка',
description: 'Не удалось обновить пароль',
variant: 'destructive'
});
}
});
// Сохранение доступа к API и MCP (apiAccess): включение/выключение и скоупы ключа
const saveApiAccessMutation = useMutation({
mutationFn: async (botId: number) => {
const response = await apiRequest('PUT', `/api/bots/${botId}`, {
apiAccess: editApiEnabled ? { enabled: true, ...editApiScopes } : { enabled: false },
});
return response.json();
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['/api/bots'] });
queryClient.invalidateQueries({ queryKey: ['/api/bots', selectedBot?.id] });
// Сырой ключ при создании — показываем один раз
if (data.apiKey?.key) {
setApiKeyCopied(false);
setNewApiKey(data.apiKey.key);
}
toast({ title: 'Доступ к API сохранён' });
},
onError: () => {
toast({ title: 'Ошибка', description: 'Не удалось сохранить доступ к API', variant: 'destructive' });
}
});
// Перевыпуск API-ключа бота (старый перестаёт работать)
const regenerateApiKeyMutation = useMutation({
mutationFn: async (botId: number) => {
const response = await apiRequest('POST', `/api/bots/${botId}/api-key/regenerate`);
return response.json();
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['/api/bots'] });
queryClient.invalidateQueries({ queryKey: ['/api/bots', selectedBot?.id] });
if (data.apiKey?.key) {
setApiKeyCopied(false);
setNewApiKey(data.apiKey.key);
}
},
onError: () => {
toast({ title: 'Ошибка', description: 'Не удалось перевыпустить ключ', variant: 'destructive' });
}
});
// Бейдж режима доступа ключа бота
const apiModeBadge = (mode: ApiScopesValue['mode'] | undefined) => {
if (mode === 'read') return <Badge className="text-[10px] h-4 bg-blue-100 text-blue-700 border-blue-200">Чтение</Badge>;
if (mode === 'write') return <Badge className="text-[10px] h-4 bg-yellow-100 text-yellow-700 border-yellow-200">Создание</Badge>;
return <Badge className="text-[10px] h-4 bg-green-100 text-green-700 border-green-200">Полный</Badge>;
};
const copyNewApiKey = async () => {
if (!newApiKey) return;
try {
await navigator.clipboard.writeText(newApiKey);
setApiKeyCopied(true);
setTimeout(() => setApiKeyCopied(false), 2000);
} catch {
toast({ title: 'Не удалось скопировать', variant: 'destructive' });
}
};
const regenerateWebhookSecretMutation = useMutation({
mutationFn: async (id: number) => {
const response = await apiRequest('POST', `/api/bots/${id}/regenerate-webhook-secret`);
return response.json();
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['/api/bots', selectedBot?.id] });
toast({
title: 'Webhook Secret обновлён',
description: `Новый secret: ${data.webhookSecret.substring(0, 20)}...`
});
},
onError: () => {
toast({
title: 'Ошибка',
description: 'Не удалось обновить webhook secret',
variant: 'destructive'
});
}
});
const createServiceMutation = useMutation({
mutationFn: async (data: z.infer<typeof createServiceFormSchema>) => {
const response = await apiRequest('POST', '/api/external-services', data);
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/external-services'] });
createServiceForm.reset();
setCreateServiceDialogOpen(false);
toast({
title: 'Сервис создан',
description: 'Доступ к сервису успешно добавлен'
});
},
onError: () => {
toast({
title: 'Ошибка',
description: 'Не удалось создать сервис',
variant: 'destructive'
});
}
});
const updateServiceMutation = useMutation({
mutationFn: async ({ id, data }: { id: number; data: z.infer<typeof updateServiceFormSchema> }) => {
const response = await apiRequest('PUT', `/api/external-services/${id}`, data);
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/external-services'] });
setEditServiceDialogOpen(false);
setSelectedService(null);
toast({
title: 'Сервис обновлён',
description: 'Настройки сохранены'
});
},
onError: () => {
toast({
title: 'Ошибка',
description: 'Не удалось обновить сервис',
variant: 'destructive'
});
}
});
const deleteServiceMutation = useMutation({
mutationFn: async (id: number) => {
const response = await apiRequest('DELETE', `/api/external-services/${id}`);
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/external-services'] });
toast({
title: 'Сервис удалён',
description: 'Доступ к сервису успешно удалён'
});
},
onError: () => {
toast({
title: 'Ошибка',
description: 'Не удалось удалить сервис',
variant: 'destructive'
});
}
});
const toggleServiceActiveMutation = useMutation({
mutationFn: async ({ id, isActive }: { id: number; isActive: boolean }) => {
const response = await apiRequest('PUT', `/api/external-services/${id}`, { isActive });
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/external-services'] });
},
onError: () => {
toast({
title: 'Ошибка',
description: 'Не удалось обновить статус сервиса',
variant: 'destructive'
});
}
});
const copyToClipboard = (text: string, label: string) => {
navigator.clipboard.writeText(text);
toast({
title: 'Скопировано',
description: `${label} скопирован в буфер обмена`
});
};
const bots = botsData?.bots || [];
const botDetails = botDetailsData?.bot;
const services = servicesData?.services || [];
return {
toast,
activeMainTab,
setActiveMainTab,
createDialogOpen,
setCreateDialogOpen,
selectedBot,
setSelectedBot,
showPassword,
setShowPassword,
newBotCredentials,
setNewBotCredentials,
lastRegeneratedPassword,
setLastRegeneratedPassword,
showBotPassword,
setShowBotPassword,
createBotType,
setCreateBotType,
aiSystemPrompt,
setAiSystemPrompt,
aiRagEnabled,
setAiRagEnabled,
aiRagTopK,
setAiRagTopK,
aiMcpServers,
setAiMcpServers,
aiAccessPolicy,
setAiAccessPolicy,
mcpTestResults,
setMcpTestResults,
mcpTesting,
setMcpTesting,
aiLlmProviderId,
setAiLlmProviderId,
aiLlmModel,
setAiLlmModel,
aiSendSystemPrompt,
setAiSendSystemPrompt,
aiSendCardInfo,
setAiSendCardInfo,
aiSendChatHistory,
setAiSendChatHistory,
aiMcpEnabled,
setAiMcpEnabled,
editAiSystemPrompt,
setEditAiSystemPrompt,
editAiRagEnabled,
setEditAiRagEnabled,
editAiRagTopK,
setEditAiRagTopK,
editAiMcpServers,
setEditAiMcpServers,
editAiAccessPolicy,
setEditAiAccessPolicy,
editMcpTestResults,
setEditMcpTestResults,
editMcpTesting,
setEditMcpTesting,
editAiLlmProviderId,
setEditAiLlmProviderId,
editAiLlmModel,
setEditAiLlmModel,
editAiSendSystemPrompt,
setEditAiSendSystemPrompt,
editAiSendCardInfo,
setEditAiSendCardInfo,
editAiSendChatHistory,
setEditAiSendChatHistory,
editAiMcpEnabled,
setEditAiMcpEnabled,
createApiEnabled,
setCreateApiEnabled,
createApiScopes,
setCreateApiScopes,
editApiEnabled,
setEditApiEnabled,
editApiScopes,
setEditApiScopes,
newApiKey,
setNewApiKey,
regenerateKeyConfirmOpen,
setRegenerateKeyConfirmOpen,
apiKeyCopied,
setApiKeyCopied,
createServiceDialogOpen,
setCreateServiceDialogOpen,
editServiceDialogOpen,
setEditServiceDialogOpen,
selectedService,
setSelectedService,
pageDialogOpen,
setPageDialogOpen,
editingPage,
setEditingPage,
pageForm,
setPageForm,
pageShowTemplates,
setPageShowTemplates,
pagePreviewOpen,
setPagePreviewOpen,
pagePreviewDialogOpen,
setPagePreviewDialogOpen,
slugCheckResult,
setSlugCheckResult,
previewFormsData,
previewExtraCtx,
botsData,
isLoading,
botDetailsData,
botLoading,
formsData,
servicesData,
servicesLoading,
pagesData,
pagesLoading,
createPageMutation,
updatePageMutation,
deletePageMutation,
openCreatePage,
openEditPage,
savePage,
createBotForm,
updateBotForm,
createServiceForm,
updateServiceForm,
testMcpMutation,
saveAiSettingsMutation,
orgUsersData,
llmProvidersData,
llmProviders,
createProviderObj,
createOllamaQuery,
editProviderObj,
editOllamaQuery,
createBotMutation,
updateBotMutation,
saveAccessPolicyMutation,
deleteBotMutation,
regeneratePasswordMutation,
saveApiAccessMutation,
regenerateApiKeyMutation,
apiModeBadge,
copyNewApiKey,
regenerateWebhookSecretMutation,
createServiceMutation,
updateServiceMutation,
deleteServiceMutation,
toggleServiceActiveMutation,
copyToClipboard,
bots,
botDetails,
services,
};
}

File diff suppressed because it is too large Load Diff