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 { useNavigate } from 'react-router-dom';
|
||||
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 { useCurrentWorkspace } from '@/app/infra/http';
|
||||
import { Agent } from '@/app/infra/entities/api';
|
||||
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 AgentCreateContent from './components/AgentCreateContent';
|
||||
import AgentDebugPanel from './components/AgentDebugPanel';
|
||||
@@ -18,6 +16,8 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const canManage =
|
||||
currentWorkspace?.permissions.includes('resource.manage') ?? false;
|
||||
const canOperate =
|
||||
currentWorkspace?.permissions.includes('runtime.operate') ?? false;
|
||||
const { refreshPipelines, pipelines, setDetailEntityName } = useSidebarData();
|
||||
@@ -25,7 +25,6 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
||||
const [loading, setLoading] = useState(!isCreateMode);
|
||||
const [formDirty, setFormDirty] = useState(false);
|
||||
const [formSaving, setFormSaving] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('config');
|
||||
|
||||
useEffect(() => {
|
||||
if (isCreateMode) {
|
||||
@@ -79,42 +78,17 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-w-0 flex-col">
|
||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||
<h1 className="text-xl font-semibold">{t('agents.editAgent')}</h1>
|
||||
<Button
|
||||
type="submit"
|
||||
form="agent-form"
|
||||
disabled={!formDirty || formSaving}
|
||||
className={activeTab !== 'config' ? 'invisible' : ''}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</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"
|
||||
>
|
||||
<ProcessorDetailWorkbench
|
||||
key={id}
|
||||
title={t('agents.editAgent')}
|
||||
saveLabel={t('common.save')}
|
||||
saveFormId="agent-form"
|
||||
canSave={canManage}
|
||||
isDirty={formDirty}
|
||||
isSaving={formSaving}
|
||||
configTitle={t('pipelines.configuration')}
|
||||
configContent={
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<AgentFormComponent
|
||||
agentId={id}
|
||||
onFinish={() => {
|
||||
@@ -127,23 +101,21 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
||||
onDirtyChange={setFormDirty}
|
||||
onSavingChange={setFormSaving}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{canOperate && (
|
||||
<TabsContent
|
||||
value="debug"
|
||||
className="mt-4 min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden"
|
||||
>
|
||||
<AgentDebugPanel
|
||||
agentId={id}
|
||||
supportedEventPatterns={
|
||||
agent.supported_event_patterns ??
|
||||
agent.capability?.supported_event_patterns ?? ['*']
|
||||
}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
</fieldset>
|
||||
}
|
||||
debugTitle={canOperate ? t('agents.debugTab') : undefined}
|
||||
debugContent={
|
||||
canOperate ? (
|
||||
<AgentDebugPanel
|
||||
agentId={id}
|
||||
supportedEventPatterns={
|
||||
agent.supported_event_patterns ??
|
||||
agent.capability?.supported_event_patterns ?? ['*']
|
||||
}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
unsavedLabel={t('pipelines.unsavedChanges')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,11 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Braces,
|
||||
LoaderCircle,
|
||||
MessageSquare,
|
||||
Play,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import { AlertTriangle, LoaderCircle, Play, RotateCcw } from 'lucide-react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
@@ -205,162 +191,149 @@ export default function AgentDebugPanel({
|
||||
}
|
||||
|
||||
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)]">
|
||||
<Card className="min-w-0">
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{isMessageEvent ? (
|
||||
<MessageSquare className="size-5" />
|
||||
) : (
|
||||
<Braces className="size-5" />
|
||||
)}
|
||||
{t('agents.debugTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription>{t('agents.debugDescription')}</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={resetSession}
|
||||
>
|
||||
<RotateCcw className="size-4" />
|
||||
{t('agents.debugResetSession')}
|
||||
</Button>
|
||||
<div className="flex h-full min-h-0 min-w-0 flex-col">
|
||||
<div className="shrink-0 space-y-3 border-b p-3">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<Label>{t('agents.debugEventType')}</Label>
|
||||
<Select value={preset} onValueChange={selectPreset}>
|
||||
<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>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<Alert>
|
||||
<AlertTriangle />
|
||||
<AlertTitle>{t('agents.debugActualRun')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('agents.debugActualRunDescription')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={resetSession}
|
||||
title={t('agents.debugResetSession')}
|
||||
>
|
||||
<RotateCcw className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('agents.debugEventType')}</Label>
|
||||
<Select value={preset} onValueChange={selectPreset}>
|
||||
<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')}
|
||||
{preset === 'custom' && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="agent-debug-custom-event">
|
||||
{t('agents.debugCustomEventType')}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="agent-debug-input"
|
||||
value={inputText}
|
||||
onChange={(event) => setInputText(event.target.value)}
|
||||
className="min-h-24 resize-y"
|
||||
placeholder={t('agents.debugInputPlaceholder')}
|
||||
<Input
|
||||
id="agent-debug-custom-event"
|
||||
value={customEventType}
|
||||
onChange={(event) => setCustomEventType(event.target.value)}
|
||||
placeholder="custom.event"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Label htmlFor="agent-debug-payload">
|
||||
{t('agents.debugEventPayload')}
|
||||
</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('agents.debugSupportedEvents')}: {supportedLabel}
|
||||
</span>
|
||||
</div>
|
||||
<Alert className="py-2">
|
||||
<AlertTriangle />
|
||||
<AlertTitle>{t('agents.debugActualRun')}</AlertTitle>
|
||||
<AlertDescription className="text-xs">
|
||||
{t('agents.debugActualRunDescription')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</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
|
||||
id="agent-debug-payload"
|
||||
value={eventDataText}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" disabled={running} onClick={runDebugEvent}>
|
||||
{running ? (
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="size-4" />
|
||||
)}
|
||||
{running ? t('agents.debugRunning') : t('agents.debugRun')}
|
||||
</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>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
disabled={running}
|
||||
onClick={runDebugEvent}
|
||||
>
|
||||
{running ? (
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
) : (
|
||||
<div className="max-h-[42rem] space-y-4 overflow-y-auto pr-1">
|
||||
{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>
|
||||
<Play className="size-4" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{running ? t('agents.debugRunning') : t('agents.debugRun')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,13 +6,17 @@ import { z } from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Bot,
|
||||
CircleAlert,
|
||||
CircleCheck,
|
||||
Info,
|
||||
LoaderCircle,
|
||||
Power,
|
||||
RefreshCw,
|
||||
SlidersHorizontal,
|
||||
Trash2,
|
||||
Unplug,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { Agent, ApiRespPluginSystemStatus } from '@/app/infra/entities/api';
|
||||
@@ -60,6 +64,8 @@ interface AgentFormComponentProps {
|
||||
onSavingChange?: (saving: boolean) => void;
|
||||
}
|
||||
|
||||
type AgentConfigSection = 'events' | 'runner' | 'runner_config' | 'basic';
|
||||
|
||||
export default function AgentFormComponent({
|
||||
agentId,
|
||||
onFinish,
|
||||
@@ -76,6 +82,8 @@ export default function AgentFormComponent({
|
||||
const [pluginStatusError, setPluginStatusError] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [activeSection, setActiveSection] =
|
||||
useState<AgentConfigSection>('runner');
|
||||
const isSavingRef = useRef(false);
|
||||
|
||||
const formSchema = z.object({
|
||||
@@ -182,6 +190,35 @@ export default function AgentFormComponent({
|
||||
const selectedRunnerOption = runnerOptions.find(
|
||||
(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) {
|
||||
return (
|
||||
@@ -433,63 +470,147 @@ export default function AgentFormComponent({
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
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">
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden">
|
||||
<div className="mx-auto flex w-full min-w-0 max-w-5xl flex-col gap-6 pb-8">
|
||||
{
|
||||
<div className="contents">
|
||||
<Card className="order-2">
|
||||
<nav className="mb-4 shrink-0 space-y-2 border-b pb-4">
|
||||
<div className="overflow-x-auto">
|
||||
<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="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>
|
||||
<CardTitle>{t('agents.basicInfo')}</CardTitle>
|
||||
<CardTitle>{t('agents.runnerSettings')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('agents.basicInfoDescription')}
|
||||
{t('agents.noRunnerMetadata')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex gap-4 items-start">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="basic.name"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
{t('common.name')}
|
||||
<span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="basic.emoji"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('common.icon')}</FormLabel>
|
||||
<FormControl>
|
||||
<EmojiPicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'events' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('agents.bindableEvents')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('agents.bindableEventsDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="supported_event_patterns_text"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('agents.supportedEvents')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
className="min-h-32 font-mono text-sm"
|
||||
placeholder={'*\nmessage.received\ngroup.*'}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('agents.supportedEventsDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{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
|
||||
control={form.control}
|
||||
name="basic.description"
|
||||
name="basic.name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('common.description')}</FormLabel>
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
{t('common.name')}
|
||||
<span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
@@ -497,122 +618,98 @@ export default function AgentFormComponent({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="basic.enabled"
|
||||
name="basic.emoji"
|
||||
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>
|
||||
<FormItem>
|
||||
<FormLabel>{t('common.icon')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value ?? true}
|
||||
onCheckedChange={field.onChange}
|
||||
<EmojiPicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<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
|
||||
control={form.control}
|
||||
name="supported_event_patterns_text"
|
||||
name="basic.description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('agents.supportedEvents')}
|
||||
</FormLabel>
|
||||
<FormLabel>{t('common.description')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
className="min-h-32 font-mono text-sm"
|
||||
placeholder={'*\nmessage.received\ngroup.*'}
|
||||
<Input {...field} value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="basic.enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
<Power className="size-4" />
|
||||
{t('agents.enabled')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t('agents.enabledDescription')}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value ?? true}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('agents.supportedEventsDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</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>
|
||||
</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 { useNavigate } from 'react-router-dom';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import PipelineFormComponent from '@/app/home/pipelines/components/pipeline-form/PipelineFormComponent';
|
||||
import DebugDialog from '@/app/home/pipelines/components/debug-dialog/DebugDialog';
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
import { Settings, Bug, BarChart3 } from 'lucide-react';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
|
||||
export default function PipelineDetailContent({
|
||||
@@ -40,7 +39,6 @@ export default function PipelineDetailContent({
|
||||
return () => setDetailEntityName(null);
|
||||
}, [id, isCreateMode, pipelines, setDetailEntityName, t]);
|
||||
|
||||
const [activeTab, setActiveTab] = useState('config');
|
||||
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
|
||||
const [formDirty, setFormDirty] = useState(false);
|
||||
const [formSaving, setFormSaving] = useState(false);
|
||||
@@ -96,103 +94,62 @@ export default function PipelineDetailContent({
|
||||
|
||||
// ==================== Edit Mode ====================
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Sticky Header: title + save button */}
|
||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||
<h1 className="text-xl font-semibold">{t('pipelines.editPipeline')}</h1>
|
||||
{canManage && (
|
||||
<Button
|
||||
type="submit"
|
||||
form="pipeline-form"
|
||||
disabled={!formDirty || formSaving}
|
||||
className={activeTab !== 'config' ? 'invisible' : ''}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Horizontal Tabs */}
|
||||
<Tabs
|
||||
key={id}
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex flex-1 flex-col min-h-0"
|
||||
>
|
||||
<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('pipelines.debugChat')}
|
||||
{activeTab === 'debug' && (
|
||||
<span
|
||||
className={`inline-block size-2 rounded-full ${
|
||||
isWebSocketConnected ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}
|
||||
<ProcessorDetailWorkbench
|
||||
key={id}
|
||||
title={t('pipelines.editPipeline')}
|
||||
saveLabel={t('common.save')}
|
||||
saveFormId="pipeline-form"
|
||||
canSave={canManage}
|
||||
isDirty={formDirty}
|
||||
isSaving={formSaving}
|
||||
configTitle={t('pipelines.configuration')}
|
||||
configContent={
|
||||
<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>
|
||||
}
|
||||
debugTitle={canOperate ? t('pipelines.debugChat') : undefined}
|
||||
debugConnected={canOperate ? isWebSocketConnected : undefined}
|
||||
debugConnectedLabel={t('pipelines.debugDialog.connected')}
|
||||
debugDisconnectedLabel={t('pipelines.debugDialog.disconnected')}
|
||||
debugContent={
|
||||
canOperate ? (
|
||||
<DebugDialog
|
||||
open={true}
|
||||
pipelineId={id}
|
||||
isEmbedded={true}
|
||||
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>
|
||||
)}
|
||||
{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>
|
||||
),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ interface DebugDialogProps {
|
||||
open: boolean;
|
||||
pipelineId: string;
|
||||
isEmbedded?: boolean;
|
||||
compact?: boolean;
|
||||
onConnectionStatusChange?: (isConnected: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -115,6 +116,7 @@ export default function DebugDialog({
|
||||
open,
|
||||
pipelineId,
|
||||
isEmbedded = false,
|
||||
compact = false,
|
||||
onConnectionStatusChange,
|
||||
}: DebugDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -805,7 +807,12 @@ export default function DebugDialog({
|
||||
|
||||
const renderContent = () => (
|
||||
<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
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -835,8 +842,13 @@ export default function DebugDialog({
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<div className="space-y-6">
|
||||
<ScrollArea
|
||||
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 ? (
|
||||
<div className="text-center text-muted-foreground py-12 text-lg">
|
||||
{t('pipelines.debugDialog.noMessages')}
|
||||
@@ -852,7 +864,10 @@ export default function DebugDialog({
|
||||
>
|
||||
<div
|
||||
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'
|
||||
? 'user-message-bubble bg-primary/10 text-foreground rounded-br-none'
|
||||
: 'bg-muted text-foreground rounded-bl-none',
|
||||
@@ -990,7 +1005,9 @@ export default function DebugDialog({
|
||||
</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 items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@@ -1072,7 +1089,10 @@ export default function DebugDialog({
|
||||
!isConnected ||
|
||||
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 ? (
|
||||
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] =
|
||||
useState<PipelineConfigTab>();
|
||||
@@ -567,12 +576,48 @@ export default function PipelineFormComponent({
|
||||
onSubmit={form.handleSubmit(handleFormSubmit)}
|
||||
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">
|
||||
{/* Vertical section navigation (only show when multiple sections) */}
|
||||
<div className="flex-1 flex min-h-0 flex-col">
|
||||
{/* Keep the primary pipeline flow visible while editing. */}
|
||||
{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">
|
||||
<ul className="flex md:flex-col gap-1 md:space-y-1">
|
||||
{formLabelList.map((section) => {
|
||||
<nav className="mb-4 shrink-0 space-y-2 border-b pb-4">
|
||||
<div className="overflow-x-auto">
|
||||
<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;
|
||||
return (
|
||||
<li key={section.name}>
|
||||
@@ -580,13 +625,13 @@ export default function PipelineFormComponent({
|
||||
type="button"
|
||||
onClick={() => setActiveSection(section.name)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors text-left cursor-pointer whitespace-nowrap',
|
||||
'flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors',
|
||||
activeSection === section.name
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<Icon className="size-3.5" />
|
||||
{section.label}
|
||||
</button>
|
||||
</li>
|
||||
|
||||
@@ -16,12 +16,7 @@ export interface WebSocketMessage {
|
||||
|
||||
export interface WebSocketResponse {
|
||||
type:
|
||||
| 'connected'
|
||||
| 'response'
|
||||
| 'user_message'
|
||||
| 'pong'
|
||||
| 'broadcast'
|
||||
| 'error';
|
||||
'connected' | 'response' | 'user_message' | 'pong' | 'broadcast' | 'error';
|
||||
connection_id?: string;
|
||||
pipeline_uuid?: string;
|
||||
session_type?: string;
|
||||
@@ -91,7 +86,6 @@ export class WebSocketClient {
|
||||
|
||||
// 连接打开
|
||||
this.ws.onopen = () => {
|
||||
this.reconnectAttempts = 0;
|
||||
this.isConnecting = false;
|
||||
const token = this.token || localStorage.getItem('token');
|
||||
const workspaceUuid = getActiveWorkspaceUuid();
|
||||
@@ -119,6 +113,10 @@ export class WebSocketClient {
|
||||
|
||||
// 第一次连接成功
|
||||
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.startHeartbeat();
|
||||
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