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:
386
client/src/components/bots/BotAiTab.tsx
Normal file
386
client/src/components/bots/BotAiTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
client/src/components/bots/BotApiDocsCard.tsx
Normal file
45
client/src/components/bots/BotApiDocsCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
client/src/components/bots/BotListCard.tsx
Normal file
77
client/src/components/bots/BotListCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
346
client/src/components/bots/BotSettingsTab.tsx
Normal file
346
client/src/components/bots/BotSettingsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
client/src/components/bots/BotSubscriptionsTab.tsx
Normal file
42
client/src/components/bots/BotSubscriptionsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
client/src/components/bots/BotWebhookTab.tsx
Normal file
114
client/src/components/bots/BotWebhookTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
client/src/components/bots/CollapsibleSection.tsx
Normal file
39
client/src/components/bots/CollapsibleSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
466
client/src/components/bots/CreateBotDialog.tsx
Normal file
466
client/src/components/bots/CreateBotDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
client/src/components/bots/CreateBotUsersPicker.tsx
Normal file
20
client/src/components/bots/CreateBotUsersPicker.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
358
client/src/components/bots/JsPagesSection.tsx
Normal file
358
client/src/components/bots/JsPagesSection.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
291
client/src/components/bots/ServicesSection.tsx
Normal file
291
client/src/components/bots/ServicesSection.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1101
client/src/components/bots/TabModulesSection.tsx
Normal file
1101
client/src/components/bots/TabModulesSection.tsx
Normal file
File diff suppressed because it is too large
Load Diff
115
client/src/components/bots/bots-types.ts
Normal file
115
client/src/components/bots/bots-types.ts
Normal 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 }
|
||||
73
client/src/components/bots/bots-utils.ts
Normal file
73
client/src/components/bots/bots-utils.ts
Normal 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>
|
||||
);
|
||||
};`;
|
||||
848
client/src/components/bots/useBotsController.tsx
Normal file
848
client/src/components/bots/useBotsController.tsx
Normal 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
Reference in New Issue
Block a user