Compare commits

...

7 Commits

Author SHA1 Message Date
RockChinQ 6a6a2b865b fix(web): make processor debugging reliable 2026-08-25 15:52:59 +08:00
RockChinQ 781d8a9ac8 fix(web): clean up pipeline debug websocket 2026-08-25 12:50:15 +08:00
RockChinQ e3150e66a4 refactor(web): use shadcn processor navigation 2026-08-25 12:36:57 +08:00
RockChinQ ac31f1f006 feat(web): surface agent basics and runner status 2026-08-25 12:17:59 +08:00
RockChinQ f65cca3f40 fix(web): contain pipeline debug scrolling 2026-08-25 12:03:43 +08:00
RockChinQ e70a0d3f01 fix(web): remove agent debug run notice 2026-08-25 11:58:54 +08:00
RockChinQ 46aea2b499 feat(web): unify processor detail workbench 2026-08-25 11:54:59 +08:00
28 changed files with 1846 additions and 770 deletions
+15 -1
View File
@@ -1,14 +1,17 @@
"""Agent runner errors."""
from __future__ import annotations
class AgentRunnerError(Exception):
"""Base error for agent runner operations."""
pass
class RunnerNotFoundError(AgentRunnerError):
"""Runner not found in registry."""
def __init__(self, runner_id: str):
self.runner_id = runner_id
super().__init__(f'Agent runner not found: {runner_id}')
@@ -16,6 +19,7 @@ class RunnerNotFoundError(AgentRunnerError):
class RunnerNotAuthorizedError(AgentRunnerError):
"""Runner not authorized for this binding."""
def __init__(self, runner_id: str, bound_plugins: list[str] | None):
self.runner_id = runner_id
self.bound_plugins = bound_plugins
@@ -24,6 +28,7 @@ class RunnerNotAuthorizedError(AgentRunnerError):
class RunnerProtocolError(AgentRunnerError):
"""Runner protocol version mismatch or invalid manifest."""
def __init__(self, runner_id: str, message: str):
self.runner_id = runner_id
super().__init__(f'Agent runner protocol error for {runner_id}: {message}')
@@ -31,7 +36,16 @@ class RunnerProtocolError(AgentRunnerError):
class RunnerExecutionError(AgentRunnerError):
"""Runner execution failed."""
def __init__(self, runner_id: str, message: str, retryable: bool = False):
def __init__(
self,
runner_id: str,
message: str,
retryable: bool = False,
error_code: str | None = None,
):
self.runner_id = runner_id
self.message = message
self.retryable = retryable
self.error_code = error_code
super().__init__(f'Agent runner {runner_id} execution failed: {message}')
+5 -5
View File
@@ -58,21 +58,21 @@ class AgentRunnerInvoker:
except asyncio.TimeoutError as e:
raise RunnerExecutionError(
descriptor.id,
'Runner timed out (code: runner.timeout)',
'Runner timed out',
retryable=True,
error_code='runner.timeout',
) from e
except ActionCallTimeoutError as e:
raise RunnerExecutionError(
descriptor.id,
f'{e} (code: runner.timeout)',
str(e),
retryable=True,
error_code='runner.timeout',
) from e
except RunnerExecutionError:
raise
except Exception as e:
self.ap.logger.error(
f'Runner {descriptor.id} unexpected error: {traceback.format_exc()}'
)
self.ap.logger.error(f'Runner {descriptor.id} unexpected error: {traceback.format_exc()}')
raise RunnerExecutionError(
descriptor.id,
str(e),
@@ -152,10 +152,14 @@ class AgentResultNormalizer:
error_msg = data.get('error', 'Unknown error')
error_code = data.get('code', 'unknown')
retryable = data.get('retryable', False)
normalized_error_code = str(error_code or '').strip()
raise RunnerExecutionError(
descriptor.id,
f'{error_msg} (code: {error_code})',
str(error_msg),
retryable=retryable,
error_code=(
normalized_error_code if normalized_error_code and normalized_error_code != 'unknown' else None
),
)
elif result_type == 'action.requested':
@@ -2,6 +2,13 @@ from __future__ import annotations
import quart
from .....agent.runner.errors import (
AgentRunnerError,
RunnerExecutionError,
RunnerNotAuthorizedError,
RunnerNotFoundError,
RunnerProtocolError,
)
from ...authz import Permission, require_permission
from ...context import RequestContext
from .. import group
@@ -63,6 +70,36 @@ class AgentsRouterGroup(group.RouterGroup):
)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
except RunnerExecutionError as exc:
return self.http_status(
422,
exc.error_code or 'runner_execution_failed',
exc.message,
)
except RunnerNotFoundError:
return self.http_status(
409,
'runner_not_found',
'The configured Agent runner is unavailable',
)
except RunnerNotAuthorizedError:
return self.http_status(
403,
'runner_not_authorized',
'The configured Agent runner is not authorized',
)
except RunnerProtocolError:
return self.http_status(
502,
'runner_protocol_error',
'The Agent runner returned an invalid response',
)
except AgentRunnerError:
return self.http_status(
502,
'runner_error',
'The Agent runner could not complete this test',
)
return self.success(data=result)
@self.route(
@@ -25,7 +25,7 @@ class BanWordFilter(filter_model.ContentFilter):
return entities.FilterResult(
level=entities.ResultLevel.BLOCK,
replacement='',
user_notice='内容检查规则执行失败,请联系管理员',
user_notice='内容安全检查配置有误,请检查敏感词设置',
console_notice=f'Sensitive-word regex rejected: {exc}',
)
+4 -1
View File
@@ -7,7 +7,10 @@ from collections.abc import Sequence
import regex
MAX_PATTERN_COUNT = 64
# The bundled sensitive-word list already contains more than 64 entries. Keep
# the deterministic cap, but leave enough room for the built-in defaults and
# reasonable administrator customisation.
MAX_PATTERN_COUNT = 256
MAX_PATTERN_CHARS = 1024
MAX_INPUT_CHARS = 1024 * 1024
MAX_REPLACEMENT_CHARS = 64
@@ -901,7 +901,7 @@ async def test_orchestrator_enforces_total_runner_deadline(clean_agent_state):
[message async for message in orchestrator.run_from_query(query)]
assert exc_info.value.retryable is True
assert 'runner.timeout' in str(exc_info.value)
assert exc_info.value.error_code == 'runner.timeout'
assert await get_session_registry().list_active_runs() == []
@@ -1012,6 +1012,7 @@ class TestQueryEntrySessionQueryId:
]
)
ap = FakeApplication(plugin_connector, db_engine)
async def build_resource_context(execution_query):
from langbot.pkg.provider.tools.loaders.mcp import (
_execution_context_from_query,
@@ -1025,9 +1026,7 @@ class TestQueryEntrySessionQueryId:
return 'Pinned documentation'
mcp_loader = types.SimpleNamespace(
build_resource_context_for_query=AsyncMock(
side_effect=build_resource_context
)
build_resource_context_for_query=AsyncMock(side_effect=build_resource_context)
)
ap.tool_mgr = types.SimpleNamespace(mcp_tool_loader=mcp_loader)
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
@@ -1105,14 +1104,8 @@ class TestQueryEntrySessionQueryId:
assert 'Pinned documentation' in plugin_connector.contexts[0]['input']['contents'][0]['text']
assert event.input.text == 'hello'
assert event.input.contents[0].text == 'hello'
assert (
plugin_connector.contexts[0]['conversation']['workspace_id']
== TEST_CONTEXT.workspace_uuid
)
assert (
plugin_connector.contexts[0]['runtime']['metadata']['workspace_id']
== TEST_CONTEXT.workspace_uuid
)
assert plugin_connector.contexts[0]['conversation']['workspace_id'] == TEST_CONTEXT.workspace_uuid
assert plugin_connector.contexts[0]['runtime']['metadata']['workspace_id'] == TEST_CONTEXT.workspace_uuid
assert 'Pinned documentation' not in str(execution_query.user_message.content)
mcp_loader.build_resource_context_for_query.assert_awaited_once_with(execution_query)
@@ -1,4 +1,5 @@
"""Tests for agent runner result normalizer."""
from __future__ import annotations
import pytest
@@ -12,6 +13,7 @@ from langbot_plugin.api.entities.builtin.provider import message as provider_mes
class FakeApplication:
"""Fake Application for testing."""
def __init__(self):
class FakeLogger:
def __init__(self):
@@ -19,10 +21,13 @@ class FakeApplication:
def info(self, msg):
pass
def debug(self, msg):
pass
def warning(self, msg):
self.warnings.append(msg)
def error(self, msg):
pass
@@ -192,6 +197,7 @@ class TestNormalizeRunFailed:
assert exc_info.value.runner_id == 'plugin:langbot-team/LocalAgent/default'
assert exc_info.value.retryable is True
assert exc_info.value.error_code == 'upstream.timeout'
assert 'timeout' in str(exc_info.value)
@@ -290,6 +296,7 @@ class TestNormalizeNonMessageResults:
assert result is None
assert app.logger.warnings
class TestNormalizeInvalidResults:
"""Tests for handling invalid results."""
@@ -9,6 +9,8 @@ from unittest.mock import ANY, AsyncMock
import pytest
import quart
from langbot.pkg.agent.runner.errors import RunnerExecutionError
core_app_module = types.ModuleType('langbot.pkg.core.app')
core_app_module.Application = object
sys.modules.setdefault('langbot.pkg.core.app', core_app_module)
@@ -142,3 +144,28 @@ async def test_debug_agent_returns_bad_request_for_invalid_event():
'code': -1,
'msg': 'Invalid event_type',
}
async def test_debug_agent_returns_actionable_runner_error():
agent_service = SimpleNamespace(
debug_agent=AsyncMock(
side_effect=RunnerExecutionError(
'plugin:langbot-team/DifyAgent/default',
'api-key is required',
error_code='dify.config_invalid',
)
),
)
client = await _create_test_client(agent_service)
response = await client.post(
'/api/v1/agents/agent-1/debug',
json={'event_type': 'message.received', 'text': 'hello'},
headers={'Authorization': 'Bearer test-token'},
)
assert response.status_code == 422
assert await response.get_json() == {
'code': 'dify.config_invalid',
'msg': 'api-key is required',
}
+19
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
import json
import threading
from pathlib import Path
import pytest
@@ -53,6 +55,23 @@ async def test_matches_any_rejects_pattern_and_input_amplification():
)
@pytest.mark.asyncio
async def test_bundled_sensitive_words_fit_within_pattern_limit():
config_path = Path(__file__).parents[3] / 'src/langbot/templates/metadata/sensitive-words.json'
config = json.loads(config_path.read_text())
assert len(config['words']) <= safe_regex.MAX_PATTERN_COUNT
found, masked = await safe_regex.mask_patterns(
config['words'],
'普通消息',
mask=config['mask'],
mask_word=config['mask_word'],
)
assert found is False
assert masked == '普通消息'
@pytest.mark.asyncio
async def test_mask_patterns_bounds_replacement_growth_and_masks_matches():
found, masked = await safe_regex.mask_patterns(
+57 -61
View File
@@ -1,23 +1,26 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, 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';
import AgentFormComponent from './components/AgentFormComponent';
import AgentFormComponent, {
AgentFormHandle,
AgentRunnerStatus,
} from './components/AgentFormComponent';
export default function AgentDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new';
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 +28,10 @@ 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');
const [runnerStatus, setRunnerStatus] = useState<AgentRunnerStatus | null>(
null,
);
const agentFormRef = useRef<AgentFormHandle>(null);
useEffect(() => {
if (isCreateMode) {
@@ -38,6 +44,10 @@ export default function AgentDetailContent({ id }: { id: string }) {
return () => setDetailEntityName(null);
}, [id, isCreateMode, pipelines, setDetailEntityName, t]);
useEffect(() => {
setRunnerStatus(null);
}, [id]);
useEffect(() => {
if (isCreateMode) return;
let cancelled = false;
@@ -79,45 +89,27 @@ 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={`${agent.emoji || '🤖'} ${agent.name}`}
status={runnerStatus}
saveLabel={t('common.save')}
saveFormId="agent-form"
canSave={canManage}
isDirty={formDirty}
isSaving={formSaving}
configTitle={t('pipelines.configuration')}
configContent={
<fieldset className="contents" disabled={!canManage}>
<AgentFormComponent
ref={agentFormRef}
agentId={id}
onFinish={() => {
onFinish={(updatedAgent) => {
if (updatedAgent) {
setAgent((current) =>
current ? { ...current, ...updatedAgent } : current,
);
}
refreshPipelines();
}}
onDeleted={() => {
@@ -126,24 +118,28 @@ export default function AgentDetailContent({ id }: { id: string }) {
}}
onDirtyChange={setFormDirty}
onSavingChange={setFormSaving}
onRunnerStatusChange={setRunnerStatus}
/>
</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}
hasUnsavedChanges={formDirty}
beforeRun={async () => agentFormRef.current?.save() ?? false}
onOpenRunnerConfig={() =>
agentFormRef.current?.openSection('runner_config')
}
supportedEventPatterns={
agent.supported_event_patterns ??
agent.capability?.supported_event_patterns ?? ['*']
}
/>
) : undefined
}
unsavedLabel={t('pipelines.unsavedChanges')}
/>
);
}
@@ -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,25 +1,17 @@
import { useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import {
AlertTriangle,
Braces,
AlertCircle,
ChevronDown,
CircleHelp,
LoaderCircle,
MessageSquare,
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 {
@@ -30,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 {
@@ -41,6 +42,8 @@ interface DebugEntry {
direction: 'input' | 'output' | 'error';
eventType: string;
text: string;
errorCode?: string;
detail?: string;
}
const EVENT_PRESETS = [
@@ -102,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');
@@ -121,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);
@@ -144,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 {
@@ -157,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,
@@ -167,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,
@@ -186,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 {
@@ -205,162 +269,182 @@ 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>
{availablePresets.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>
<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>
<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 ? (
<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) => (
<Alert
key={entry.id}
variant={
entry.direction === 'error' ? 'destructive' : 'default'
}
className={
entry.direction === 'output'
? 'border-primary/20 bg-primary/5'
: 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">
{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>
{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>
)}
</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')
: hasUnsavedChanges
? t('agents.debugSaveAndRun')
: t('agents.debugRun')}
</Button>
</div>
</div>
);
}
@@ -1,19 +1,19 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
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';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import {
CircleAlert,
CircleCheck,
LoaderCircle,
Power,
RefreshCw,
Trash2,
Unplug,
} from 'lucide-react';
import { Bot, Info, Power, SlidersHorizontal, Trash2, Zap } from 'lucide-react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Agent, ApiRespPluginSystemStatus } from '@/app/infra/entities/api';
import {
@@ -23,6 +23,7 @@ import {
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
@@ -50,23 +51,71 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
export interface AgentRunnerStatus {
label: string;
description?: string;
tone: 'neutral' | 'success' | 'warning' | 'error';
}
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;
}
export default function AgentFormComponent({
agentId,
onFinish,
onDeleted,
onDirtyChange,
onSavingChange,
}: AgentFormComponentProps) {
export type AgentConfigSection =
'events' | 'runner' | 'runner_config' | 'basic';
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);
@@ -76,7 +125,10 @@ export default function AgentFormComponent({
const [pluginStatusError, setPluginStatusError] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [activeSection, setActiveSection] =
useState<AgentConfigSection>('basic');
const isSavingRef = useRef(false);
const hasUnsavedChangesRef = useRef(false);
const formSchema = z.object({
basic: z.object({
@@ -113,6 +165,7 @@ export default function AgentFormComponent({
if (!savedSnapshotRef.current) return false;
return JSON.stringify(watchedValues) !== savedSnapshotRef.current;
})();
hasUnsavedChangesRef.current = hasUnsavedChanges;
useEffect(() => {
onDirtyChange?.(hasUnsavedChanges);
@@ -182,117 +235,142 @@ 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 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;
icon: React.ElementType;
}> = [
{
name: 'basic',
label: t('agents.basicInfo'),
icon: Info,
},
{
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 (
<div className="mt-3 flex flex-wrap gap-2">
{showRetry && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void loadPluginSystemStatus()}
>
<RefreshCw className="size-4" />
{t('common.retry')}
</Button>
)}
<Button type="button" variant="outline" size="sm" asChild>
<Link to="/home/extensions">{t('plugins.title')}</Link>
</Button>
</div>
);
}
function renderRunnerStatus() {
const runnerStatus = useMemo<AgentRunnerStatus>(() => {
if (pluginStatusLoading) {
return (
<Alert>
<LoaderCircle className="animate-spin" />
<AlertTitle>{t('agents.runnerStatusLoading')}</AlertTitle>
</Alert>
);
return {
label: t('agents.runnerStatusLoading'),
tone: 'neutral',
};
}
if (pluginStatusError || !pluginSystemStatus) {
return (
<Alert variant="destructive">
<CircleAlert />
<AlertTitle>{t('agents.runnerStatusCheckFailed')}</AlertTitle>
<AlertDescription>
{t('agents.runnerStatusCheckFailedDescription')}
{renderRunnerStatusActions()}
</AlertDescription>
</Alert>
);
return {
label: t('agents.runnerStatusCheckFailed'),
description: t('agents.runnerStatusCheckFailedDescription'),
tone: 'error',
};
}
if (!pluginSystemStatus.is_enable) {
return (
<Alert variant="destructive">
<Power />
<AlertTitle>{t('plugins.systemDisabled')}</AlertTitle>
<AlertDescription>
{t('plugins.systemDisabledDesc')}
{renderRunnerStatusActions(false)}
</AlertDescription>
</Alert>
);
return {
label: t('plugins.systemDisabled'),
description: t('plugins.systemDisabledDesc'),
tone: 'error',
};
}
if (!pluginSystemStatus.is_connected) {
return (
<Alert variant="destructive">
<Unplug />
<AlertTitle>{t('plugins.connectionError')}</AlertTitle>
<AlertDescription>
{t('plugins.connectionErrorDesc')}
{renderRunnerStatusActions()}
</AlertDescription>
</Alert>
);
return {
label: t('plugins.connectionError'),
description: t('plugins.connectionErrorDesc'),
tone: 'error',
};
}
if (runnerOptions.length === 0) {
return (
<Alert variant="destructive">
<CircleAlert />
<AlertTitle>{t('agents.noRunnersAvailable')}</AlertTitle>
<AlertDescription>
{t('agents.noRunnersAvailableDescription')}
{renderRunnerStatusActions()}
</AlertDescription>
</Alert>
);
return {
label: t('agents.noRunnersAvailable'),
description: t('agents.noRunnersAvailableDescription'),
tone: 'error',
};
}
if (!currentRunner || !selectedRunnerOption) {
return (
<Alert variant="destructive">
<CircleAlert />
<AlertTitle>{t('agents.selectedRunnerUnavailable')}</AlertTitle>
<AlertDescription>
{t('agents.selectedRunnerUnavailableDescription', {
runner: currentRunner || t('agents.noRunnerSelected'),
})}
{renderRunnerStatusActions()}
</AlertDescription>
</Alert>
);
return {
label: t('agents.selectedRunnerUnavailable'),
description: t('agents.selectedRunnerUnavailableDescription', {
runner: currentRunner || t('agents.noRunnerSelected'),
}),
tone: 'warning',
};
}
return (
<Alert className="border-emerald-600/40 bg-emerald-500/5 text-emerald-950 dark:text-emerald-100">
<CircleCheck className="text-emerald-600" />
<AlertTitle>{t('agents.runnerReady')}</AlertTitle>
<AlertDescription>
{t('agents.runnerReadyDescription', {
runner: extractI18nObject(selectedRunnerOption.label),
})}
</AlertDescription>
</Alert>
);
}
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', {
runner: extractI18nObject(selectedRunnerOption.label),
}),
tone: 'success',
};
}, [
currentRunner,
pluginStatusError,
pluginStatusLoading,
pluginSystemStatus,
runnerOptions.length,
missingRunnerFields,
selectedRunnerOption,
t,
]);
useEffect(() => {
onRunnerStatusChange?.(runnerStatus);
}, [onRunnerStatusChange, runnerStatus]);
function updateSnapshotIfInitial(stageKey: string) {
if (!initializedStagesRef.current.has(stageKey)) {
@@ -372,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)
@@ -433,63 +536,120 @@ 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">
<Tabs
value={activeSection}
onValueChange={(value) =>
setActiveSection(value as AgentConfigSection)
}
>
<div className="overflow-x-auto">
<TabsList className="grid min-w-[44rem] w-full grid-cols-4">
{primarySections.map((section) => {
const Icon = section.icon;
return (
<TabsTrigger key={section.name} value={section.name}>
<Icon />
{section.label}
</TabsTrigger>
);
})}
</TabsList>
</div>
</Tabs>
</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">
{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 +657,99 @@ 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}
ariaLabel={t('common.icon')}
/>
</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>
@@ -641,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' ||
@@ -0,0 +1,208 @@
import { ReactNode, useState } from 'react';
import { BarChart3, Bug, Settings } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
interface ProcessorMonitoringView {
label: string;
content: ReactNode;
}
export interface ProcessorDetailStatus {
label: string;
description?: string;
tone: 'neutral' | 'success' | 'warning' | 'error';
}
interface ProcessorDetailWorkbenchProps {
title: string;
status?: ProcessorDetailStatus | null;
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,
status,
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">
<div className="flex min-w-0 items-center gap-2">
<h1 className="truncate text-xl font-semibold">{title}</h1>
{status && (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
role="status"
aria-label={status.label}
tabIndex={0}
className={cn(
'rounded-full',
status.tone === 'success' &&
'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
status.tone === 'warning' &&
'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300',
status.tone === 'error' &&
'border-destructive/30 bg-destructive/10 text-destructive',
status.tone === 'neutral' &&
'border-border bg-muted/50 text-muted-foreground',
)}
>
<span
className={cn(
'size-1.5 rounded-full',
status.tone === 'success' && 'bg-emerald-500',
status.tone === 'warning' && 'bg-amber-500',
status.tone === 'error' && 'bg-destructive',
status.tone === 'neutral' &&
'animate-pulse bg-muted-foreground',
)}
/>
{status.label}
</Badge>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-72">
<p className="font-medium">{status.label}</p>
{status.description && (
<p className="mt-1 font-normal opacity-80">
{status.description}
</p>
)}
</TooltipContent>
</Tooltip>
)}
</div>
<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 -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,13 +1,14 @@
import { useState, useEffect } from 'react';
import { useEffect, useRef, useState } 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 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';
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,10 +41,11 @@ 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);
const pipelineFormRef = useRef<PipelineFormHandle>(null);
const pipeline = pipelines.find((item) => item.id === id);
function handleFinish() {
refreshPipelines();
@@ -96,103 +98,65 @@ 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={`${pipeline?.emoji || '⚙️'} ${pipeline?.name || 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
ref={pipelineFormRef}
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}
hasUnsavedChanges={formDirty}
beforeSend={async () => pipelineFormRef.current?.save() ?? false}
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
}
/>
);
}
@@ -40,13 +40,22 @@ import {
Music,
Code,
AlignLeft,
RotateCcw,
} from 'lucide-react';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
interface DebugDialogProps {
open: boolean;
pipelineId: string;
isEmbedded?: boolean;
compact?: boolean;
onConnectionStatusChange?: (isConnected: boolean) => void;
beforeSend?: () => Promise<boolean>;
hasUnsavedChanges?: boolean;
}
function AuthenticatedMessageImage({
@@ -115,7 +124,10 @@ export default function DebugDialog({
open,
pipelineId,
isEmbedded = false,
compact = false,
onConnectionStatusChange,
beforeSend,
hasUnsavedChanges = false,
}: DebugDialogProps) {
const { t } = useTranslation();
const [selectedPipelineId, setSelectedPipelineId] = useState(pipelineId);
@@ -142,43 +154,61 @@ export default function DebugDialog({
new Set(),
);
const [streamOutput, setStreamOutput] = useState(true);
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const wsClientRef = useRef<WebSocketClient | null>(null);
const isInitializingRef = useRef<boolean>(false);
const historyRequestGenerationRef = useRef(0);
const invalidateHistoryRequests = useCallback(() => {
historyRequestGenerationRef.current++;
}, []);
const scrollToBottom = useCallback(() => {
// Use setTimeout to ensure scroll happens after DOM update
setTimeout(() => {
const scrollArea = document.querySelector('.scroll-area') as HTMLElement;
if (scrollArea) {
scrollArea.scrollTo({
top: scrollArea.scrollHeight,
behavior: 'smooth',
});
}
// Also ensure messagesEndRef scrolls into view
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
const viewport = scrollAreaRef.current?.querySelector<HTMLElement>(
'[data-slot="scroll-area-viewport"]',
);
viewport?.scrollTo({
top: viewport.scrollHeight,
behavior: 'smooth',
});
}, 0);
}, []);
const loadMessages = useCallback(
async (pipelineId: string) => {
const generation = ++historyRequestGenerationRef.current;
try {
const response = await httpClient.getWebSocketHistoryMessages(
pipelineId,
sessionType,
);
setMessages(response.messages);
if (generation !== historyRequestGenerationRef.current) return;
setMessages(Array.isArray(response.messages) ? response.messages : []);
} catch (error) {
if (generation !== historyRequestGenerationRef.current) return;
console.error('Failed to load messages:', error);
}
},
[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) => {
@@ -187,24 +217,30 @@ export default function DebugDialog({
return;
}
let wsClient: WebSocketClient | null = null;
let errorReported = false;
try {
isInitializingRef.current = true;
// Disconnect old connection
if (wsClientRef.current) {
wsClientRef.current.disconnect();
wsClientRef.current = null;
}
const previousClient = wsClientRef.current;
wsClientRef.current = null;
previousClient?.disconnect();
// Create new connection
const wsClient = new WebSocketClient(pipelineId, sessionType);
wsClient = new WebSocketClient(pipelineId, sessionType);
// Store the client before awaiting connect so effect cleanup can also
// cancel sockets that are still authenticating.
wsClientRef.current = wsClient;
wsClient
.onConnected(() => {
if (wsClientRef.current !== wsClient) return;
setIsConnected(true);
isInitializingRef.current = false;
})
.onMessage((wsMessage) => {
if (wsClientRef.current !== wsClient) return;
// Convert WebSocketMessage to Message type
const message: Message = {
...wsMessage,
@@ -229,26 +265,32 @@ export default function DebugDialog({
});
})
.onError((error) => {
if (wsClientRef.current !== wsClient) return;
errorReported = true;
console.error('WebSocket error:', error);
setIsConnected(false);
isInitializingRef.current = false;
toast.error(t('pipelines.debugDialog.connectionError'));
})
.onClose(() => {
if (wsClientRef.current !== wsClient) return;
setIsConnected(false);
isInitializingRef.current = false;
})
.onBroadcast((message) => {
if (wsClientRef.current !== wsClient) return;
toast.info(message);
});
await wsClient.connect();
wsClientRef.current = wsClient;
} catch (error) {
if (!wsClient || wsClientRef.current !== wsClient) return;
console.error('WebSocket connection failed:', error);
setIsConnected(false);
isInitializingRef.current = false;
toast.error(t('pipelines.debugDialog.connectionFailed'));
if (!errorReported) {
toast.error(t('pipelines.debugDialog.connectionFailed'));
}
}
},
[sessionType, t],
@@ -264,24 +306,28 @@ export default function DebugDialog({
if (open) {
setSelectedPipelineId(pipelineId);
} else {
invalidateHistoryRequests();
// Disconnect WebSocket immediately when dialog closes
if (wsClientRef.current) {
wsClientRef.current.disconnect();
const wsClient = wsClientRef.current;
wsClientRef.current = null;
wsClient.disconnect();
setIsConnected(false);
isInitializingRef.current = false;
}
}
return () => {
invalidateHistoryRequests();
// Disconnect WebSocket on component unmount
if (wsClientRef.current) {
wsClientRef.current.disconnect();
const wsClient = wsClientRef.current;
wsClientRef.current = null;
wsClient.disconnect();
isInitializingRef.current = false;
}
};
}, [open, pipelineId]);
}, [open, pipelineId, invalidateHistoryRequests]);
// Reload messages and reconnect when sessionType or selectedPipelineId changes
useEffect(() => {
@@ -412,6 +458,9 @@ export default function DebugDialog({
try {
setIsUploading(true);
if (hasUnsavedChanges && beforeSend && !(await beforeSend())) {
return;
}
const messageChain = [];
@@ -805,38 +854,82 @@ 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">
<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>
<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',
)}
>
<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">
<ScrollArea className="flex-1 p-6 overflow-y-auto min-h-0 scroll-area">
<div className="space-y-6">
<ScrollArea
ref={scrollAreaRef}
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 +945,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',
@@ -919,7 +1015,6 @@ export default function DebugDialog({
</div>
))
)}
<div ref={messagesEndRef} />
</div>
</ScrollArea>
@@ -990,7 +1085,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,14 +1169,19 @@ 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')
) : (
<>
<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 {
@@ -8,6 +15,7 @@ import {
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import { getDefaultValues } from '@/app/home/components/dynamic-form/DynamicFormItemConfig';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@@ -31,7 +39,6 @@ import {
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { cn } from '@/lib/utils';
import {
Card,
CardContent,
@@ -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);
@@ -156,7 +175,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>();
@@ -259,7 +287,7 @@ export default function PipelineFormComponent({
function handleFormSubmit(values: FormValues) {
if (isEditMode) {
handleModify(values);
void handleModify(values);
} else {
handleCreate(values);
}
@@ -293,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,
@@ -318,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.
@@ -567,32 +608,49 @@ 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) => {
const Icon = section.icon;
return (
<li key={section.name}>
<button
<nav className="mb-4 shrink-0 space-y-2 border-b pb-4">
<Tabs value={activeSection} onValueChange={setActiveSection}>
<div className="overflow-x-auto">
<TabsList className="grid min-w-[34rem] w-full grid-cols-3">
{primarySections.map((section) => {
const Icon = section.icon;
return (
<TabsTrigger
key={section.name}
value={section.name}
>
<Icon />
{section.label}
</TabsTrigger>
);
})}
</TabsList>
</div>
<div className="flex flex-wrap gap-1">
{secondarySections.map((section) => {
const Icon = section.icon;
return (
<Button
key={section.name}
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',
variant={
activeSection === section.name
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
)}
? 'secondary'
: 'ghost'
}
size="sm"
onClick={() => setActiveSection(section.name)}
>
<Icon className="size-4 shrink-0" />
<Icon />
{section.label}
</button>
</li>
);
})}
</ul>
</Button>
);
})}
</div>
</Tabs>
</nav>
)}
@@ -851,7 +909,9 @@ export default function PipelineFormComponent({
</Dialog>
</>
);
}
});
export default PipelineFormComponent;
interface SectionItem {
label: string;
name: string;
+69 -22
View File
@@ -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;
@@ -36,9 +31,12 @@ export class WebSocketClient {
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private reconnectDelay = 3000; // 3秒重连间隔
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
private heartbeatInterval: NodeJS.Timeout | null = null;
private heartbeatIntervalMs = 30000; // 30秒
private isConnecting = false; // 防止重复连接
private shouldReconnect = true;
private disconnectedByUser = false;
// 事件回调
private onConnectedCallback?: (data: WebSocketResponse) => void;
@@ -59,6 +57,13 @@ export class WebSocketClient {
public connect(): Promise<string> {
return new Promise((resolve, reject) => {
try {
this.disconnectedByUser = false;
this.shouldReconnect = true;
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
// 防止重复连接
if (
this.isConnecting ||
@@ -87,22 +92,27 @@ export class WebSocketClient {
window.location.host;
const url = `${protocol}//${host}/api/v1/pipelines/${this.pipelineId}/ws/connect?session_type=${this.sessionType}`;
this.ws = new WebSocket(url);
const socket = new WebSocket(url);
this.ws = socket;
// 连接打开
this.ws.onopen = () => {
this.reconnectAttempts = 0;
socket.onopen = () => {
if (this.disconnectedByUser || this.ws !== socket) {
socket.close();
return;
}
this.isConnecting = false;
const token = this.token || localStorage.getItem('token');
const workspaceUuid = getActiveWorkspaceUuid();
if (!token || !workspaceUuid) {
const error = new Error('WebSocket认证信息缺失');
this.shouldReconnect = false;
this.onErrorCallback?.(error);
this.ws?.close();
socket.close();
reject(error);
return;
}
this.ws?.send(
socket.send(
JSON.stringify({
type: 'authenticate',
token,
@@ -112,13 +122,23 @@ export class WebSocketClient {
};
// 接收消息
this.ws.onmessage = (event) => {
socket.onmessage = (event) => {
if (this.disconnectedByUser || this.ws !== socket) return;
try {
const data: WebSocketResponse = JSON.parse(event.data);
this.handleMessage(data);
if (data.type === 'error' && !this.connectionId) {
reject(new Error(data.message || 'WebSocket连接失败'));
return;
}
// 第一次连接成功
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);
@@ -130,22 +150,36 @@ export class WebSocketClient {
};
// 连接关闭
this.ws.onclose = () => {
socket.onclose = () => {
if (this.ws === socket) {
this.ws = null;
this.connectionId = null;
}
this.isConnecting = false;
this.stopHeartbeat();
if (this.disconnectedByUser) return;
this.onCloseCallback?.();
// 自动重连
if (this.reconnectAttempts < this.maxReconnectAttempts) {
if (
this.shouldReconnect &&
this.reconnectAttempts < this.maxReconnectAttempts
) {
this.reconnectAttempts++;
setTimeout(() => {
this.reconnectTimeout = setTimeout(() => {
this.reconnectTimeout = null;
if (!this.shouldReconnect || this.disconnectedByUser) return;
this.connect().catch(console.error);
}, this.reconnectDelay * this.reconnectAttempts);
}
};
// 连接错误
this.ws.onerror = (event) => {
socket.onerror = (event) => {
if (this.disconnectedByUser || this.ws !== socket) {
reject(new Error('WebSocket连接已取消'));
return;
}
console.error('WebSocket错误:', event);
this.isConnecting = false;
const error = new Error('WebSocket连接失败');
@@ -210,6 +244,13 @@ export class WebSocketClient {
case 'error':
const error = new Error(data.message || '未知错误');
this.onErrorCallback?.(error);
// Authentication/resource errors happen before the `connected`
// handshake. Retrying them cannot recover and would leak error toasts
// after the user leaves the Pipeline page.
if (!this.connectionId) {
this.shouldReconnect = false;
this.ws?.close();
}
break;
default:
@@ -272,21 +313,27 @@ export class WebSocketClient {
* 断开连接
*/
public disconnect() {
this.disconnectedByUser = true;
this.shouldReconnect = false;
this.reconnectAttempts = this.maxReconnectAttempts;
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
if (this.ws) {
this.stopHeartbeat();
// 停止自动重连
this.reconnectAttempts = this.maxReconnectAttempts;
const socket = this.ws;
// 发送断开消息
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'disconnect' }));
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'disconnect' }));
}
this.ws.close();
this.ws = null;
this.connectionId = null;
this.isConnecting = false;
socket.close();
}
}
+3
View File
@@ -10,6 +10,7 @@ interface EmojiPickerProps {
value?: string;
onChange: (emoji: string) => void;
disabled?: boolean;
ariaLabel?: string;
}
// 扩展的emoji分类
@@ -179,6 +180,7 @@ export default function EmojiPicker({
value,
onChange,
disabled,
ariaLabel,
}: EmojiPickerProps) {
const [open, setOpen] = useState(false);
const [activeCategory, setActiveCategory] = useState<string>('common');
@@ -199,6 +201,7 @@ export default function EmojiPicker({
disabled={disabled}
className="w-16 h-16 text-3xl p-0 hover:bg-gray-100 dark:hover:bg-gray-800"
type="button"
aria-label={ariaLabel}
>
{value || '😀'}
</Button>
+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 -4
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.',
@@ -735,9 +737,6 @@ const enUS = {
debugDescription:
'Run the current Agent with a message or platform event and inspect the real output.',
debugResetSession: 'Reset session',
debugActualRun: 'This is a real run',
debugActualRunDescription:
'The test calls the configured runner, models, and authorized tools, but does not deliver output to a real chat platform.',
debugEventType: 'Event type',
debugMessageReceived: 'Message received',
debugGroupMemberJoined: 'Group member joined',
@@ -752,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: {
@@ -1327,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 -4
View File
@@ -696,15 +696,14 @@ const zhHans = {
selectedRunnerUnavailableDescription:
'{{runner}} 当前未注册。请选择其他运行器,或恢复对应扩展。',
noRunnerSelected: '尚未选择运行器',
runnerConfigIncomplete: '运行器配置待完善',
runnerConfigIncompleteDescription: '请填写必填项:{{fields}}',
runnerReady: '运行器已就绪',
runnerReadyDescription: '{{runner}} 已注册,插件运行时连接正常。',
debugTab: '事件调试',
debugTitle: 'Agent 事件调试',
debugDescription: '用消息或平台事件直接运行当前 Agent,并查看真实输出。',
debugResetSession: '重置会话',
debugActualRun: '这是真实运行',
debugActualRunDescription:
'测试会调用当前运行器、模型与已授权工具,但不会把输出发送到真实聊天平台。',
debugEventType: '事件类型',
debugMessageReceived: '收到消息',
debugGroupMemberJoined: '成员加入群组',
@@ -719,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: {
@@ -1270,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]);
@@ -0,0 +1,215 @@
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 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');
await expect(flow.getByRole('tab').nth(0)).toContainText(
'Basic Information',
);
await expect(flow.getByRole('tab').nth(1)).toContainText(
'Bindable Event Range',
);
await expect(flow.getByRole('tab').nth(2)).toContainText('Runner');
await expect(flow.getByRole('tab').nth(3)).toContainText('Local Agent');
await expect(configPanel.getByLabel('Name')).toBeVisible();
await expect(configPanel.getByLabel('Icon')).toBeVisible();
await expect(configPanel.getByLabel('Description')).toBeVisible();
const runnerStatus = page.getByRole('status', { name: 'Runner ready' });
await expect(runnerStatus).toBeVisible();
await runnerStatus.hover();
await expect(
page.getByText(
'Local Agent is registered and the plugin runtime is connected.',
),
).toBeVisible();
await flow.getByRole('tab').nth(1).click();
await expect(
configPanel.getByText('Bindable Event Range', { exact: true }).last(),
).toBeVisible();
await flow.getByRole('tab').nth(3).click();
await expect(
configPanel.getByText('Local Agent', { exact: true }).last(),
).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');
const debugPanel = page.getByRole('region', { name: 'Debug Chat' });
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();
expect(debugBox).not.toBeNull();
expect(configBox).not.toBeNull();
expect(debugBox!.x).toBeLessThan(configBox!.x);
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');
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(
configPanel.getByText('Runtime', { exact: true }).last(),
).toBeVisible();
});
});
@@ -0,0 +1,86 @@
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',
);
const pipelineDebug = readSource(
'src/app/home/pipelines/components/debug-dialog/DebugDialog.tsx',
);
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/,
);
assert.match(websocketClient, /private reconnectTimeout:/);
assert.match(websocketClient, /private disconnectedByUser = false/);
assert.match(
websocketClient,
/if \(!this\.connectionId\)[\s\S]*this\.shouldReconnect = false/,
);
assert.match(
pipelineDebug,
/wsClientRef\.current = wsClient;[\s\S]*await wsClient\.connect\(\)/,
);
assert.match(
pipelineDebug,
/if \(wsClientRef\.current !== wsClient\) return;/,
);
assert.match(pipelineDebug, /data-slot="scroll-area-viewport"/);
assert.doesNotMatch(pipelineDebug, /scrollIntoView/);
});
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: 'basic'[\s\S]*name: 'events'[\s\S]*name: 'runner'[\s\S]*name: 'runner_config'/,
);
assert.match(
pipelineForm,
/const primarySectionNames = \['trigger', 'ai', 'output'\]/,
);
assert.match(agentForm, /<TabsList[^>]*grid-cols-4/);
assert.match(pipelineForm, /<TabsList[^>]*grid-cols-3/);
assert.doesNotMatch(agentForm, /<ol className=/);
assert.doesNotMatch(pipelineForm, /<ol className=/);
});