feat(web): unify processor detail workbench

This commit is contained in:
RockChinQ
2026-08-25 11:54:59 +08:00
parent 49d0aac210
commit 46aea2b499
10 changed files with 835 additions and 477 deletions
+30 -58
View File
@@ -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/);
});