mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-26 12:17:14 +00:00
Compare commits
7 Commits
49d0aac210
...
6a6a2b865b
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a6a2b865b | |||
| 781d8a9ac8 | |||
| e3150e66a4 | |||
| ac31f1f006 | |||
| f65cca3f40 | |||
| e70a0d3f01 | |||
| 46aea2b499 |
@@ -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}')
|
||||
|
||||
@@ -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}',
|
||||
)
|
||||
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 Agent’s 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',
|
||||
|
||||
@@ -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: '暂无消息',
|
||||
|
||||
@@ -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=/);
|
||||
});
|
||||
Reference in New Issue
Block a user