mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-17 09:56:06 +00:00
681 lines
23 KiB
TypeScript
681 lines
23 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import type React from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import { useForm } from 'react-hook-form';
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import { z } from 'zod';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { toast } from 'sonner';
|
|
import {
|
|
Brain,
|
|
CircleAlert,
|
|
CircleCheck,
|
|
FileJson2,
|
|
Info,
|
|
LoaderCircle,
|
|
Power,
|
|
RefreshCw,
|
|
Trash2,
|
|
Unplug,
|
|
} from 'lucide-react';
|
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
|
import { Agent, ApiRespPluginSystemStatus } from '@/app/infra/entities/api';
|
|
import {
|
|
PipelineConfigStage,
|
|
PipelineConfigTab,
|
|
} from '@/app/infra/entities/pipeline';
|
|
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
|
import { extractI18nObject } from '@/i18n/I18nProvider';
|
|
import { cn } from '@/lib/utils';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import EmojiPicker from '@/components/ui/emoji-picker';
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '@/components/ui/card';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import {
|
|
Form,
|
|
FormControl,
|
|
FormDescription,
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
FormMessage,
|
|
} from '@/components/ui/form';
|
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
|
|
|
interface AgentFormComponentProps {
|
|
agentId: string;
|
|
onFinish: () => void;
|
|
onDeleted: () => void;
|
|
onDirtyChange?: (dirty: boolean) => void;
|
|
onSavingChange?: (saving: boolean) => void;
|
|
}
|
|
|
|
interface SectionItem {
|
|
label: string;
|
|
name: 'basic' | 'runner' | 'events';
|
|
icon: React.ElementType;
|
|
}
|
|
|
|
export default function AgentFormComponent({
|
|
agentId,
|
|
onFinish,
|
|
onDeleted,
|
|
onDirtyChange,
|
|
onSavingChange,
|
|
}: AgentFormComponentProps) {
|
|
const { t } = useTranslation();
|
|
const [activeSection, setActiveSection] =
|
|
useState<SectionItem['name']>('basic');
|
|
const [runnerConfigSchema, setRunnerConfigSchema] =
|
|
useState<PipelineConfigTab | null>(null);
|
|
const [pluginSystemStatus, setPluginSystemStatus] =
|
|
useState<ApiRespPluginSystemStatus | null>(null);
|
|
const [pluginStatusLoading, setPluginStatusLoading] = useState(true);
|
|
const [pluginStatusError, setPluginStatusError] = useState(false);
|
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const isSavingRef = useRef(false);
|
|
|
|
const formSchema = z.object({
|
|
basic: z.object({
|
|
name: z.string().min(1, { message: t('agents.nameRequired') }),
|
|
description: z.string().optional(),
|
|
emoji: z.string().optional(),
|
|
enabled: z.boolean().optional(),
|
|
}),
|
|
runner: z.record(z.string(), z.any()),
|
|
runner_config: z.record(z.string(), z.any()),
|
|
supported_event_patterns_text: z.string(),
|
|
});
|
|
type FormValues = z.infer<typeof formSchema>;
|
|
|
|
const form = useForm<FormValues>({
|
|
resolver: zodResolver(formSchema),
|
|
defaultValues: {
|
|
basic: {
|
|
name: '',
|
|
description: '',
|
|
emoji: '🤖',
|
|
enabled: true,
|
|
},
|
|
runner: {},
|
|
runner_config: {},
|
|
supported_event_patterns_text: '*',
|
|
},
|
|
});
|
|
|
|
const savedSnapshotRef = useRef('');
|
|
const initializedStagesRef = useRef<Set<string>>(new Set());
|
|
const watchedValues = form.watch();
|
|
const hasUnsavedChanges = (() => {
|
|
if (!savedSnapshotRef.current) return false;
|
|
return JSON.stringify(watchedValues) !== savedSnapshotRef.current;
|
|
})();
|
|
|
|
useEffect(() => {
|
|
onDirtyChange?.(hasUnsavedChanges);
|
|
}, [hasUnsavedChanges, onDirtyChange]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
Promise.all([httpClient.getAgentMetadata(), httpClient.getAgent(agentId)])
|
|
.then(([metadata, resp]) => {
|
|
if (cancelled) return;
|
|
setRunnerConfigSchema(metadata.runner_config ?? null);
|
|
const agent = resp.agent;
|
|
const config = (agent.config ?? {}) as Record<string, any>;
|
|
const loadedValues: FormValues = {
|
|
basic: {
|
|
name: agent.name ?? '',
|
|
description: agent.description ?? '',
|
|
emoji: agent.emoji || '🤖',
|
|
enabled: agent.enabled ?? true,
|
|
},
|
|
runner: (config.runner as Record<string, unknown>) ?? {},
|
|
runner_config:
|
|
(config.runner_config as Record<string, unknown>) ?? {},
|
|
supported_event_patterns_text: (
|
|
agent.supported_event_patterns ??
|
|
agent.capability?.supported_event_patterns ?? ['*']
|
|
).join('\n'),
|
|
};
|
|
form.reset(loadedValues);
|
|
savedSnapshotRef.current = JSON.stringify(loadedValues);
|
|
initializedStagesRef.current.clear();
|
|
})
|
|
.catch((err) => {
|
|
toast.error(t('agents.loadError') + err.msg);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [agentId, form, t]);
|
|
|
|
const loadPluginSystemStatus = useCallback(async () => {
|
|
setPluginStatusLoading(true);
|
|
setPluginStatusError(false);
|
|
try {
|
|
setPluginSystemStatus(await httpClient.getPluginSystemStatus());
|
|
} catch {
|
|
setPluginSystemStatus(null);
|
|
setPluginStatusError(true);
|
|
} finally {
|
|
setPluginStatusLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void loadPluginSystemStatus();
|
|
}, [loadPluginSystemStatus]);
|
|
|
|
const sections: SectionItem[] = [
|
|
{ label: t('agents.basicInfo'), name: 'basic', icon: Info },
|
|
{ label: t('agents.runnerSettings'), name: 'runner', icon: Brain },
|
|
{ label: t('agents.advanced'), name: 'events', icon: FileJson2 },
|
|
];
|
|
|
|
const currentRunner = (form.watch('runner') as Record<string, any>)?.id;
|
|
const runnerOptions = useMemo(() => {
|
|
const runnerStage = runnerConfigSchema?.stages.find(
|
|
(stage) => stage.name === 'runner',
|
|
);
|
|
return (
|
|
runnerStage?.config.find((item) => item.name === 'id')?.options ?? []
|
|
);
|
|
}, [runnerConfigSchema]);
|
|
const selectedRunnerOption = runnerOptions.find(
|
|
(option) => option.name === currentRunner,
|
|
);
|
|
|
|
function renderRunnerStatusActions(showRetry = true) {
|
|
return (
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
{showRetry && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => void loadPluginSystemStatus()}
|
|
>
|
|
<RefreshCw className="size-4" />
|
|
{t('common.retry')}
|
|
</Button>
|
|
)}
|
|
<Button type="button" variant="outline" size="sm" asChild>
|
|
<Link to="/home/extensions">{t('plugins.title')}</Link>
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderRunnerStatus() {
|
|
if (pluginStatusLoading) {
|
|
return (
|
|
<Alert>
|
|
<LoaderCircle className="animate-spin" />
|
|
<AlertTitle>{t('agents.runnerStatusLoading')}</AlertTitle>
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
if (pluginStatusError || !pluginSystemStatus) {
|
|
return (
|
|
<Alert variant="destructive">
|
|
<CircleAlert />
|
|
<AlertTitle>{t('agents.runnerStatusCheckFailed')}</AlertTitle>
|
|
<AlertDescription>
|
|
{t('agents.runnerStatusCheckFailedDescription')}
|
|
{renderRunnerStatusActions()}
|
|
</AlertDescription>
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
if (!pluginSystemStatus.is_enable) {
|
|
return (
|
|
<Alert variant="destructive">
|
|
<Power />
|
|
<AlertTitle>{t('plugins.systemDisabled')}</AlertTitle>
|
|
<AlertDescription>
|
|
{t('plugins.systemDisabledDesc')}
|
|
{renderRunnerStatusActions(false)}
|
|
</AlertDescription>
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
if (!pluginSystemStatus.is_connected) {
|
|
return (
|
|
<Alert variant="destructive">
|
|
<Unplug />
|
|
<AlertTitle>{t('plugins.connectionError')}</AlertTitle>
|
|
<AlertDescription>
|
|
{t('plugins.connectionErrorDesc')}
|
|
{renderRunnerStatusActions()}
|
|
</AlertDescription>
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
if (runnerOptions.length === 0) {
|
|
return (
|
|
<Alert variant="destructive">
|
|
<CircleAlert />
|
|
<AlertTitle>{t('agents.noRunnersAvailable')}</AlertTitle>
|
|
<AlertDescription>
|
|
{t('agents.noRunnersAvailableDescription')}
|
|
{renderRunnerStatusActions()}
|
|
</AlertDescription>
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
if (!currentRunner || !selectedRunnerOption) {
|
|
return (
|
|
<Alert variant="destructive">
|
|
<CircleAlert />
|
|
<AlertTitle>{t('agents.selectedRunnerUnavailable')}</AlertTitle>
|
|
<AlertDescription>
|
|
{t('agents.selectedRunnerUnavailableDescription', {
|
|
runner: currentRunner || t('agents.noRunnerSelected'),
|
|
})}
|
|
{renderRunnerStatusActions()}
|
|
</AlertDescription>
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Alert className="border-emerald-600/40 bg-emerald-500/5 text-emerald-950 dark:text-emerald-100">
|
|
<CircleCheck className="text-emerald-600" />
|
|
<AlertTitle>{t('agents.runnerReady')}</AlertTitle>
|
|
<AlertDescription>
|
|
{t('agents.runnerReadyDescription', {
|
|
runner: extractI18nObject(selectedRunnerOption.label),
|
|
})}
|
|
</AlertDescription>
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
function updateSnapshotIfInitial(stageKey: string) {
|
|
if (!initializedStagesRef.current.has(stageKey)) {
|
|
initializedStagesRef.current.add(stageKey);
|
|
if (!hasUnsavedChanges) {
|
|
savedSnapshotRef.current = JSON.stringify(form.getValues());
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleDynamicFormEmit(
|
|
formName: 'runner' | 'runner_config',
|
|
stageName: string,
|
|
values: object,
|
|
) {
|
|
if (formName === 'runner') {
|
|
form.setValue('runner', values, { shouldDirty: true });
|
|
updateSnapshotIfInitial(`runner.${stageName}`);
|
|
return;
|
|
}
|
|
|
|
const currentRunnerConfigs =
|
|
(form.getValues('runner_config') as Record<string, unknown>) || {};
|
|
form.setValue(
|
|
'runner_config',
|
|
{
|
|
...currentRunnerConfigs,
|
|
[stageName]: values,
|
|
},
|
|
{ shouldDirty: true },
|
|
);
|
|
updateSnapshotIfInitial(`runner_config.${stageName}`);
|
|
}
|
|
|
|
function renderDynamicStage(stage: PipelineConfigStage) {
|
|
const isRunnerSelector = stage.name === 'runner';
|
|
if (!isRunnerSelector && stage.name !== currentRunner) return null;
|
|
|
|
const initialValues = isRunnerSelector
|
|
? (form.watch('runner') as Record<string, unknown>) || {}
|
|
: ((form.watch('runner_config') as Record<string, any>) || {})[
|
|
stage.name
|
|
] || {};
|
|
|
|
return (
|
|
<Card key={stage.name}>
|
|
<CardHeader>
|
|
<CardTitle>{extractI18nObject(stage.label)}</CardTitle>
|
|
{stage.description && (
|
|
<CardDescription>
|
|
{extractI18nObject(stage.description)}
|
|
</CardDescription>
|
|
)}
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
<DynamicFormComponent
|
|
itemConfigList={stage.config}
|
|
initialValues={initialValues}
|
|
onSubmit={(values) =>
|
|
handleDynamicFormEmit(
|
|
isRunnerSelector ? 'runner' : 'runner_config',
|
|
stage.name,
|
|
values,
|
|
)
|
|
}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function normalizeEventPatterns(value: string): string[] {
|
|
const patterns = value
|
|
.split(/[\n,]/)
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
return patterns.length > 0 ? patterns : ['*'];
|
|
}
|
|
|
|
function handleSubmit(values: FormValues) {
|
|
if (isSavingRef.current) return;
|
|
const submittedSnapshot = JSON.stringify(values);
|
|
const runner = values.runner || {};
|
|
const agent: Partial<Agent> = {
|
|
name: values.basic.name,
|
|
description: values.basic.description ?? '',
|
|
emoji: values.basic.emoji,
|
|
enabled: values.basic.enabled ?? true,
|
|
component_ref: (runner.id as string) || null,
|
|
supported_event_patterns: normalizeEventPatterns(
|
|
values.supported_event_patterns_text,
|
|
),
|
|
config: {
|
|
runner,
|
|
runner_config: values.runner_config ?? {},
|
|
},
|
|
};
|
|
|
|
isSavingRef.current = true;
|
|
setIsSaving(true);
|
|
onSavingChange?.(true);
|
|
httpClient
|
|
.updateAgent(agentId, agent)
|
|
.then(() => {
|
|
savedSnapshotRef.current = submittedSnapshot;
|
|
onFinish();
|
|
toast.success(t('agents.saveSuccess'));
|
|
})
|
|
.catch((err) => {
|
|
toast.error(t('agents.saveError') + err.msg);
|
|
})
|
|
.finally(() => {
|
|
isSavingRef.current = false;
|
|
setIsSaving(false);
|
|
onSavingChange?.(false);
|
|
});
|
|
}
|
|
|
|
function confirmDelete() {
|
|
httpClient
|
|
.deleteAgent(agentId)
|
|
.then(() => {
|
|
toast.success(t('agents.deleteSuccess'));
|
|
setShowDeleteConfirm(false);
|
|
onDeleted();
|
|
})
|
|
.catch((err) => {
|
|
toast.error(t('agents.deleteError') + err.msg);
|
|
});
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="h-full p-0 flex flex-col">
|
|
<Form {...form}>
|
|
<form
|
|
id="agent-form"
|
|
onSubmit={form.handleSubmit(handleSubmit)}
|
|
className="h-full flex flex-col flex-1 min-h-0 mb-2"
|
|
>
|
|
<div className="flex-1 flex flex-col md:flex-row min-h-0">
|
|
<nav className="shrink-0 mb-4 md:mb-0 md:w-44 md:pr-4 md:mr-4 md:border-r overflow-x-auto md:overflow-x-visible md:overflow-y-auto">
|
|
<ul className="flex md:flex-col gap-1 md:space-y-1">
|
|
{sections.map((section) => {
|
|
const Icon = section.icon;
|
|
return (
|
|
<li key={section.name}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setActiveSection(section.name)}
|
|
className={cn(
|
|
'w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors text-left cursor-pointer whitespace-nowrap',
|
|
activeSection === section.name
|
|
? 'bg-accent text-accent-foreground'
|
|
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
|
|
)}
|
|
>
|
|
<Icon className="size-4 shrink-0" />
|
|
{section.label}
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</nav>
|
|
|
|
<div className="flex-1 overflow-y-auto min-h-0">
|
|
{activeSection === 'basic' && (
|
|
<div className="space-y-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>{t('agents.basicInfo')}</CardTitle>
|
|
<CardDescription>
|
|
{t('agents.basicInfoDescription')}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="flex gap-4 items-start">
|
|
<FormField
|
|
control={form.control}
|
|
name="basic.name"
|
|
render={({ field }) => (
|
|
<FormItem className="flex-1">
|
|
<FormLabel>
|
|
{t('common.name')}
|
|
<span className="text-destructive">*</span>
|
|
</FormLabel>
|
|
<FormControl>
|
|
<Input {...field} value={field.value ?? ''} />
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name="basic.emoji"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('common.icon')}</FormLabel>
|
|
<FormControl>
|
|
<EmojiPicker
|
|
value={field.value}
|
|
onChange={field.onChange}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="basic.description"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('common.description')}</FormLabel>
|
|
<FormControl>
|
|
<Input {...field} value={field.value ?? ''} />
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="basic.enabled"
|
|
render={({ field }) => (
|
|
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
|
<div className="space-y-0.5">
|
|
<FormLabel className="flex items-center gap-2">
|
|
<Power className="size-4" />
|
|
{t('agents.enabled')}
|
|
</FormLabel>
|
|
<FormDescription>
|
|
{t('agents.enabledDescription')}
|
|
</FormDescription>
|
|
</div>
|
|
<FormControl>
|
|
<Switch
|
|
checked={field.value ?? true}
|
|
onCheckedChange={field.onChange}
|
|
/>
|
|
</FormControl>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border-destructive/50">
|
|
<CardHeader>
|
|
<CardTitle className="text-destructive">
|
|
{t('agents.dangerZone')}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{t('agents.dangerZoneDescription')}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-1">
|
|
<p className="text-sm font-medium">
|
|
{t('agents.deleteAgentAction')}
|
|
</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('agents.deleteAgentHint')}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="destructive"
|
|
size="sm"
|
|
disabled={isSaving}
|
|
onClick={() => setShowDeleteConfirm(true)}
|
|
>
|
|
<Trash2 className="size-4 mr-1.5" />
|
|
{t('common.delete')}
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{activeSection === 'runner' && (
|
|
<div className="space-y-6">
|
|
{renderRunnerStatus()}
|
|
{runnerConfigSchema?.stages.map((stage) =>
|
|
renderDynamicStage(stage),
|
|
)}
|
|
{!runnerConfigSchema && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>{t('agents.runnerSettings')}</CardTitle>
|
|
<CardDescription>
|
|
{t('agents.noRunnerMetadata')}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeSection === 'events' && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>{t('agents.bindableEvents')}</CardTitle>
|
|
<CardDescription>
|
|
{t('agents.bindableEventsDescription')}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<FormField
|
|
control={form.control}
|
|
name="supported_event_patterns_text"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('agents.supportedEvents')}</FormLabel>
|
|
<FormControl>
|
|
<Textarea
|
|
{...field}
|
|
className="min-h-32 font-mono text-sm"
|
|
placeholder={'*\nmessage.received\ngroup.*'}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t('agents.supportedEventsDescription')}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</Form>
|
|
</div>
|
|
|
|
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="py-4">{t('agents.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>
|
|
</>
|
|
);
|
|
}
|