fix(web): make processor debugging reliable

This commit is contained in:
RockChinQ
2026-08-25 15:52:59 +08:00
parent 781d8a9ac8
commit 6a6a2b865b
24 changed files with 788 additions and 160 deletions
+16 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { httpClient } from '@/app/infra/http/HttpClient';
@@ -10,6 +10,7 @@ import PipelineDetailContent from '@/app/home/pipelines/PipelineDetailContent';
import AgentCreateContent from './components/AgentCreateContent';
import AgentDebugPanel from './components/AgentDebugPanel';
import AgentFormComponent, {
AgentFormHandle,
AgentRunnerStatus,
} from './components/AgentFormComponent';
@@ -30,6 +31,7 @@ export default function AgentDetailContent({ id }: { id: string }) {
const [runnerStatus, setRunnerStatus] = useState<AgentRunnerStatus | null>(
null,
);
const agentFormRef = useRef<AgentFormHandle>(null);
useEffect(() => {
if (isCreateMode) {
@@ -89,7 +91,7 @@ export default function AgentDetailContent({ id }: { id: string }) {
return (
<ProcessorDetailWorkbench
key={id}
title={t('agents.editAgent')}
title={`${agent.emoji || '🤖'} ${agent.name}`}
status={runnerStatus}
saveLabel={t('common.save')}
saveFormId="agent-form"
@@ -100,8 +102,14 @@ export default function AgentDetailContent({ id }: { id: string }) {
configContent={
<fieldset className="contents" disabled={!canManage}>
<AgentFormComponent
ref={agentFormRef}
agentId={id}
onFinish={() => {
onFinish={(updatedAgent) => {
if (updatedAgent) {
setAgent((current) =>
current ? { ...current, ...updatedAgent } : current,
);
}
refreshPipelines();
}}
onDeleted={() => {
@@ -119,6 +127,11 @@ export default function AgentDetailContent({ id }: { id: string }) {
canOperate ? (
<AgentDebugPanel
agentId={id}
hasUnsavedChanges={formDirty}
beforeRun={async () => agentFormRef.current?.save() ?? false}
onOpenRunnerConfig={() =>
agentFormRef.current?.openSection('runner_config')
}
supportedEventPatterns={
agent.supported_event_patterns ??
agent.capability?.supported_event_patterns ?? ['*']
@@ -51,9 +51,12 @@ export default function AgentCreateContent({
});
function handleKindChange(nextKind: AgentKind) {
const previousDefaultEmoji = kind === 'pipeline' ? '⚙️' : '🤖';
const nextDefaultEmoji = nextKind === 'pipeline' ? '⚙️' : '🤖';
setKind(nextKind);
if (!form.getValues('emoji')) {
form.setValue('emoji', nextKind === 'pipeline' ? '⚙️' : '🤖');
const currentEmoji = form.getValues('emoji');
if (!currentEmoji || currentEmoji === previousDefaultEmoji) {
form.setValue('emoji', nextDefaultEmoji);
}
}
@@ -1,7 +1,14 @@
import { useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { LoaderCircle, Play, RotateCcw } from 'lucide-react';
import {
AlertCircle,
ChevronDown,
CircleHelp,
LoaderCircle,
Play,
RotateCcw,
} from 'lucide-react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -15,10 +22,19 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
interface AgentDebugPanelProps {
agentId: string;
supportedEventPatterns?: string[];
beforeRun?: () => Promise<boolean>;
hasUnsavedChanges?: boolean;
onOpenRunnerConfig?: () => void;
}
interface DebugEntry {
@@ -26,6 +42,8 @@ interface DebugEntry {
direction: 'input' | 'output' | 'error';
eventType: string;
text: string;
errorCode?: string;
detail?: string;
}
const EVENT_PRESETS = [
@@ -87,9 +105,17 @@ function createDebugSessionId(agentId: string) {
return `webui:${agentId}:${nonce}`;
}
function matchesEventPattern(pattern: string, eventType: string) {
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`^${escaped.replaceAll('*', '.*')}$`).test(eventType);
}
export default function AgentDebugPanel({
agentId,
supportedEventPatterns = ['*'],
beforeRun,
hasUnsavedChanges = false,
onOpenRunnerConfig,
}: AgentDebugPanelProps) {
const { t } = useTranslation();
const [preset, setPreset] = useState('message.received');
@@ -106,6 +132,22 @@ export default function AgentDebugPanel({
() => supportedEventPatterns.join(', '),
[supportedEventPatterns],
);
const availablePresets = useMemo(
() =>
EVENT_PRESETS.filter(
(item) =>
item.value === 'custom' ||
supportedEventPatterns.some((pattern) =>
matchesEventPattern(pattern, item.value),
),
),
[supportedEventPatterns],
);
useEffect(() => {
if (availablePresets.some((item) => item.value === preset)) return;
selectPreset(availablePresets[0]?.value ?? 'custom');
}, [availablePresets, preset]);
function selectPreset(value: string) {
setPreset(value);
@@ -129,6 +171,14 @@ export default function AgentDebugPanel({
toast.error(t('agents.debugInputRequired'));
return;
}
if (
!supportedEventPatterns.some((pattern) =>
matchesEventPattern(pattern, eventType),
)
) {
toast.error(t('agents.debugUnsupportedEvent'));
return;
}
let eventData: Record<string, unknown>;
try {
@@ -142,6 +192,12 @@ export default function AgentDebugPanel({
return;
}
setRunning(true);
if (hasUnsavedChanges && beforeRun && !(await beforeRun())) {
setRunning(false);
return;
}
const requestId = globalThis.crypto?.randomUUID?.() ?? String(Date.now());
setEntries((current) => [
...current,
@@ -152,7 +208,6 @@ export default function AgentDebugPanel({
text: inputText.trim() || JSON.stringify(eventData, null, 2),
},
]);
setRunning(true);
try {
const result = await httpClient.debugAgent(agentId, {
event_type: eventType,
@@ -171,17 +226,41 @@ export default function AgentDebugPanel({
]);
if (isMessageEvent) setInputText('');
} catch (error) {
const errorCode =
typeof error === 'object' && error && 'code' in error
? String((error as { code?: string }).code || '')
: '';
const message =
typeof error === 'object' && error && 'msg' in error
? String((error as { msg?: string }).msg || '')
: t('agents.debugRunFailed');
const isConfigError = errorCode.endsWith('.config_invalid');
const isExecutionError = errorCode === 'runner_execution_failed';
const isTimeout = errorCode === 'runner.timeout';
const friendlyMessage = isConfigError
? t('agents.debugRunnerConfigInvalidDescription', {
message:
message === 'api-key is required'
? t('agents.debugApiKeyRequired')
: message,
})
: isExecutionError
? t('agents.debugRunnerExecutionFailedDescription')
: isTimeout
? t('agents.debugRunnerTimeoutDescription')
: message || t('agents.debugRunFailed');
setEntries((current) => [
...current,
{
id: `error:${requestId}`,
direction: 'error',
eventType,
text: message || t('agents.debugRunFailed'),
text: friendlyMessage,
errorCode,
detail:
isExecutionError || isTimeout
? message || t('agents.debugRunFailed')
: undefined,
},
]);
} finally {
@@ -200,7 +279,7 @@ export default function AgentDebugPanel({
<SelectValue />
</SelectTrigger>
<SelectContent>
{EVENT_PRESETS.map((item) => (
{availablePresets.map((item) => (
<SelectItem key={item.value} value={item.value}>
{t(item.labelKey)}
</SelectItem>
@@ -242,22 +321,30 @@ export default function AgentDebugPanel({
</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>
<Alert className="my-4 bg-muted/20">
<CircleHelp className="size-4" />
<AlertTitle>{t('agents.debugEmptyTitle')}</AlertTitle>
<AlertDescription>
{t('agents.debugEmptyTranscript')}
</AlertDescription>
</Alert>
) : (
<div className="space-y-3">
{entries.map((entry) => (
<div
<Alert
key={entry.id}
className={`rounded-lg border p-3 ${
variant={
entry.direction === 'error' ? 'destructive' : 'default'
}
className={
entry.direction === 'output'
? 'border-primary/20 bg-primary/5'
: entry.direction === 'error'
? 'border-destructive/30 bg-destructive/5'
: 'bg-muted/40'
}`}
: entry.direction === 'input'
? 'bg-muted/40'
: undefined
}
>
{entry.direction === 'error' && <AlertCircle />}
<div className="mb-2 flex items-center justify-between gap-2">
<Badge variant="outline">{entry.eventType}</Badge>
<span className="text-xs text-muted-foreground">
@@ -271,7 +358,36 @@ export default function AgentDebugPanel({
<pre className="min-w-0 whitespace-pre-wrap break-words font-sans text-sm leading-relaxed">
{entry.text}
</pre>
</div>
{entry.detail && (
<Collapsible className="mt-3">
<CollapsibleTrigger asChild>
<Button type="button" variant="ghost" size="sm">
{t('agents.debugErrorDetails')}
<ChevronDown className="size-3.5" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="mt-2 max-h-32 overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted p-2 font-mono text-xs text-muted-foreground">
{entry.detail}
</pre>
</CollapsibleContent>
</Collapsible>
)}
{(entry.errorCode?.endsWith('.config_invalid') ||
entry.errorCode === 'runner_execution_failed' ||
entry.errorCode === 'runner.timeout') &&
onOpenRunnerConfig && (
<Button
type="button"
variant="outline"
size="sm"
className="mt-3"
onClick={onOpenRunnerConfig}
>
{t('agents.debugReviewRunnerConfig')}
</Button>
)}
</Alert>
))}
</div>
)}
@@ -322,7 +438,11 @@ export default function AgentDebugPanel({
) : (
<Play className="size-4" />
)}
{running ? t('agents.debugRunning') : t('agents.debugRun')}
{running
? t('agents.debugRunning')
: hasUnsavedChanges
? t('agents.debugSaveAndRun')
: t('agents.debugRun')}
</Button>
</div>
</div>
@@ -1,4 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
forwardRef,
type ForwardedRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@@ -51,23 +60,62 @@ export interface AgentRunnerStatus {
interface AgentFormComponentProps {
agentId: string;
onFinish: () => void;
onFinish: (agent?: Partial<Agent>) => void;
onDeleted: () => void;
onDirtyChange?: (dirty: boolean) => void;
onSavingChange?: (saving: boolean) => void;
onRunnerStatusChange?: (status: AgentRunnerStatus) => void;
}
type AgentConfigSection = 'events' | 'runner' | 'runner_config' | 'basic';
export type AgentConfigSection =
'events' | 'runner' | 'runner_config' | 'basic';
export default function AgentFormComponent({
agentId,
onFinish,
onDeleted,
onDirtyChange,
onSavingChange,
onRunnerStatusChange,
}: AgentFormComponentProps) {
export interface AgentFormHandle {
openSection: (section: AgentConfigSection) => void;
save: () => Promise<boolean>;
}
function isRequiredRunnerValueMissing(value: unknown): boolean {
if (value === null || value === undefined) return true;
if (typeof value === 'string') return value.trim() === '';
if (Array.isArray(value)) return value.length === 0;
if (typeof value === 'object' && 'primary' in value) {
return !String((value as { primary?: unknown }).primary || '').trim();
}
return false;
}
function isRunnerFieldVisible(
field: PipelineConfigStage['config'][number],
values: Record<string, unknown>,
) {
if (!field.show_if || field.show_if.field.startsWith('__system.')) {
return true;
}
const dependentValue = values[field.show_if.field];
if (field.show_if.operator === 'eq') {
return dependentValue === field.show_if.value;
}
if (field.show_if.operator === 'neq') {
return dependentValue !== field.show_if.value;
}
return (
Array.isArray(field.show_if.value) &&
field.show_if.value.includes(dependentValue)
);
}
function AgentFormComponent(
{
agentId,
onFinish,
onDeleted,
onDirtyChange,
onSavingChange,
onRunnerStatusChange,
}: AgentFormComponentProps,
ref: ForwardedRef<AgentFormHandle>,
) {
const { t } = useTranslation();
const [runnerConfigSchema, setRunnerConfigSchema] =
useState<PipelineConfigTab | null>(null);
@@ -80,6 +128,7 @@ export default function AgentFormComponent({
const [activeSection, setActiveSection] =
useState<AgentConfigSection>('basic');
const isSavingRef = useRef(false);
const hasUnsavedChangesRef = useRef(false);
const formSchema = z.object({
basic: z.object({
@@ -116,6 +165,7 @@ export default function AgentFormComponent({
if (!savedSnapshotRef.current) return false;
return JSON.stringify(watchedValues) !== savedSnapshotRef.current;
})();
hasUnsavedChangesRef.current = hasUnsavedChanges;
useEffect(() => {
onDirtyChange?.(hasUnsavedChanges);
@@ -191,6 +241,24 @@ export default function AgentFormComponent({
const activeRunnerStage = runnerConfigSchema?.stages.find(
(stage) => stage.name === currentRunner,
);
const runnerConfigValues = form.watch('runner_config') as Record<
string,
Record<string, unknown>
>;
const activeRunnerValues = useMemo(
() => runnerConfigValues?.[currentRunner] ?? {},
[currentRunner, runnerConfigValues],
);
const missingRunnerFields = useMemo(
() =>
(activeRunnerStage?.config ?? []).filter(
(field) =>
field.required &&
isRunnerFieldVisible(field, activeRunnerValues) &&
isRequiredRunnerValueMissing(activeRunnerValues[field.name]),
),
[activeRunnerStage, activeRunnerValues],
);
const primarySections: Array<{
name: AgentConfigSection;
label: string;
@@ -270,6 +338,18 @@ export default function AgentFormComponent({
};
}
if (missingRunnerFields.length > 0) {
return {
label: t('agents.runnerConfigIncomplete'),
description: t('agents.runnerConfigIncompleteDescription', {
fields: missingRunnerFields
.map((field) => extractI18nObject(field.label))
.join(', '),
}),
tone: 'warning',
};
}
return {
label: t('agents.runnerReady'),
description: t('agents.runnerReadyDescription', {
@@ -283,6 +363,7 @@ export default function AgentFormComponent({
pluginStatusLoading,
pluginSystemStatus,
runnerOptions.length,
missingRunnerFields,
selectedRunnerOption,
t,
]);
@@ -369,45 +450,70 @@ export default function AgentFormComponent({
return patterns.length > 0 ? patterns : ['*'];
}
function handleSubmit(values: FormValues) {
if (isSavingRef.current) return;
const submittedSnapshot = JSON.stringify(values);
const runner = values.runner || {};
const agent: Partial<Agent> = {
name: values.basic.name,
description: values.basic.description ?? '',
emoji: values.basic.emoji,
enabled: values.basic.enabled ?? true,
component_ref: (runner.id as string) || null,
supported_event_patterns: normalizeEventPatterns(
values.supported_event_patterns_text,
),
config: {
runner,
runner_config: values.runner_config ?? {},
},
};
const saveValues = useCallback(
async (values: FormValues) => {
if (isSavingRef.current) return false;
const submittedSnapshot = JSON.stringify(values);
const runner = values.runner || {};
const agent: Partial<Agent> = {
name: values.basic.name,
description: values.basic.description ?? '',
emoji: values.basic.emoji,
enabled: values.basic.enabled ?? true,
component_ref: (runner.id as string) || null,
supported_event_patterns: normalizeEventPatterns(
values.supported_event_patterns_text,
),
config: {
runner,
runner_config: values.runner_config ?? {},
},
};
isSavingRef.current = true;
setIsSaving(true);
onSavingChange?.(true);
httpClient
.updateAgent(agentId, agent)
.then(() => {
isSavingRef.current = true;
setIsSaving(true);
onSavingChange?.(true);
try {
await httpClient.updateAgent(agentId, agent);
savedSnapshotRef.current = submittedSnapshot;
onFinish();
onFinish(agent);
toast.success(t('agents.saveSuccess'));
})
.catch((err) => {
toast.error(t('agents.saveError') + err.msg);
})
.finally(() => {
return true;
} catch (err) {
const message =
typeof err === 'object' && err && 'msg' in err
? String((err as { msg?: string }).msg || '')
: '';
toast.error(t('agents.saveError') + message);
return false;
} finally {
isSavingRef.current = false;
setIsSaving(false);
onSavingChange?.(false);
});
}
},
[agentId, onFinish, onSavingChange, t],
);
function handleSubmit(values: FormValues) {
void saveValues(values);
}
useImperativeHandle(
ref,
() => ({
openSection: setActiveSection,
async save() {
if (!hasUnsavedChangesRef.current) return true;
if (isSavingRef.current) return false;
const valid = await form.trigger();
if (!valid) return false;
return (await saveValues(form.getValues())) ?? false;
},
}),
[form, saveValues],
);
function confirmDelete() {
httpClient
.deleteAgent(agentId)
@@ -672,3 +778,5 @@ export default function AgentFormComponent({
</>
);
}
export default forwardRef(AgentFormComponent);
@@ -1752,6 +1752,17 @@ function findSidebarChildForPath(pathname: string): SidebarChildVO | undefined {
);
if (matchedChild) return matchedChild;
// Keep the legacy Pipeline URL usable after Pipelines and Agents were
// unified under the Processors section.
if (
pathname === '/home/pipelines' ||
pathname.startsWith('/home/pipelines/')
) {
return sidebarConfigList.find(
(childConfig) => childConfig.id === 'pipelines',
);
}
if (
pathname === '/home/mcp' ||
pathname === '/home/skills' ||
+1 -1
View File
@@ -269,7 +269,7 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) {
</div>
</header>
<main className="flex-1 overflow-hidden min-w-0 px-4 pb-4 pt-0">
<main className="min-h-0 min-w-0 flex-1 overflow-clip px-4 pb-4 pt-0">
<div
className={`mx-auto h-full w-full min-w-0 ${HOME_CONTENT_MAX_WIDTH}`}
>
@@ -1,7 +1,9 @@
import { useState, useEffect } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import PipelineFormComponent from '@/app/home/pipelines/components/pipeline-form/PipelineFormComponent';
import PipelineFormComponent, {
PipelineFormHandle,
} 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';
@@ -42,6 +44,8 @@ export default function PipelineDetailContent({
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
const [formDirty, setFormDirty] = useState(false);
const [formSaving, setFormSaving] = useState(false);
const pipelineFormRef = useRef<PipelineFormHandle>(null);
const pipeline = pipelines.find((item) => item.id === id);
function handleFinish() {
refreshPipelines();
@@ -96,7 +100,7 @@ export default function PipelineDetailContent({
return (
<ProcessorDetailWorkbench
key={id}
title={t('pipelines.editPipeline')}
title={`${pipeline?.emoji || '⚙️'} ${pipeline?.name || t('pipelines.editPipeline')}`}
saveLabel={t('common.save')}
saveFormId="pipeline-form"
canSave={canManage}
@@ -106,6 +110,7 @@ export default function PipelineDetailContent({
configContent={
<fieldset className="contents" disabled={!canManage}>
<PipelineFormComponent
ref={pipelineFormRef}
pipelineId={id}
isEditMode={true}
disableForm={!canManage}
@@ -130,6 +135,8 @@ export default function PipelineDetailContent({
pipelineId={id}
isEmbedded={true}
compact={true}
hasUnsavedChanges={formDirty}
beforeSend={async () => pipelineFormRef.current?.save() ?? false}
onConnectionStatusChange={setIsWebSocketConnected}
/>
) : undefined
@@ -40,7 +40,13 @@ import {
Music,
Code,
AlignLeft,
RotateCcw,
} from 'lucide-react';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
interface DebugDialogProps {
open: boolean;
@@ -48,6 +54,8 @@ interface DebugDialogProps {
isEmbedded?: boolean;
compact?: boolean;
onConnectionStatusChange?: (isConnected: boolean) => void;
beforeSend?: () => Promise<boolean>;
hasUnsavedChanges?: boolean;
}
function AuthenticatedMessageImage({
@@ -118,6 +126,8 @@ export default function DebugDialog({
isEmbedded = false,
compact = false,
onConnectionStatusChange,
beforeSend,
hasUnsavedChanges = false,
}: DebugDialogProps) {
const { t } = useTranslation();
const [selectedPipelineId, setSelectedPipelineId] = useState(pipelineId);
@@ -177,7 +187,7 @@ export default function DebugDialog({
sessionType,
);
if (generation !== historyRequestGenerationRef.current) return;
setMessages(response.messages);
setMessages(Array.isArray(response.messages) ? response.messages : []);
} catch (error) {
if (generation !== historyRequestGenerationRef.current) return;
console.error('Failed to load messages:', error);
@@ -186,6 +196,19 @@ export default function DebugDialog({
[sessionType],
);
const resetConversation = useCallback(async () => {
try {
await httpClient.resetWebSocketSession(selectedPipelineId, sessionType);
invalidateHistoryRequests();
setMessages([]);
setQuotedMessage(null);
toast.success(t('pipelines.debugDialog.resetSuccess'));
} catch (error) {
console.error('Failed to reset Debug Chat session:', error);
toast.error(t('pipelines.debugDialog.resetFailed'));
}
}, [invalidateHistoryRequests, selectedPipelineId, sessionType, t]);
// Initialize WebSocket connection
const initWebSocket = useCallback(
async (pipelineId: string) => {
@@ -435,6 +458,9 @@ export default function DebugDialog({
try {
setIsUploading(true);
if (hasUnsavedChanges && beforeSend && !(await beforeSend())) {
return;
}
const messageChain = [];
@@ -834,32 +860,65 @@ export default function DebugDialog({
compact && 'w-12 p-1.5 pl-1',
)}
>
<Button
variant="ghost"
size="icon"
className={cn(
'w-10 h-10 justify-center rounded-md transition-none border-0 shadow-none',
sessionType === 'person'
? 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground',
)}
onClick={() => setSessionType('person')}
>
<User className="size-5" />
</Button>
<Button
variant="ghost"
size="icon"
className={cn(
'w-10 h-10 justify-center rounded-md transition-none border-0 shadow-none',
sessionType === 'group'
? 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground',
)}
onClick={() => setSessionType('group')}
>
<Users className="size-5" />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label={t('pipelines.debugDialog.privateChat')}
className={cn(
'w-10 h-10 justify-center rounded-md transition-none border-0 shadow-none',
sessionType === 'person'
? 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground',
)}
onClick={() => setSessionType('person')}
>
<User className="size-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
{t('pipelines.debugDialog.privateChat')}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label={t('pipelines.debugDialog.groupChat')}
className={cn(
'w-10 h-10 justify-center rounded-md transition-none border-0 shadow-none',
sessionType === 'group'
? 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground',
)}
onClick={() => setSessionType('group')}
>
<Users className="size-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
{t('pipelines.debugDialog.groupChat')}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t('pipelines.debugDialog.reset')}
className="w-10 h-10 justify-center rounded-md text-muted-foreground"
onClick={() => void resetConversation()}
>
<RotateCcw className="size-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
{t('pipelines.debugDialog.reset')}
</TooltipContent>
</Tooltip>
</div>
<div className="flex-1 flex flex-col w-[10rem] h-full min-h-0">
@@ -1120,7 +1179,9 @@ export default function DebugDialog({
) : (
<>
<Send className="size-4" />
{t('pipelines.debugDialog.send')}
{hasUnsavedChanges
? t('pipelines.debugDialog.saveAndSend')
: t('pipelines.debugDialog.send')}
</>
)}
</Button>
@@ -1,4 +1,11 @@
import { useEffect, useRef, useState, useMemo } from 'react';
import {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { GetPipelineResponseData, Pipeline } from '@/app/infra/entities/api';
import {
@@ -51,17 +58,7 @@ import {
} from 'lucide-react';
import PipelineExtension from '@/app/home/pipelines/components/pipeline-extensions/PipelineExtension';
export default function PipelineFormComponent({
onFinish,
onNewPipelineCreated,
isEditMode,
pipelineId,
showButtons = true,
onDeletePipeline,
onCancel,
onDirtyChange,
onSavingChange,
}: {
interface PipelineFormComponentProps {
pipelineId?: string;
isEditMode: boolean;
disableForm: boolean;
@@ -72,7 +69,29 @@ export default function PipelineFormComponent({
onCancel?: () => void;
onDirtyChange?: (dirty: boolean) => void;
onSavingChange?: (saving: boolean) => void;
}) {
}
export interface PipelineFormHandle {
save: () => Promise<boolean>;
}
const PipelineFormComponent = forwardRef<
PipelineFormHandle,
PipelineFormComponentProps
>(function PipelineFormComponent(
{
onFinish,
onNewPipelineCreated,
isEditMode,
pipelineId,
showButtons = true,
onDeletePipeline,
onCancel,
onDirtyChange,
onSavingChange,
},
ref,
) {
const { t } = useTranslation();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showCopyConfirm, setShowCopyConfirm] = useState(false);
@@ -268,7 +287,7 @@ export default function PipelineFormComponent({
function handleFormSubmit(values: FormValues) {
if (isEditMode) {
handleModify(values);
void handleModify(values);
} else {
handleCreate(values);
}
@@ -302,8 +321,8 @@ export default function PipelineFormComponent({
});
}
function handleModify(values: FormValues) {
if (isSavingRef.current) return;
async function handleModify(values: FormValues): Promise<boolean> {
if (isSavingRef.current) return false;
const submittedSnapshot = JSON.stringify(values);
const realConfig = {
ai: values.ai,
@@ -327,23 +346,36 @@ export default function PipelineFormComponent({
isSavingRef.current = true;
setIsSaving(true);
onSavingChange?.(true);
httpClient
.updatePipeline(pipelineId || '', pipeline)
.then(() => {
savedSnapshotRef.current = submittedSnapshot;
onFinish();
toast.success(t('pipelines.saveSuccess'));
})
.catch((err) => {
toast.error(t('pipelines.saveError') + err.msg);
})
.finally(() => {
isSavingRef.current = false;
setIsSaving(false);
onSavingChange?.(false);
});
try {
await httpClient.updatePipeline(pipelineId || '', pipeline);
savedSnapshotRef.current = submittedSnapshot;
onFinish();
toast.success(t('pipelines.saveSuccess'));
return true;
} catch (err) {
const message =
typeof err === 'object' && err && 'msg' in err
? String((err as { msg?: string }).msg || '')
: '';
toast.error(t('pipelines.saveError') + message);
return false;
} finally {
isSavingRef.current = false;
setIsSaving(false);
onSavingChange?.(false);
}
}
useImperativeHandle(ref, () => ({
async save() {
if (!hasUnsavedChangesRef.current) return true;
if (isSavingRef.current || !isEditMode) return false;
const valid = await form.trigger();
if (!valid) return false;
return handleModify(form.getValues());
},
}));
// Called from DynamicFormComponent onSubmit callbacks.
// On the first emission for a stage (mount-time default filling), the
// snapshot is synchronously re-captured so that hasUnsavedChanges stays false.
@@ -877,7 +909,9 @@ export default function PipelineFormComponent({
</Dialog>
</>
);
}
});
export default PipelineFormComponent;
interface SectionItem {
label: string;
name: string;
+2 -2
View File
@@ -207,7 +207,7 @@ function SidebarProvider({
} as React.CSSProperties
}
className={cn(
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex h-svh w-full overflow-hidden',
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex h-svh min-h-0 w-full overflow-clip',
className,
)}
{...props}
@@ -566,7 +566,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
<main
data-slot="sidebar-inset"
className={cn(
'bg-background relative flex w-full flex-1 flex-col min-w-0',
'bg-background relative flex min-h-0 w-full flex-1 flex-col overflow-clip min-w-0',
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
'dark:md:peer-data-[variant=inset]:border dark:md:peer-data-[variant=inset]:border-sidebar-border',
className,
+18 -1
View File
@@ -727,6 +727,8 @@ const enUS = {
selectedRunnerUnavailableDescription:
'{{runner}} is not currently registered. Select another runner or restore its extension.',
noRunnerSelected: 'No runner selected',
runnerConfigIncomplete: 'Runner configuration incomplete',
runnerConfigIncompleteDescription: 'Complete required fields: {{fields}}',
runnerReady: 'Runner ready',
runnerReadyDescription:
'{{runner}} is registered and the plugin runtime is connected.',
@@ -749,18 +751,32 @@ const enUS = {
debugEventPayload: 'Event payload (JSON)',
debugSupportedEvents: 'Agent supports',
debugRun: 'Run test',
debugSaveAndRun: 'Save and run',
debugRunning: 'Running',
debugTranscript: 'Debug transcript',
debugTranscriptDescription:
'Inputs and Agent outputs from the current debug session.',
debugEmptyTitle: 'Verify how this Agent behaves',
debugEmptyTranscript:
'Choose an event and run a test to see the result here.',
'Choose an event, enter test content, then select “Run test”. Results stay on this page.',
debugAgentOutput: 'Agent output',
debugTestInput: 'Test input',
debugNoTextOutput: 'The run completed without textual output.',
debugEventTypeRequired: 'Enter an event type',
debugInputRequired: 'Enter a conversation input',
debugInvalidPayload: 'The event payload must be a valid JSON object',
debugUnsupportedEvent:
'This event is outside the Agents bindable event range',
debugRunnerConfigInvalidDescription:
'The runner configuration is incomplete: {{message}}',
debugRunnerExecutionFailedDescription:
'This run failed. Check the selected model and runner configuration, then try again.',
debugRunnerTimeoutDescription:
'The run timed out. Try again later or adjust the runner timeout.',
debugApiKeyRequired: 'API Key is missing',
debugOpenRunnerConfig: 'Open runner configuration',
debugReviewRunnerConfig: 'Review runner configuration',
debugErrorDetails: 'View error details',
debugRunFailed: 'Agent debug run failed',
},
plugins: {
@@ -1324,6 +1340,7 @@ const enUS = {
privateChat: 'Private Chat',
groupChat: 'Group Chat',
send: 'Send',
saveAndSend: 'Save and send',
reset: 'Reset Conversation',
inputPlaceholder: 'Send {{type}} message...',
noMessages: 'No messages',
+16 -1
View File
@@ -696,6 +696,8 @@ const zhHans = {
selectedRunnerUnavailableDescription:
'{{runner}} 当前未注册。请选择其他运行器,或恢复对应扩展。',
noRunnerSelected: '尚未选择运行器',
runnerConfigIncomplete: '运行器配置待完善',
runnerConfigIncompleteDescription: '请填写必填项:{{fields}}',
runnerReady: '运行器已就绪',
runnerReadyDescription: '{{runner}} 已注册,插件运行时连接正常。',
debugTab: '事件调试',
@@ -716,16 +718,28 @@ const zhHans = {
debugEventPayload: '事件载荷(JSON',
debugSupportedEvents: 'Agent 支持',
debugRun: '运行测试',
debugSaveAndRun: '保存并运行',
debugRunning: '运行中',
debugTranscript: '调试记录',
debugTranscriptDescription: '当前调试会话中的输入与 Agent 输出。',
debugEmptyTranscript: '选择事件并运行测试后,结果会显示在这里。',
debugEmptyTitle: '在这里验证 Agent 的实际效果',
debugEmptyTranscript:
'选择事件类型,填写测试内容,然后点击“运行测试”。结果只会显示在这里。',
debugAgentOutput: 'Agent 输出',
debugTestInput: '测试输入',
debugNoTextOutput: '运行完成,但没有产生文本输出。',
debugEventTypeRequired: '请输入事件类型',
debugInputRequired: '请输入对话内容',
debugInvalidPayload: '事件载荷必须是有效的 JSON 对象',
debugUnsupportedEvent: '这个事件不在当前 Agent 的可绑定事件范围内',
debugRunnerConfigInvalidDescription: '运行器配置不完整:{{message}}',
debugRunnerExecutionFailedDescription:
'本次运行失败。请检查所选模型和运行器配置后重试。',
debugRunnerTimeoutDescription: '运行超时。请稍后重试或调整运行器超时时间。',
debugApiKeyRequired: 'API Key 未填写',
debugOpenRunnerConfig: '前往运行器配置',
debugReviewRunnerConfig: '检查运行器配置',
debugErrorDetails: '查看详细错误',
debugRunFailed: 'Agent 调试运行失败',
},
plugins: {
@@ -1267,6 +1281,7 @@ const zhHans = {
privateChat: '私聊',
groupChat: '群聊',
send: '发送',
saveAndSend: '保存并发送',
reset: '重置对话',
inputPlaceholder: '发送 {{type}} 消息...',
noMessages: '暂无消息',
+28
View File
@@ -706,6 +706,24 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
return fulfillJson(route, { agents: state.pipelines });
}
const agentDebugMatch = path.match(/^\/api\/v1\/agents\/([^/]+)\/debug$/);
if (agentDebugMatch) {
const payload = parseJsonBody(route);
return fulfillJson(route, {
event_id: nextId(state, 'event'),
event_type: String(payload.event_type || 'message.received'),
conversation_id: String(payload.conversation_id || 'debug-session'),
final_text: 'Mock Agent response',
outputs: [
{
kind: 'message',
role: 'assistant',
text: 'Mock Agent response',
},
],
});
}
const agentMatch = path.match(/^\/api\/v1\/agents\/([^/]+)$/);
if (agentMatch) {
const agentId = decodeURIComponent(agentMatch[1]);
@@ -756,6 +774,16 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
return fulfillJson(route, { pipelines: state.pipelines });
}
if (
/^\/api\/v1\/pipelines\/[^/]+\/ws\/messages\/(person|group)$/.test(path)
) {
return fulfillJson(route, { messages: [] });
}
if (/^\/api\/v1\/pipelines\/[^/]+\/ws\/reset\/(person|group)$/.test(path)) {
return fulfillJson(route, { message: 'reset' });
}
const pipelineMatch = path.match(/^\/api\/v1\/pipelines\/([^/]+)$/);
if (pipelineMatch) {
const pipelineId = decodeURIComponent(pipelineMatch[1]);
@@ -19,7 +19,6 @@ test.describe('processor detail workbench', () => {
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();
@@ -28,9 +27,21 @@ test.describe('processor detail workbench', () => {
expect(configBox!.width).toBeGreaterThan(debugBox!.width);
const appShell = page.locator('[class*="group/sidebar-wrapper"]');
const sidebarInset = page.locator('[data-slot="sidebar-inset"]');
await expect(appShell).toHaveCSS('overflow', 'clip');
await expect(sidebarInset).toHaveCSS('overflow', 'clip');
await appShell.evaluate((element) => {
element.scrollTop = 300;
});
await sidebarInset.evaluate((element) => {
element.scrollTop = 300;
});
await expect
.poll(() => appShell.evaluate((element) => element.scrollTop))
.toBe(0);
await expect
.poll(() => sidebarInset.evaluate((element) => element.scrollTop))
.toBe(0);
expect(debugBox!.y).toBeGreaterThanOrEqual(0);
const flow = configPanel.getByRole('tablist');
@@ -66,10 +77,95 @@ test.describe('processor detail workbench', () => {
).toBeVisible();
});
test('agent saves edits before debugging and shows the real output', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
const requests: string[] = [];
page.on('request', (request) => {
const path = new URL(request.url()).pathname;
if (
request.method() === 'PUT' &&
path === '/api/v1/agents/agent-workbench'
) {
requests.push('save');
}
if (
request.method() === 'POST' &&
path === '/api/v1/agents/agent-workbench/debug'
) {
requests.push('debug');
}
});
await page.goto('/home/agents?id=agent-workbench');
await page.getByLabel('Description').fill('Updated before debugging');
await page
.getByRole('textbox', { name: 'Conversation input' })
.fill('Hello');
await page.getByRole('button', { name: 'Save and run' }).click();
await expect(page.getByText('Mock Agent response')).toBeVisible();
expect(requests).toEqual(['save', 'debug']);
});
test('agent turns runner failures into an actionable message', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.route(
'**/api/v1/agents/agent-workbench/debug',
async (route) => {
await route.fulfill({
status: 422,
contentType: 'application/json',
body: JSON.stringify({
code: 'dify.config_invalid',
msg: 'api-key is required',
}),
});
},
);
await page.goto('/home/agents?id=agent-workbench');
await page
.getByRole('textbox', { name: 'Conversation input' })
.fill('Hello');
await page.getByRole('button', { name: 'Run test' }).click();
await expect(
page.getByText(
'The runner configuration is incomplete: API Key is missing',
),
).toBeVisible();
await expect(page.getByText('Internal server error')).toHaveCount(0);
await page
.getByRole('button', { name: 'Review runner configuration' })
.click();
await expect(
page.getByRole('tab', { name: 'Local Agent', exact: true }),
).toHaveAttribute('data-state', 'active');
});
test('pipeline keeps debug chat left and exposes its main flow first', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.routeWebSocket('**/api/v1/pipelines/**/ws/connect**', (ws) => {
ws.onMessage((raw) => {
const message = JSON.parse(String(raw));
if (message.type === 'authenticate') {
ws.send(
JSON.stringify({
type: 'connected',
connection_id: 'playwright-connection',
pipeline_uuid: 'pipeline-workbench',
session_type: 'person',
}),
);
}
});
});
await page.goto('/home/pipelines?id=pipeline-workbench');
@@ -77,6 +173,18 @@ test.describe('processor detail workbench', () => {
const configPanel = page.getByRole('region', { name: 'Configuration' });
await expect(debugPanel).toBeVisible();
await expect(configPanel).toBeVisible();
await expect(
debugPanel.getByRole('button', { name: 'Private Chat' }),
).toBeVisible();
await expect(
debugPanel.getByRole('button', { name: 'Group Chat' }),
).toBeVisible();
await debugPanel
.getByRole('button', { name: 'Reset Conversation' })
.click();
await expect(
page.getByText('Conversation reset successfully'),
).toBeVisible();
const debugBox = await debugPanel.boundingBox();
const configBox = await configPanel.boundingBox();
@@ -86,19 +194,18 @@ test.describe('processor detail workbench', () => {
expect(configBox!.width).toBeGreaterThan(debugBox!.width);
const appShell = page.locator('[class*="group/sidebar-wrapper"]');
const sidebarInset = page.locator('[data-slot="sidebar-inset"]');
await expect(appShell).toHaveCSS('overflow', 'clip');
await expect(sidebarInset).toHaveCSS('overflow', 'clip');
await expect
.poll(() => appShell.evaluate((element) => element.scrollTop))
.toBe(0);
expect(debugBox!.y).toBeGreaterThanOrEqual(0);
const flow = configPanel.getByRole('tablist');
await expect(flow.getByRole('tab').nth(0)).toContainText(
'Trigger Conditions',
);
await expect(flow.getByRole('tab').nth(1)).toContainText('AI Capabilities');
await expect(flow.getByRole('tab').nth(2)).toContainText(
'Output Processing',
);
await expect(flow.getByRole('tab').nth(0)).toContainText('Trigger');
await expect(flow.getByRole('tab').nth(1)).toContainText('AI');
await expect(flow.getByRole('tab').nth(2)).toContainText('Output');
await flow.getByRole('tab').nth(1).click();
await expect(