Files
LangBot/web/src/app/home/bots/BotDetailContent.tsx
T

371 lines
13 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import BotForm, {
BotFormHandle,
} from '@/app/home/bots/components/bot-form/BotForm';
import { BotLogListComponent } from '@/app/home/bots/components/bot-log/view/BotLogListComponent';
import BotSessionMonitor from '@/app/home/bots/components/bot-session/BotSessionMonitor';
import type { BotSessionMonitorHandle } from '@/app/home/bots/components/bot-session/BotSessionMonitor';
import { httpClient } from '@/app/infra/http/HttpClient';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { useTranslation } from 'react-i18next';
import { Settings, FileText, Users, RefreshCw, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import { useCurrentWorkspace } from '@/app/infra/http';
import { Bot } from '@/app/infra/entities/api';
import EntityBasicInfoDialog, {
EntityBasicInfoValues,
} from '@/app/home/components/entity-basic-info/EntityBasicInfoDialog';
import AdapterEventDebugDialog from './components/bot-form/AdapterEventDebugDialog';
import EntityTitleEditButton from '@/app/home/components/entity-basic-info/EntityTitleEditButton';
export default function BotDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new';
const navigate = useNavigate();
const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace();
const canManage =
currentWorkspace?.permissions.includes('resource.manage') ?? false;
const canViewMonitoring =
currentWorkspace?.permissions.includes('resource.view') ?? false;
const { refreshBots, bots, setDetailEntityName } = useSidebarData();
// Set breadcrumb entity name
useEffect(() => {
if (isCreateMode) {
setDetailEntityName(t('bots.createBot'));
} else {
const bot = bots.find((b) => b.id === id);
setDetailEntityName(bot?.name ?? id);
}
return () => setDetailEntityName(null);
}, [id, isCreateMode, bots, setDetailEntityName, t]);
const [activeTab, setActiveTab] = useState('config');
const [adapterLabel, setAdapterLabel] = useState('');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [basicInfoOpen, setBasicInfoOpen] = useState(false);
const [bot, setBot] = useState<Bot | null>(null);
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false);
const sessionMonitorRef = useRef<BotSessionMonitorHandle>(null);
const botFormRef = useRef<BotFormHandle>(null);
// Track whether the form has unsaved changes
const [formDirty, setFormDirty] = useState(false);
// Enable state managed here so the header switch works
const [botEnabled, setBotEnabled] = useState(true);
const [enableLoaded, setEnableLoaded] = useState(false);
// Fetch bot enable state
useEffect(() => {
if (!isCreateMode) {
httpClient.getBot(id).then((res) => {
setBot(res.bot);
setBotEnabled(res.bot.enable ?? true);
setEnableLoaded(true);
});
}
}, [id, isCreateMode]);
const handleEnableToggle = useCallback(
async (checked: boolean) => {
const prev = botEnabled;
setBotEnabled(checked);
try {
await httpClient.updateBot(id, { enable: checked });
setBot((current) =>
current ? { ...current, enable: checked } : current,
);
refreshBots();
} catch {
setBotEnabled(prev);
toast.error(t('bots.setBotEnableError'));
}
},
[id, botEnabled, refreshBots, t],
);
function handleFormSubmit() {
// Re-sync enable state after form save (form may update enable too)
httpClient.getBot(id).then((res) => {
setBot(res.bot);
setBotEnabled(res.bot.enable ?? true);
});
refreshBots();
}
function handleBotDeleted() {
refreshBots();
navigate('/home/bots');
}
function handleNewBotCreated(newBotId: string) {
refreshBots();
navigate(`/home/bots?id=${encodeURIComponent(newBotId)}`);
}
async function saveBasicInfo(values: EntityBasicInfoValues) {
try {
await httpClient.updateBot(id, {
name: values.name,
description: values.description,
});
setBot((current) => (current ? { ...current, ...values } : current));
botFormRef.current?.syncBasicInfo(values);
await refreshBots();
toast.success(t('bots.saveSuccess'));
} catch (error) {
const message =
typeof error === 'object' && error && 'msg' in error
? String((error as { msg?: string }).msg || '')
: '';
toast.error(t('bots.saveError') + message);
throw error;
}
}
function confirmDelete() {
httpClient
.deleteBot(id)
.then(() => {
setShowDeleteConfirm(false);
toast.success(t('bots.deleteSuccess'));
handleBotDeleted();
})
.catch((err) => {
toast.error(t('bots.deleteError') + err.msg);
});
}
// ==================== Create Mode ====================
if (isCreateMode) {
return (
<div className="flex h-full min-w-0 flex-col">
{/* Header */}
<div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold">{t('bots.createBot')}</h1>
{canManage && (
<Button type="submit" form="bot-form">
{t('common.submit')}
</Button>
)}
</div>
{/* Content */}
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden">
<div className="mx-auto w-full min-w-0 max-w-3xl pb-8">
<fieldset className="contents" disabled={!canManage}>
<BotForm
initBotId={undefined}
onFormSubmit={handleFormSubmit}
onNewBotCreated={handleNewBotCreated}
/>
</fieldset>
</div>
</div>
</div>
);
}
// ==================== Edit Mode ====================
return (
<>
<div className="flex h-full min-w-0 flex-col">
{/* Sticky Header: title + enable switch + save button */}
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 pb-4">
<div className="flex min-w-0 items-center gap-4">
<div className="flex min-w-0 items-center gap-1">
<h1 className="truncate text-xl font-semibold">
{bot?.name || t('bots.editBot')}
</h1>
{canManage && (
<EntityTitleEditButton onClick={() => setBasicInfoOpen(true)} />
)}
</div>
{enableLoaded && (
<div className="flex items-center gap-2">
<Switch
id="bot-enable-switch"
checked={botEnabled}
onCheckedChange={handleEnableToggle}
disabled={!canManage}
/>
<Label
htmlFor="bot-enable-switch"
className="text-sm text-muted-foreground cursor-pointer"
>
{t('common.enable')}
</Label>
</div>
)}
</div>
{canManage && (
<div className="flex shrink-0 items-center gap-2">
<AdapterEventDebugDialog
key={id}
botId={id}
adapterLabel={adapterLabel}
/>
<Button
type="submit"
form="bot-form"
disabled={!formDirty}
className={activeTab !== 'config' ? 'invisible' : ''}
>
{t('common.save')}
</Button>
<Button
type="button"
variant="destructive"
onClick={() => setShowDeleteConfirm(true)}
>
<Trash2 className="size-4" />
{t('common.delete')}
</Button>
</div>
)}
</div>
{/* Horizontal Tabs */}
<Tabs
key={id}
value={activeTab}
onValueChange={setActiveTab}
className="flex min-h-0 min-w-0 flex-1 flex-col"
>
<div className="flex shrink-0 items-center gap-1">
<TabsList>
<TabsTrigger value="config" className="gap-1.5">
<Settings className="size-3.5" />
{t('bots.configuration')}
</TabsTrigger>
{canViewMonitoring && (
<TabsTrigger value="logs" className="gap-1.5">
<FileText className="size-3.5" />
{t('bots.logs')}
</TabsTrigger>
)}
{canViewMonitoring && (
<TabsTrigger value="sessions" className="gap-1.5">
<Users className="size-3.5" />
{t('bots.sessionMonitor.title')}
</TabsTrigger>
)}
</TabsList>
{activeTab === 'sessions' && (
<button
type="button"
aria-label={t('bots.sessionMonitor.refresh')}
title={t('bots.sessionMonitor.refresh')}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
disabled={isRefreshingSessions}
onClick={() => {
if (isRefreshingSessions) return;
setIsRefreshingSessions(true);
const minDelay = new Promise((r) => setTimeout(r, 500));
Promise.all([
sessionMonitorRef.current?.refreshSessions(),
minDelay,
]).finally(() => setIsRefreshingSessions(false));
}}
>
<RefreshCw
className={cn(
'size-3.5',
isRefreshingSessions && 'animate-spin',
)}
/>
</button>
)}
</div>
{/* Tab: Configuration */}
<TabsContent
value="config"
className="mt-4 min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden lg:overflow-hidden"
>
<div className="min-h-0 min-w-0 pb-4 lg:h-full lg:pb-0">
<fieldset className="contents" disabled={!canManage}>
<BotForm
ref={botFormRef}
initBotId={id}
onFormSubmit={handleFormSubmit}
onNewBotCreated={handleNewBotCreated}
onDirtyChange={setFormDirty}
onAdapterLabelChange={setAdapterLabel}
/>
</fieldset>
</div>
</TabsContent>
{/* Tab: Logs */}
{canViewMonitoring && (
<TabsContent
value="logs"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<BotLogListComponent botId={id} />
</TabsContent>
)}
{/* Tab: Sessions */}
{canViewMonitoring && (
<TabsContent value="sessions" className="flex-1 min-h-0 mt-4">
<BotSessionMonitor ref={sessionMonitorRef} botId={id} />
</TabsContent>
)}
</Tabs>
</div>
{/* Delete confirmation dialog */}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
<DialogDescription className="sr-only">
{t('bots.deleteConfirmation')}
</DialogDescription>
</DialogHeader>
<div className="py-4">{t('bots.deleteConfirmation')}</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowDeleteConfirm(false)}
>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={confirmDelete}>
{t('common.confirmDelete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<EntityBasicInfoDialog
open={basicInfoOpen}
onOpenChange={setBasicInfoOpen}
values={{
name: bot?.name || '',
description: bot?.description || '',
}}
showEmoji={false}
onSave={saveBasicInfo}
/>
</>
);
}