mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-26 12:17:14 +00:00
feat(web): unify processor detail workbench
This commit is contained in:
@@ -1,13 +1,11 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Bug, Settings } from 'lucide-react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||||
import { Agent } from '@/app/infra/entities/api';
|
import { Agent } from '@/app/infra/entities/api';
|
||||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||||
|
import ProcessorDetailWorkbench from '@/app/home/components/processor-detail/ProcessorDetailWorkbench';
|
||||||
import PipelineDetailContent from '@/app/home/pipelines/PipelineDetailContent';
|
import PipelineDetailContent from '@/app/home/pipelines/PipelineDetailContent';
|
||||||
import AgentCreateContent from './components/AgentCreateContent';
|
import AgentCreateContent from './components/AgentCreateContent';
|
||||||
import AgentDebugPanel from './components/AgentDebugPanel';
|
import AgentDebugPanel from './components/AgentDebugPanel';
|
||||||
@@ -18,6 +16,8 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const currentWorkspace = useCurrentWorkspace();
|
const currentWorkspace = useCurrentWorkspace();
|
||||||
|
const canManage =
|
||||||
|
currentWorkspace?.permissions.includes('resource.manage') ?? false;
|
||||||
const canOperate =
|
const canOperate =
|
||||||
currentWorkspace?.permissions.includes('runtime.operate') ?? false;
|
currentWorkspace?.permissions.includes('runtime.operate') ?? false;
|
||||||
const { refreshPipelines, pipelines, setDetailEntityName } = useSidebarData();
|
const { refreshPipelines, pipelines, setDetailEntityName } = useSidebarData();
|
||||||
@@ -25,7 +25,6 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
|||||||
const [loading, setLoading] = useState(!isCreateMode);
|
const [loading, setLoading] = useState(!isCreateMode);
|
||||||
const [formDirty, setFormDirty] = useState(false);
|
const [formDirty, setFormDirty] = useState(false);
|
||||||
const [formSaving, setFormSaving] = useState(false);
|
const [formSaving, setFormSaving] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState('config');
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCreateMode) {
|
if (isCreateMode) {
|
||||||
@@ -79,42 +78,17 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-w-0 flex-col">
|
<ProcessorDetailWorkbench
|
||||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
key={id}
|
||||||
<h1 className="text-xl font-semibold">{t('agents.editAgent')}</h1>
|
title={t('agents.editAgent')}
|
||||||
<Button
|
saveLabel={t('common.save')}
|
||||||
type="submit"
|
saveFormId="agent-form"
|
||||||
form="agent-form"
|
canSave={canManage}
|
||||||
disabled={!formDirty || formSaving}
|
isDirty={formDirty}
|
||||||
className={activeTab !== 'config' ? 'invisible' : ''}
|
isSaving={formSaving}
|
||||||
>
|
configTitle={t('pipelines.configuration')}
|
||||||
{t('common.save')}
|
configContent={
|
||||||
</Button>
|
<fieldset className="contents" disabled={!canManage}>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs
|
|
||||||
key={id}
|
|
||||||
value={activeTab}
|
|
||||||
onValueChange={setActiveTab}
|
|
||||||
className="flex min-h-0 min-w-0 flex-1 flex-col"
|
|
||||||
>
|
|
||||||
<TabsList className="shrink-0">
|
|
||||||
<TabsTrigger value="config" className="gap-1.5">
|
|
||||||
<Settings className="size-3.5" />
|
|
||||||
{t('pipelines.configuration')}
|
|
||||||
</TabsTrigger>
|
|
||||||
{canOperate && (
|
|
||||||
<TabsTrigger value="debug" className="gap-1.5">
|
|
||||||
<Bug className="size-3.5" />
|
|
||||||
{t('agents.debugTab')}
|
|
||||||
</TabsTrigger>
|
|
||||||
)}
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent
|
|
||||||
value="config"
|
|
||||||
className="mt-4 min-h-0 min-w-0 flex-1 overflow-hidden"
|
|
||||||
>
|
|
||||||
<AgentFormComponent
|
<AgentFormComponent
|
||||||
agentId={id}
|
agentId={id}
|
||||||
onFinish={() => {
|
onFinish={() => {
|
||||||
@@ -127,23 +101,21 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
|||||||
onDirtyChange={setFormDirty}
|
onDirtyChange={setFormDirty}
|
||||||
onSavingChange={setFormSaving}
|
onSavingChange={setFormSaving}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</fieldset>
|
||||||
|
}
|
||||||
{canOperate && (
|
debugTitle={canOperate ? t('agents.debugTab') : undefined}
|
||||||
<TabsContent
|
debugContent={
|
||||||
value="debug"
|
canOperate ? (
|
||||||
className="mt-4 min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden"
|
<AgentDebugPanel
|
||||||
>
|
agentId={id}
|
||||||
<AgentDebugPanel
|
supportedEventPatterns={
|
||||||
agentId={id}
|
agent.supported_event_patterns ??
|
||||||
supportedEventPatterns={
|
agent.capability?.supported_event_patterns ?? ['*']
|
||||||
agent.supported_event_patterns ??
|
}
|
||||||
agent.capability?.supported_event_patterns ?? ['*']
|
/>
|
||||||
}
|
) : undefined
|
||||||
/>
|
}
|
||||||
</TabsContent>
|
unsavedLabel={t('pipelines.unsavedChanges')}
|
||||||
)}
|
/>
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,11 @@
|
|||||||
import { useMemo, useRef, useState } from 'react';
|
import { useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import { AlertTriangle, LoaderCircle, Play, RotateCcw } from 'lucide-react';
|
||||||
AlertTriangle,
|
|
||||||
Braces,
|
|
||||||
LoaderCircle,
|
|
||||||
MessageSquare,
|
|
||||||
Play,
|
|
||||||
RotateCcw,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card';
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import {
|
import {
|
||||||
@@ -205,162 +191,149 @@ export default function AgentDebugPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto grid w-full min-w-0 max-w-6xl gap-6 pb-8 lg:grid-cols-[minmax(0,1fr)_minmax(22rem,0.8fr)]">
|
<div className="flex h-full min-h-0 min-w-0 flex-col">
|
||||||
<Card className="min-w-0">
|
<div className="shrink-0 space-y-3 border-b p-3">
|
||||||
<CardHeader>
|
<div className="flex items-end gap-2">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="min-w-0 flex-1 space-y-1.5">
|
||||||
<div className="space-y-1">
|
<Label>{t('agents.debugEventType')}</Label>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<Select value={preset} onValueChange={selectPreset}>
|
||||||
{isMessageEvent ? (
|
<SelectTrigger className="w-full">
|
||||||
<MessageSquare className="size-5" />
|
<SelectValue />
|
||||||
) : (
|
</SelectTrigger>
|
||||||
<Braces className="size-5" />
|
<SelectContent>
|
||||||
)}
|
{EVENT_PRESETS.map((item) => (
|
||||||
{t('agents.debugTitle')}
|
<SelectItem key={item.value} value={item.value}>
|
||||||
</CardTitle>
|
{t(item.labelKey)}
|
||||||
<CardDescription>{t('agents.debugDescription')}</CardDescription>
|
</SelectItem>
|
||||||
</div>
|
))}
|
||||||
<Button
|
</SelectContent>
|
||||||
type="button"
|
</Select>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={resetSession}
|
|
||||||
>
|
|
||||||
<RotateCcw className="size-4" />
|
|
||||||
{t('agents.debugResetSession')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
<Button
|
||||||
<CardContent className="space-y-5">
|
type="button"
|
||||||
<Alert>
|
variant="outline"
|
||||||
<AlertTriangle />
|
size="icon"
|
||||||
<AlertTitle>{t('agents.debugActualRun')}</AlertTitle>
|
onClick={resetSession}
|
||||||
<AlertDescription>
|
title={t('agents.debugResetSession')}
|
||||||
{t('agents.debugActualRunDescription')}
|
>
|
||||||
</AlertDescription>
|
<RotateCcw className="size-4" />
|
||||||
</Alert>
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
{preset === 'custom' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<Label>{t('agents.debugEventType')}</Label>
|
<Label htmlFor="agent-debug-custom-event">
|
||||||
<Select value={preset} onValueChange={selectPreset}>
|
{t('agents.debugCustomEventType')}
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{EVENT_PRESETS.map((item) => (
|
|
||||||
<SelectItem key={item.value} value={item.value}>
|
|
||||||
{t(item.labelKey)}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
{preset === 'custom' && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="agent-debug-custom-event">
|
|
||||||
{t('agents.debugCustomEventType')}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="agent-debug-custom-event"
|
|
||||||
value={customEventType}
|
|
||||||
onChange={(event) => setCustomEventType(event.target.value)}
|
|
||||||
placeholder="custom.event"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="agent-debug-input">
|
|
||||||
{isMessageEvent
|
|
||||||
? t('agents.debugMessageInput')
|
|
||||||
: t('agents.debugEventSummary')}
|
|
||||||
</Label>
|
</Label>
|
||||||
<Textarea
|
<Input
|
||||||
id="agent-debug-input"
|
id="agent-debug-custom-event"
|
||||||
value={inputText}
|
value={customEventType}
|
||||||
onChange={(event) => setInputText(event.target.value)}
|
onChange={(event) => setCustomEventType(event.target.value)}
|
||||||
className="min-h-24 resize-y"
|
placeholder="custom.event"
|
||||||
placeholder={t('agents.debugInputPlaceholder')}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<Alert className="py-2">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<AlertTriangle />
|
||||||
<Label htmlFor="agent-debug-payload">
|
<AlertTitle>{t('agents.debugActualRun')}</AlertTitle>
|
||||||
{t('agents.debugEventPayload')}
|
<AlertDescription className="text-xs">
|
||||||
</Label>
|
{t('agents.debugActualRunDescription')}
|
||||||
<span className="text-xs text-muted-foreground">
|
</AlertDescription>
|
||||||
{t('agents.debugSupportedEvents')}: {supportedLabel}
|
</Alert>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
|
<div className="mb-3">
|
||||||
|
<p className="text-sm font-medium">{t('agents.debugTranscript')}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('agents.debugTranscriptDescription')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<div className="flex min-h-48 items-center justify-center rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||||
|
{t('agents.debugEmptyTranscript')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
className={`rounded-lg border p-3 ${
|
||||||
|
entry.direction === 'output'
|
||||||
|
? 'border-primary/20 bg-primary/5'
|
||||||
|
: entry.direction === 'error'
|
||||||
|
? 'border-destructive/30 bg-destructive/5'
|
||||||
|
: 'bg-muted/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
|
<Badge variant="outline">{entry.eventType}</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{entry.direction === 'output'
|
||||||
|
? t('agents.debugAgentOutput')
|
||||||
|
: entry.direction === 'error'
|
||||||
|
? t('common.error')
|
||||||
|
: t('agents.debugTestInput')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<pre className="min-w-0 whitespace-pre-wrap break-words font-sans text-sm leading-relaxed">
|
||||||
|
{entry.text}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shrink-0 space-y-3 border-t p-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="agent-debug-input">
|
||||||
|
{isMessageEvent
|
||||||
|
? t('agents.debugMessageInput')
|
||||||
|
: t('agents.debugEventSummary')}
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="agent-debug-input"
|
||||||
|
value={inputText}
|
||||||
|
onChange={(event) => setInputText(event.target.value)}
|
||||||
|
className="min-h-20 resize-y"
|
||||||
|
placeholder={t('agents.debugInputPlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details className="rounded-md border bg-muted/20 px-3 py-2">
|
||||||
|
<summary className="cursor-pointer text-xs font-medium">
|
||||||
|
{t('agents.debugEventPayload')}
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('agents.debugSupportedEvents')}: {supportedLabel}
|
||||||
|
</p>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="agent-debug-payload"
|
id="agent-debug-payload"
|
||||||
value={eventDataText}
|
value={eventDataText}
|
||||||
onChange={(event) => setEventDataText(event.target.value)}
|
onChange={(event) => setEventDataText(event.target.value)}
|
||||||
className="min-h-40 resize-y font-mono text-xs"
|
className="min-h-28 resize-y font-mono text-xs"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<Button
|
||||||
<Button type="button" disabled={running} onClick={runDebugEvent}>
|
type="button"
|
||||||
{running ? (
|
className="w-full"
|
||||||
<LoaderCircle className="size-4 animate-spin" />
|
disabled={running}
|
||||||
) : (
|
onClick={runDebugEvent}
|
||||||
<Play className="size-4" />
|
>
|
||||||
)}
|
{running ? (
|
||||||
{running ? t('agents.debugRunning') : t('agents.debugRun')}
|
<LoaderCircle className="size-4 animate-spin" />
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="min-h-[28rem] min-w-0">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{t('agents.debugTranscript')}</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{t('agents.debugTranscriptDescription')}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{entries.length === 0 ? (
|
|
||||||
<div className="flex min-h-72 items-center justify-center rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">
|
|
||||||
{t('agents.debugEmptyTranscript')}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="max-h-[42rem] space-y-4 overflow-y-auto pr-1">
|
<Play className="size-4" />
|
||||||
{entries.map((entry) => (
|
|
||||||
<div
|
|
||||||
key={entry.id}
|
|
||||||
className={`rounded-lg border p-3 ${
|
|
||||||
entry.direction === 'output'
|
|
||||||
? 'border-primary/20 bg-primary/5'
|
|
||||||
: entry.direction === 'error'
|
|
||||||
? 'border-destructive/30 bg-destructive/5'
|
|
||||||
: 'bg-muted/40'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="mb-2 flex items-center justify-between gap-2">
|
|
||||||
<Badge variant="outline">{entry.eventType}</Badge>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{entry.direction === 'output'
|
|
||||||
? t('agents.debugAgentOutput')
|
|
||||||
: entry.direction === 'error'
|
|
||||||
? t('common.error')
|
|
||||||
: t('agents.debugTestInput')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<pre className="min-w-0 whitespace-pre-wrap break-words font-sans text-sm leading-relaxed">
|
|
||||||
{entry.text}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
{running ? t('agents.debugRunning') : t('agents.debugRun')}
|
||||||
</Card>
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,17 @@ import { z } from 'zod';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
|
Bot,
|
||||||
CircleAlert,
|
CircleAlert,
|
||||||
CircleCheck,
|
CircleCheck,
|
||||||
|
Info,
|
||||||
LoaderCircle,
|
LoaderCircle,
|
||||||
Power,
|
Power,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
SlidersHorizontal,
|
||||||
Trash2,
|
Trash2,
|
||||||
Unplug,
|
Unplug,
|
||||||
|
Zap,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { Agent, ApiRespPluginSystemStatus } from '@/app/infra/entities/api';
|
import { Agent, ApiRespPluginSystemStatus } from '@/app/infra/entities/api';
|
||||||
@@ -60,6 +64,8 @@ interface AgentFormComponentProps {
|
|||||||
onSavingChange?: (saving: boolean) => void;
|
onSavingChange?: (saving: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AgentConfigSection = 'events' | 'runner' | 'runner_config' | 'basic';
|
||||||
|
|
||||||
export default function AgentFormComponent({
|
export default function AgentFormComponent({
|
||||||
agentId,
|
agentId,
|
||||||
onFinish,
|
onFinish,
|
||||||
@@ -76,6 +82,8 @@ export default function AgentFormComponent({
|
|||||||
const [pluginStatusError, setPluginStatusError] = useState(false);
|
const [pluginStatusError, setPluginStatusError] = useState(false);
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [activeSection, setActiveSection] =
|
||||||
|
useState<AgentConfigSection>('runner');
|
||||||
const isSavingRef = useRef(false);
|
const isSavingRef = useRef(false);
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
@@ -182,6 +190,35 @@ export default function AgentFormComponent({
|
|||||||
const selectedRunnerOption = runnerOptions.find(
|
const selectedRunnerOption = runnerOptions.find(
|
||||||
(option) => option.name === currentRunner,
|
(option) => option.name === currentRunner,
|
||||||
);
|
);
|
||||||
|
const runnerSelectorStage = runnerConfigSchema?.stages.find(
|
||||||
|
(stage) => stage.name === 'runner',
|
||||||
|
);
|
||||||
|
const activeRunnerStage = runnerConfigSchema?.stages.find(
|
||||||
|
(stage) => stage.name === currentRunner,
|
||||||
|
);
|
||||||
|
const primarySections: Array<{
|
||||||
|
name: AgentConfigSection;
|
||||||
|
label: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
name: 'events',
|
||||||
|
label: t('agents.bindableEvents'),
|
||||||
|
icon: Zap,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'runner',
|
||||||
|
label: t('agents.runnerSettings'),
|
||||||
|
icon: Bot,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'runner_config',
|
||||||
|
label: selectedRunnerOption
|
||||||
|
? extractI18nObject(selectedRunnerOption.label)
|
||||||
|
: t('pipelines.configuration'),
|
||||||
|
icon: SlidersHorizontal,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
function renderRunnerStatusActions(showRetry = true) {
|
function renderRunnerStatusActions(showRetry = true) {
|
||||||
return (
|
return (
|
||||||
@@ -433,63 +470,147 @@ export default function AgentFormComponent({
|
|||||||
onSubmit={form.handleSubmit(handleSubmit)}
|
onSubmit={form.handleSubmit(handleSubmit)}
|
||||||
className="mb-2 flex h-full min-h-0 min-w-0 flex-1 flex-col"
|
className="mb-2 flex h-full min-h-0 min-w-0 flex-1 flex-col"
|
||||||
>
|
>
|
||||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
<nav className="mb-4 shrink-0 space-y-2 border-b pb-4">
|
||||||
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden">
|
<div className="overflow-x-auto">
|
||||||
<div className="mx-auto flex w-full min-w-0 max-w-5xl flex-col gap-6 pb-8">
|
<ol className="grid min-w-[34rem] grid-cols-3 gap-2">
|
||||||
{
|
{primarySections.map((section, index) => {
|
||||||
<div className="contents">
|
const Icon = section.icon;
|
||||||
<Card className="order-2">
|
return (
|
||||||
|
<li key={section.name} className="min-w-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveSection(section.name)}
|
||||||
|
className={`flex w-full min-w-0 items-center gap-2 rounded-lg border px-3 py-3 text-left text-sm font-medium transition-colors ${
|
||||||
|
activeSection === section.name
|
||||||
|
? 'border-primary/50 bg-primary/5 text-foreground shadow-sm'
|
||||||
|
: 'border-border bg-background text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`flex size-7 shrink-0 items-center justify-center rounded-full text-xs ${
|
||||||
|
activeSection === section.name
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-muted text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<Icon className="hidden size-4 shrink-0 xl:block" />
|
||||||
|
<span className="min-w-0 leading-tight">
|
||||||
|
{section.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveSection('basic')}
|
||||||
|
className={`flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors ${
|
||||||
|
activeSection === 'basic'
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Info className="size-3.5" />
|
||||||
|
{t('agents.basicInfo')}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<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-5xl space-y-6 pb-8">
|
||||||
|
{activeSection === 'runner' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{renderRunnerStatus()}
|
||||||
|
{runnerSelectorStage
|
||||||
|
? renderDynamicStage(runnerSelectorStage)
|
||||||
|
: !runnerConfigSchema && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
{t('agents.runnerSettings')}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t('agents.noRunnerMetadata')}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeSection === 'runner_config' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{activeRunnerStage ? (
|
||||||
|
renderDynamicStage(activeRunnerStage)
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('agents.basicInfo')}</CardTitle>
|
<CardTitle>{t('agents.runnerSettings')}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{t('agents.basicInfoDescription')}
|
{t('agents.noRunnerMetadata')}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
</Card>
|
||||||
<div className="flex gap-4 items-start">
|
)}
|
||||||
<FormField
|
</div>
|
||||||
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>
|
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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 items-start gap-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="basic.description"
|
name="basic.name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem className="flex-1">
|
||||||
<FormLabel>{t('common.description')}</FormLabel>
|
<FormLabel>
|
||||||
|
{t('common.name')}
|
||||||
|
<span className="text-destructive">*</span>
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} value={field.value ?? ''} />
|
<Input {...field} value={field.value ?? ''} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -497,122 +618,98 @@ export default function AgentFormComponent({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="basic.enabled"
|
name="basic.emoji"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
<FormItem>
|
||||||
<div className="space-y-0.5">
|
<FormLabel>{t('common.icon')}</FormLabel>
|
||||||
<FormLabel className="flex items-center gap-2">
|
|
||||||
<Power className="size-4" />
|
|
||||||
{t('agents.enabled')}
|
|
||||||
</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
{t('agents.enabledDescription')}
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Switch
|
<EmojiPicker
|
||||||
checked={field.value ?? true}
|
value={field.value}
|
||||||
onCheckedChange={field.onChange}
|
onChange={field.onChange}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="order-4 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>
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
<div className="order-1 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>
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
<Card className="order-3">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{t('agents.bindableEvents')}</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{t('agents.bindableEventsDescription')}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="supported_event_patterns_text"
|
name="basic.description"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>{t('common.description')}</FormLabel>
|
||||||
{t('agents.supportedEvents')}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Input {...field} value={field.value ?? ''} />
|
||||||
{...field}
|
</FormControl>
|
||||||
className="min-h-32 font-mono text-sm"
|
<FormMessage />
|
||||||
placeholder={'*\nmessage.received\ngroup.*'}
|
</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>
|
</FormControl>
|
||||||
<FormDescription>
|
|
||||||
{t('agents.supportedEventsDescription')}
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
}
|
|
||||||
</div>
|
<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 gap-4">
|
||||||
|
<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="mr-1.5 size-4" />
|
||||||
|
{t('common.delete')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { ReactNode, useState } from 'react';
|
||||||
|
import { BarChart3, Bug, Settings } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface ProcessorMonitoringView {
|
||||||
|
label: string;
|
||||||
|
content: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProcessorDetailWorkbenchProps {
|
||||||
|
title: string;
|
||||||
|
saveLabel: string;
|
||||||
|
saveFormId: string;
|
||||||
|
canSave: boolean;
|
||||||
|
isDirty: boolean;
|
||||||
|
isSaving: boolean;
|
||||||
|
configTitle: string;
|
||||||
|
configContent: ReactNode;
|
||||||
|
debugTitle?: string;
|
||||||
|
debugContent?: ReactNode;
|
||||||
|
debugConnected?: boolean;
|
||||||
|
debugConnectedLabel?: string;
|
||||||
|
debugDisconnectedLabel?: string;
|
||||||
|
unsavedLabel?: string;
|
||||||
|
monitoring?: ProcessorMonitoringView;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProcessorDetailWorkbench({
|
||||||
|
title,
|
||||||
|
saveLabel,
|
||||||
|
saveFormId,
|
||||||
|
canSave,
|
||||||
|
isDirty,
|
||||||
|
isSaving,
|
||||||
|
configTitle,
|
||||||
|
configContent,
|
||||||
|
debugTitle,
|
||||||
|
debugContent,
|
||||||
|
debugConnected,
|
||||||
|
debugConnectedLabel,
|
||||||
|
debugDisconnectedLabel,
|
||||||
|
unsavedLabel,
|
||||||
|
monitoring,
|
||||||
|
}: ProcessorDetailWorkbenchProps) {
|
||||||
|
const [activeView, setActiveView] = useState<'workbench' | 'monitoring'>(
|
||||||
|
'workbench',
|
||||||
|
);
|
||||||
|
const hasDebug = Boolean(debugTitle && debugContent);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 min-w-0 flex-col">
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 pb-4">
|
||||||
|
<h1 className="text-xl font-semibold">{title}</h1>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{monitoring && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={activeView === 'monitoring' ? 'secondary' : 'outline'}
|
||||||
|
onClick={() =>
|
||||||
|
setActiveView((current) =>
|
||||||
|
current === 'monitoring' ? 'workbench' : 'monitoring',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<BarChart3 className="size-4" />
|
||||||
|
{monitoring.label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canSave && activeView === 'workbench' && (
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form={saveFormId}
|
||||||
|
disabled={!isDirty || isSaving}
|
||||||
|
>
|
||||||
|
{saveLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeView === 'monitoring' && monitoring ? (
|
||||||
|
<section className="min-h-0 flex-1 overflow-y-auto rounded-xl border bg-card p-4">
|
||||||
|
{monitoring.content}
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto lg:overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'grid min-h-0 gap-3 lg:h-full',
|
||||||
|
hasDebug
|
||||||
|
? 'lg:grid-cols-[minmax(20rem,0.72fr)_minmax(0,1.28fr)]'
|
||||||
|
: 'grid-cols-1',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{hasDebug && (
|
||||||
|
<section
|
||||||
|
aria-label={debugTitle}
|
||||||
|
className="flex min-h-[32rem] min-w-0 flex-col overflow-hidden rounded-xl border bg-card lg:min-h-0"
|
||||||
|
>
|
||||||
|
<div className="flex h-12 shrink-0 items-center justify-between gap-3 border-b px-4">
|
||||||
|
<div className="flex min-w-0 items-center gap-2 font-medium">
|
||||||
|
<Bug className="size-4 shrink-0" />
|
||||||
|
<span className="truncate">{debugTitle}</span>
|
||||||
|
</div>
|
||||||
|
{debugConnected !== undefined && (
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'size-2 rounded-full',
|
||||||
|
debugConnected ? 'bg-emerald-500' : 'bg-destructive',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{debugConnected
|
||||||
|
? debugConnectedLabel
|
||||||
|
: debugDisconnectedLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">
|
||||||
|
{debugContent}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section
|
||||||
|
aria-label={configTitle}
|
||||||
|
className="flex min-h-[36rem] min-w-0 flex-col overflow-hidden rounded-xl border bg-card lg:min-h-0"
|
||||||
|
>
|
||||||
|
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4 font-medium">
|
||||||
|
<Settings className="size-4" />
|
||||||
|
<span className="truncate">{configTitle}</span>
|
||||||
|
{isDirty && (
|
||||||
|
<span className="ml-auto flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
<span className="size-1.5 rounded-full bg-amber-500" />
|
||||||
|
{unsavedLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 min-w-0 flex-1 overflow-hidden p-4">
|
||||||
|
{configContent}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import PipelineFormComponent from '@/app/home/pipelines/components/pipeline-form/PipelineFormComponent';
|
import PipelineFormComponent from '@/app/home/pipelines/components/pipeline-form/PipelineFormComponent';
|
||||||
import DebugDialog from '@/app/home/pipelines/components/debug-dialog/DebugDialog';
|
import DebugDialog from '@/app/home/pipelines/components/debug-dialog/DebugDialog';
|
||||||
import PipelineMonitoringTab from '@/app/home/pipelines/components/monitoring-tab/PipelineMonitoringTab';
|
import PipelineMonitoringTab from '@/app/home/pipelines/components/monitoring-tab/PipelineMonitoringTab';
|
||||||
|
import ProcessorDetailWorkbench from '@/app/home/components/processor-detail/ProcessorDetailWorkbench';
|
||||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Settings, Bug, BarChart3 } from 'lucide-react';
|
|
||||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||||
|
|
||||||
export default function PipelineDetailContent({
|
export default function PipelineDetailContent({
|
||||||
@@ -40,7 +39,6 @@ export default function PipelineDetailContent({
|
|||||||
return () => setDetailEntityName(null);
|
return () => setDetailEntityName(null);
|
||||||
}, [id, isCreateMode, pipelines, setDetailEntityName, t]);
|
}, [id, isCreateMode, pipelines, setDetailEntityName, t]);
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState('config');
|
|
||||||
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
|
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
|
||||||
const [formDirty, setFormDirty] = useState(false);
|
const [formDirty, setFormDirty] = useState(false);
|
||||||
const [formSaving, setFormSaving] = useState(false);
|
const [formSaving, setFormSaving] = useState(false);
|
||||||
@@ -96,103 +94,62 @@ export default function PipelineDetailContent({
|
|||||||
|
|
||||||
// ==================== Edit Mode ====================
|
// ==================== Edit Mode ====================
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<ProcessorDetailWorkbench
|
||||||
{/* Sticky Header: title + save button */}
|
key={id}
|
||||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
title={t('pipelines.editPipeline')}
|
||||||
<h1 className="text-xl font-semibold">{t('pipelines.editPipeline')}</h1>
|
saveLabel={t('common.save')}
|
||||||
{canManage && (
|
saveFormId="pipeline-form"
|
||||||
<Button
|
canSave={canManage}
|
||||||
type="submit"
|
isDirty={formDirty}
|
||||||
form="pipeline-form"
|
isSaving={formSaving}
|
||||||
disabled={!formDirty || formSaving}
|
configTitle={t('pipelines.configuration')}
|
||||||
className={activeTab !== 'config' ? 'invisible' : ''}
|
configContent={
|
||||||
>
|
<fieldset className="contents" disabled={!canManage}>
|
||||||
{t('common.save')}
|
<PipelineFormComponent
|
||||||
</Button>
|
pipelineId={id}
|
||||||
)}
|
isEditMode={true}
|
||||||
</div>
|
disableForm={!canManage}
|
||||||
|
showButtons={false}
|
||||||
{/* Horizontal Tabs */}
|
onFinish={handleFinish}
|
||||||
<Tabs
|
onNewPipelineCreated={handleNewPipelineCreated}
|
||||||
key={id}
|
onDeletePipeline={handleDeletePipeline}
|
||||||
value={activeTab}
|
onCancel={() => navigate(routeBase)}
|
||||||
onValueChange={setActiveTab}
|
onDirtyChange={setFormDirty}
|
||||||
className="flex flex-1 flex-col min-h-0"
|
onSavingChange={setFormSaving}
|
||||||
>
|
/>
|
||||||
<TabsList className="shrink-0">
|
</fieldset>
|
||||||
<TabsTrigger value="config" className="gap-1.5">
|
}
|
||||||
<Settings className="size-3.5" />
|
debugTitle={canOperate ? t('pipelines.debugChat') : undefined}
|
||||||
{t('pipelines.configuration')}
|
debugConnected={canOperate ? isWebSocketConnected : undefined}
|
||||||
</TabsTrigger>
|
debugConnectedLabel={t('pipelines.debugDialog.connected')}
|
||||||
{canOperate && (
|
debugDisconnectedLabel={t('pipelines.debugDialog.disconnected')}
|
||||||
<TabsTrigger value="debug" className="gap-1.5">
|
debugContent={
|
||||||
<Bug className="size-3.5" />
|
canOperate ? (
|
||||||
{t('pipelines.debugChat')}
|
<DebugDialog
|
||||||
{activeTab === 'debug' && (
|
open={true}
|
||||||
<span
|
pipelineId={id}
|
||||||
className={`inline-block size-2 rounded-full ${
|
isEmbedded={true}
|
||||||
isWebSocketConnected ? 'bg-green-500' : 'bg-red-500'
|
compact={true}
|
||||||
}`}
|
onConnectionStatusChange={setIsWebSocketConnected}
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
unsavedLabel={t('pipelines.unsavedChanges')}
|
||||||
|
monitoring={
|
||||||
|
canViewMonitoring
|
||||||
|
? {
|
||||||
|
label: t('pipelines.monitoring.title'),
|
||||||
|
content: (
|
||||||
|
<PipelineMonitoringTab
|
||||||
|
pipelineId={id}
|
||||||
|
onNavigateToMonitoring={() => {
|
||||||
|
navigate('/home/monitoring');
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
),
|
||||||
</TabsTrigger>
|
}
|
||||||
)}
|
: undefined
|
||||||
{canViewMonitoring && (
|
}
|
||||||
<TabsTrigger value="monitoring" className="gap-1.5">
|
/>
|
||||||
<BarChart3 className="size-3.5" />
|
|
||||||
{t('pipelines.monitoring.title')}
|
|
||||||
</TabsTrigger>
|
|
||||||
)}
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{/* Tab: Configuration */}
|
|
||||||
<TabsContent
|
|
||||||
value="config"
|
|
||||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
|
||||||
>
|
|
||||||
<fieldset className="contents" disabled={!canManage}>
|
|
||||||
<PipelineFormComponent
|
|
||||||
pipelineId={id}
|
|
||||||
isEditMode={true}
|
|
||||||
disableForm={!canManage}
|
|
||||||
showButtons={false}
|
|
||||||
onFinish={handleFinish}
|
|
||||||
onNewPipelineCreated={handleNewPipelineCreated}
|
|
||||||
onDeletePipeline={handleDeletePipeline}
|
|
||||||
onCancel={() => navigate(routeBase)}
|
|
||||||
onDirtyChange={setFormDirty}
|
|
||||||
onSavingChange={setFormSaving}
|
|
||||||
/>
|
|
||||||
</fieldset>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Tab: Debug */}
|
|
||||||
{canOperate && (
|
|
||||||
<TabsContent value="debug" className="flex-1 min-h-0 mt-4">
|
|
||||||
<DebugDialog
|
|
||||||
open={activeTab === 'debug'}
|
|
||||||
pipelineId={id}
|
|
||||||
isEmbedded={true}
|
|
||||||
onConnectionStatusChange={setIsWebSocketConnected}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tab: Monitoring */}
|
|
||||||
{canViewMonitoring && (
|
|
||||||
<TabsContent
|
|
||||||
value="monitoring"
|
|
||||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
|
||||||
>
|
|
||||||
<PipelineMonitoringTab
|
|
||||||
pipelineId={id}
|
|
||||||
onNavigateToMonitoring={() => {
|
|
||||||
navigate('/home/monitoring');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
)}
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ interface DebugDialogProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
pipelineId: string;
|
pipelineId: string;
|
||||||
isEmbedded?: boolean;
|
isEmbedded?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
onConnectionStatusChange?: (isConnected: boolean) => void;
|
onConnectionStatusChange?: (isConnected: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +116,7 @@ export default function DebugDialog({
|
|||||||
open,
|
open,
|
||||||
pipelineId,
|
pipelineId,
|
||||||
isEmbedded = false,
|
isEmbedded = false,
|
||||||
|
compact = false,
|
||||||
onConnectionStatusChange,
|
onConnectionStatusChange,
|
||||||
}: DebugDialogProps) {
|
}: DebugDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -805,7 +807,12 @@ export default function DebugDialog({
|
|||||||
|
|
||||||
const renderContent = () => (
|
const renderContent = () => (
|
||||||
<div className="flex flex-1 h-full min-h-0">
|
<div className="flex flex-1 h-full min-h-0">
|
||||||
<div className="w-14 p-2 pl-0 shrink-0 flex flex-col justify-start gap-2">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-14 p-2 pl-0 shrink-0 flex flex-col justify-start gap-2',
|
||||||
|
compact && 'w-12 p-1.5 pl-1',
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -835,8 +842,13 @@ export default function DebugDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col w-[10rem] h-full min-h-0">
|
<div className="flex-1 flex flex-col w-[10rem] h-full min-h-0">
|
||||||
<ScrollArea className="flex-1 p-6 overflow-y-auto min-h-0 scroll-area">
|
<ScrollArea
|
||||||
<div className="space-y-6">
|
className={cn(
|
||||||
|
'flex-1 overflow-y-auto min-h-0 scroll-area',
|
||||||
|
compact ? 'p-3' : 'p-6',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={compact ? 'space-y-3' : 'space-y-6'}>
|
||||||
{messages.length === 0 ? (
|
{messages.length === 0 ? (
|
||||||
<div className="text-center text-muted-foreground py-12 text-lg">
|
<div className="text-center text-muted-foreground py-12 text-lg">
|
||||||
{t('pipelines.debugDialog.noMessages')}
|
{t('pipelines.debugDialog.noMessages')}
|
||||||
@@ -852,7 +864,10 @@ export default function DebugDialog({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'max-w-3xl px-5 py-3 rounded-2xl',
|
'rounded-2xl',
|
||||||
|
compact
|
||||||
|
? 'max-w-[92%] px-3 py-2 text-sm'
|
||||||
|
: 'max-w-3xl px-5 py-3',
|
||||||
message.role === 'user'
|
message.role === 'user'
|
||||||
? 'user-message-bubble bg-primary/10 text-foreground rounded-br-none'
|
? 'user-message-bubble bg-primary/10 text-foreground rounded-br-none'
|
||||||
: 'bg-muted text-foreground rounded-bl-none',
|
: 'bg-muted text-foreground rounded-bl-none',
|
||||||
@@ -990,7 +1005,9 @@ export default function DebugDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="p-4 pb-0 flex gap-2">
|
<div
|
||||||
|
className={cn('p-4 pb-0 flex gap-2', compact && 'flex-col p-3 pb-0')}
|
||||||
|
>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
@@ -1072,7 +1089,10 @@ export default function DebugDialog({
|
|||||||
!isConnected ||
|
!isConnected ||
|
||||||
isUploading
|
isUploading
|
||||||
}
|
}
|
||||||
className="rounded-md w-20 px-6 py-2 text-base font-medium transition-none flex items-center gap-2 shadow-none disabled:opacity-50"
|
className={cn(
|
||||||
|
'rounded-md w-20 px-6 py-2 text-base font-medium transition-none flex items-center gap-2 shadow-none disabled:opacity-50',
|
||||||
|
compact && 'w-auto px-3 text-sm',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{isUploading ? (
|
{isUploading ? (
|
||||||
t('pipelines.debugDialog.uploading')
|
t('pipelines.debugDialog.uploading')
|
||||||
|
|||||||
@@ -156,7 +156,16 @@ export default function PipelineFormComponent({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const [activeSection, setActiveSection] = useState(formLabelList[0].name);
|
const [activeSection, setActiveSection] = useState(
|
||||||
|
isEditMode ? 'trigger' : 'basic',
|
||||||
|
);
|
||||||
|
const primarySectionNames = ['trigger', 'ai', 'output'];
|
||||||
|
const primarySections = primarySectionNames
|
||||||
|
.map((name) => formLabelList.find((section) => section.name === name))
|
||||||
|
.filter((section): section is SectionItem => Boolean(section));
|
||||||
|
const secondarySections = formLabelList.filter(
|
||||||
|
(section) => !primarySectionNames.includes(section.name),
|
||||||
|
);
|
||||||
|
|
||||||
const [aiConfigTabSchema, setAIConfigTabSchema] =
|
const [aiConfigTabSchema, setAIConfigTabSchema] =
|
||||||
useState<PipelineConfigTab>();
|
useState<PipelineConfigTab>();
|
||||||
@@ -567,12 +576,48 @@ export default function PipelineFormComponent({
|
|||||||
onSubmit={form.handleSubmit(handleFormSubmit)}
|
onSubmit={form.handleSubmit(handleFormSubmit)}
|
||||||
className="h-full flex flex-col flex-1 min-h-0 mb-2"
|
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">
|
<div className="flex-1 flex min-h-0 flex-col">
|
||||||
{/* Vertical section navigation (only show when multiple sections) */}
|
{/* Keep the primary pipeline flow visible while editing. */}
|
||||||
{formLabelList.length > 1 && (
|
{formLabelList.length > 1 && (
|
||||||
<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">
|
<nav className="mb-4 shrink-0 space-y-2 border-b pb-4">
|
||||||
<ul className="flex md:flex-col gap-1 md:space-y-1">
|
<div className="overflow-x-auto">
|
||||||
{formLabelList.map((section) => {
|
<ol className="grid min-w-[34rem] grid-cols-3 gap-2">
|
||||||
|
{primarySections.map((section, index) => {
|
||||||
|
const Icon = section.icon;
|
||||||
|
return (
|
||||||
|
<li key={section.name} className="relative min-w-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveSection(section.name)}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full min-w-0 items-center gap-2 rounded-lg border px-3 py-3 text-left text-sm font-medium transition-colors',
|
||||||
|
activeSection === section.name
|
||||||
|
? 'border-primary/50 bg-primary/5 text-foreground shadow-sm'
|
||||||
|
: 'border-border bg-background text-muted-foreground hover:bg-muted hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex size-7 shrink-0 items-center justify-center rounded-full text-xs',
|
||||||
|
activeSection === section.name
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-muted text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<Icon className="hidden size-4 shrink-0 xl:block" />
|
||||||
|
<span className="min-w-0 leading-tight">
|
||||||
|
{section.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<ul className="flex flex-wrap gap-1">
|
||||||
|
{secondarySections.map((section) => {
|
||||||
const Icon = section.icon;
|
const Icon = section.icon;
|
||||||
return (
|
return (
|
||||||
<li key={section.name}>
|
<li key={section.name}>
|
||||||
@@ -580,13 +625,13 @@ export default function PipelineFormComponent({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActiveSection(section.name)}
|
onClick={() => setActiveSection(section.name)}
|
||||||
className={cn(
|
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',
|
'flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors',
|
||||||
activeSection === section.name
|
activeSection === section.name
|
||||||
? 'bg-accent text-accent-foreground'
|
? 'bg-accent text-accent-foreground'
|
||||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
|
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="size-4 shrink-0" />
|
<Icon className="size-3.5" />
|
||||||
{section.label}
|
{section.label}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -16,12 +16,7 @@ export interface WebSocketMessage {
|
|||||||
|
|
||||||
export interface WebSocketResponse {
|
export interface WebSocketResponse {
|
||||||
type:
|
type:
|
||||||
| 'connected'
|
'connected' | 'response' | 'user_message' | 'pong' | 'broadcast' | 'error';
|
||||||
| 'response'
|
|
||||||
| 'user_message'
|
|
||||||
| 'pong'
|
|
||||||
| 'broadcast'
|
|
||||||
| 'error';
|
|
||||||
connection_id?: string;
|
connection_id?: string;
|
||||||
pipeline_uuid?: string;
|
pipeline_uuid?: string;
|
||||||
session_type?: string;
|
session_type?: string;
|
||||||
@@ -91,7 +86,6 @@ export class WebSocketClient {
|
|||||||
|
|
||||||
// 连接打开
|
// 连接打开
|
||||||
this.ws.onopen = () => {
|
this.ws.onopen = () => {
|
||||||
this.reconnectAttempts = 0;
|
|
||||||
this.isConnecting = false;
|
this.isConnecting = false;
|
||||||
const token = this.token || localStorage.getItem('token');
|
const token = this.token || localStorage.getItem('token');
|
||||||
const workspaceUuid = getActiveWorkspaceUuid();
|
const workspaceUuid = getActiveWorkspaceUuid();
|
||||||
@@ -119,6 +113,10 @@ export class WebSocketClient {
|
|||||||
|
|
||||||
// 第一次连接成功
|
// 第一次连接成功
|
||||||
if (data.type === 'connected' && data.connection_id) {
|
if (data.type === 'connected' && data.connection_id) {
|
||||||
|
// Only a fully authenticated runtime connection should reset
|
||||||
|
// the retry budget. Resetting on TCP open makes server-side
|
||||||
|
// errors (for example a pipeline still loading) retry forever.
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
this.connectionId = data.connection_id;
|
this.connectionId = data.connection_id;
|
||||||
this.startHeartbeat();
|
this.startHeartbeat();
|
||||||
resolve(data.connection_id);
|
resolve(data.connection_id);
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||||
|
|
||||||
|
test.describe('processor detail workbench', () => {
|
||||||
|
test.use({ viewport: { width: 1440, height: 900 } });
|
||||||
|
|
||||||
|
test('agent keeps debugging left of its orchestration settings', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await installLangBotApiMocks(page, {
|
||||||
|
authenticated: true,
|
||||||
|
withRunnerToolSelector: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/home/agents?id=agent-workbench');
|
||||||
|
|
||||||
|
const debugPanel = page.getByRole('region', { name: 'Debug' });
|
||||||
|
const configPanel = page.getByRole('region', { name: 'Configuration' });
|
||||||
|
await expect(debugPanel).toBeVisible();
|
||||||
|
await expect(configPanel).toBeVisible();
|
||||||
|
|
||||||
|
const debugBox = await debugPanel.boundingBox();
|
||||||
|
const configBox = await configPanel.boundingBox();
|
||||||
|
expect(debugBox).not.toBeNull();
|
||||||
|
expect(configBox).not.toBeNull();
|
||||||
|
expect(debugBox!.x).toBeLessThan(configBox!.x);
|
||||||
|
expect(configBox!.width).toBeGreaterThan(debugBox!.width);
|
||||||
|
|
||||||
|
const flow = configPanel.locator('ol');
|
||||||
|
await expect(flow.getByRole('button').nth(0)).toContainText(
|
||||||
|
'Bindable Event Range',
|
||||||
|
);
|
||||||
|
await expect(flow.getByRole('button').nth(1)).toContainText('Runner');
|
||||||
|
await expect(flow.getByRole('button').nth(2)).toContainText('Local Agent');
|
||||||
|
|
||||||
|
await flow.getByRole('button').nth(0).click();
|
||||||
|
await expect(
|
||||||
|
configPanel.getByText('Bindable Event Range', { exact: true }).last(),
|
||||||
|
).toBeVisible();
|
||||||
|
await flow.getByRole('button').nth(2).click();
|
||||||
|
await expect(
|
||||||
|
configPanel.getByText('Local Agent', { exact: true }).last(),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pipeline keeps debug chat left and exposes its main flow first', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await installLangBotApiMocks(page, { authenticated: true });
|
||||||
|
|
||||||
|
await page.goto('/home/pipelines?id=pipeline-workbench');
|
||||||
|
|
||||||
|
const debugPanel = page.getByRole('region', { name: 'Debug Chat' });
|
||||||
|
const configPanel = page.getByRole('region', { name: 'Configuration' });
|
||||||
|
await expect(debugPanel).toBeVisible();
|
||||||
|
await expect(configPanel).toBeVisible();
|
||||||
|
|
||||||
|
const debugBox = await debugPanel.boundingBox();
|
||||||
|
const configBox = await configPanel.boundingBox();
|
||||||
|
expect(debugBox).not.toBeNull();
|
||||||
|
expect(configBox).not.toBeNull();
|
||||||
|
expect(debugBox!.x).toBeLessThan(configBox!.x);
|
||||||
|
expect(configBox!.width).toBeGreaterThan(debugBox!.width);
|
||||||
|
|
||||||
|
const flow = configPanel.locator('ol');
|
||||||
|
await expect(flow.getByRole('button').nth(0)).toContainText(
|
||||||
|
'Trigger Conditions',
|
||||||
|
);
|
||||||
|
await expect(flow.getByRole('button').nth(1)).toContainText(
|
||||||
|
'AI Capabilities',
|
||||||
|
);
|
||||||
|
await expect(flow.getByRole('button').nth(2)).toContainText(
|
||||||
|
'Output Processing',
|
||||||
|
);
|
||||||
|
|
||||||
|
await flow.getByRole('button').nth(1).click();
|
||||||
|
await expect(
|
||||||
|
configPanel.getByText('Runtime', { exact: true }).last(),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const testDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const webRoot = path.resolve(testDir, '../..');
|
||||||
|
|
||||||
|
function readSource(relativePath) {
|
||||||
|
return fs.readFileSync(path.join(webRoot, relativePath), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('agent and pipeline details share the split processor workbench', () => {
|
||||||
|
const workbench = readSource(
|
||||||
|
'src/app/home/components/processor-detail/ProcessorDetailWorkbench.tsx',
|
||||||
|
);
|
||||||
|
const agentDetail = readSource('src/app/home/agents/AgentDetailContent.tsx');
|
||||||
|
const pipelineDetail = readSource(
|
||||||
|
'src/app/home/pipelines/PipelineDetailContent.tsx',
|
||||||
|
);
|
||||||
|
const websocketClient = readSource(
|
||||||
|
'src/app/infra/websocket/WebSocketClient.ts',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(
|
||||||
|
workbench,
|
||||||
|
/lg:grid-cols-\[minmax\(20rem,0\.72fr\)_minmax\(0,1\.28fr\)\]/,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
workbench.indexOf('{debugContent}') < workbench.indexOf('{configContent}'),
|
||||||
|
);
|
||||||
|
assert.match(agentDetail, /<ProcessorDetailWorkbench/);
|
||||||
|
assert.match(agentDetail, /debugContent=/);
|
||||||
|
assert.match(pipelineDetail, /<ProcessorDetailWorkbench/);
|
||||||
|
assert.match(pipelineDetail, /compact=\{true\}/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
websocketClient,
|
||||||
|
/this\.ws\.onopen = \(\) => \{\s*this\.reconnectAttempts = 0/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
websocketClient,
|
||||||
|
/data\.type === 'connected'[\s\S]*this\.reconnectAttempts = 0/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('processor forms expose their primary orchestration flow horizontally', () => {
|
||||||
|
const agentForm = readSource(
|
||||||
|
'src/app/home/agents/components/AgentFormComponent.tsx',
|
||||||
|
);
|
||||||
|
const pipelineForm = readSource(
|
||||||
|
'src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(
|
||||||
|
agentForm,
|
||||||
|
/name: 'events'[\s\S]*name: 'runner'[\s\S]*name: 'runner_config'/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
pipelineForm,
|
||||||
|
/const primarySectionNames = \['trigger', 'ai', 'output'\]/,
|
||||||
|
);
|
||||||
|
assert.match(agentForm, /grid min-w-\[34rem\] grid-cols-3/);
|
||||||
|
assert.match(pipelineForm, /grid min-w-\[34rem\] grid-cols-3/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user