mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-26 12:17:14 +00:00
fix(web): make processor debugging reliable
This commit is contained in:
@@ -1,14 +1,17 @@
|
|||||||
"""Agent runner errors."""
|
"""Agent runner errors."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
class AgentRunnerError(Exception):
|
class AgentRunnerError(Exception):
|
||||||
"""Base error for agent runner operations."""
|
"""Base error for agent runner operations."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class RunnerNotFoundError(AgentRunnerError):
|
class RunnerNotFoundError(AgentRunnerError):
|
||||||
"""Runner not found in registry."""
|
"""Runner not found in registry."""
|
||||||
|
|
||||||
def __init__(self, runner_id: str):
|
def __init__(self, runner_id: str):
|
||||||
self.runner_id = runner_id
|
self.runner_id = runner_id
|
||||||
super().__init__(f'Agent runner not found: {runner_id}')
|
super().__init__(f'Agent runner not found: {runner_id}')
|
||||||
@@ -16,6 +19,7 @@ class RunnerNotFoundError(AgentRunnerError):
|
|||||||
|
|
||||||
class RunnerNotAuthorizedError(AgentRunnerError):
|
class RunnerNotAuthorizedError(AgentRunnerError):
|
||||||
"""Runner not authorized for this binding."""
|
"""Runner not authorized for this binding."""
|
||||||
|
|
||||||
def __init__(self, runner_id: str, bound_plugins: list[str] | None):
|
def __init__(self, runner_id: str, bound_plugins: list[str] | None):
|
||||||
self.runner_id = runner_id
|
self.runner_id = runner_id
|
||||||
self.bound_plugins = bound_plugins
|
self.bound_plugins = bound_plugins
|
||||||
@@ -24,6 +28,7 @@ class RunnerNotAuthorizedError(AgentRunnerError):
|
|||||||
|
|
||||||
class RunnerProtocolError(AgentRunnerError):
|
class RunnerProtocolError(AgentRunnerError):
|
||||||
"""Runner protocol version mismatch or invalid manifest."""
|
"""Runner protocol version mismatch or invalid manifest."""
|
||||||
|
|
||||||
def __init__(self, runner_id: str, message: str):
|
def __init__(self, runner_id: str, message: str):
|
||||||
self.runner_id = runner_id
|
self.runner_id = runner_id
|
||||||
super().__init__(f'Agent runner protocol error for {runner_id}: {message}')
|
super().__init__(f'Agent runner protocol error for {runner_id}: {message}')
|
||||||
@@ -31,7 +36,16 @@ class RunnerProtocolError(AgentRunnerError):
|
|||||||
|
|
||||||
class RunnerExecutionError(AgentRunnerError):
|
class RunnerExecutionError(AgentRunnerError):
|
||||||
"""Runner execution failed."""
|
"""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.runner_id = runner_id
|
||||||
|
self.message = message
|
||||||
self.retryable = retryable
|
self.retryable = retryable
|
||||||
|
self.error_code = error_code
|
||||||
super().__init__(f'Agent runner {runner_id} execution failed: {message}')
|
super().__init__(f'Agent runner {runner_id} execution failed: {message}')
|
||||||
|
|||||||
@@ -58,21 +58,21 @@ class AgentRunnerInvoker:
|
|||||||
except asyncio.TimeoutError as e:
|
except asyncio.TimeoutError as e:
|
||||||
raise RunnerExecutionError(
|
raise RunnerExecutionError(
|
||||||
descriptor.id,
|
descriptor.id,
|
||||||
'Runner timed out (code: runner.timeout)',
|
'Runner timed out',
|
||||||
retryable=True,
|
retryable=True,
|
||||||
|
error_code='runner.timeout',
|
||||||
) from e
|
) from e
|
||||||
except ActionCallTimeoutError as e:
|
except ActionCallTimeoutError as e:
|
||||||
raise RunnerExecutionError(
|
raise RunnerExecutionError(
|
||||||
descriptor.id,
|
descriptor.id,
|
||||||
f'{e} (code: runner.timeout)',
|
str(e),
|
||||||
retryable=True,
|
retryable=True,
|
||||||
|
error_code='runner.timeout',
|
||||||
) from e
|
) from e
|
||||||
except RunnerExecutionError:
|
except RunnerExecutionError:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.ap.logger.error(
|
self.ap.logger.error(f'Runner {descriptor.id} unexpected error: {traceback.format_exc()}')
|
||||||
f'Runner {descriptor.id} unexpected error: {traceback.format_exc()}'
|
|
||||||
)
|
|
||||||
raise RunnerExecutionError(
|
raise RunnerExecutionError(
|
||||||
descriptor.id,
|
descriptor.id,
|
||||||
str(e),
|
str(e),
|
||||||
|
|||||||
@@ -152,10 +152,14 @@ class AgentResultNormalizer:
|
|||||||
error_msg = data.get('error', 'Unknown error')
|
error_msg = data.get('error', 'Unknown error')
|
||||||
error_code = data.get('code', 'unknown')
|
error_code = data.get('code', 'unknown')
|
||||||
retryable = data.get('retryable', False)
|
retryable = data.get('retryable', False)
|
||||||
|
normalized_error_code = str(error_code or '').strip()
|
||||||
raise RunnerExecutionError(
|
raise RunnerExecutionError(
|
||||||
descriptor.id,
|
descriptor.id,
|
||||||
f'{error_msg} (code: {error_code})',
|
str(error_msg),
|
||||||
retryable=retryable,
|
retryable=retryable,
|
||||||
|
error_code=(
|
||||||
|
normalized_error_code if normalized_error_code and normalized_error_code != 'unknown' else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
elif result_type == 'action.requested':
|
elif result_type == 'action.requested':
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import quart
|
import quart
|
||||||
|
|
||||||
|
from .....agent.runner.errors import (
|
||||||
|
AgentRunnerError,
|
||||||
|
RunnerExecutionError,
|
||||||
|
RunnerNotAuthorizedError,
|
||||||
|
RunnerNotFoundError,
|
||||||
|
RunnerProtocolError,
|
||||||
|
)
|
||||||
from ...authz import Permission, require_permission
|
from ...authz import Permission, require_permission
|
||||||
from ...context import RequestContext
|
from ...context import RequestContext
|
||||||
from .. import group
|
from .. import group
|
||||||
@@ -63,6 +70,36 @@ class AgentsRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return self.http_status(400, -1, str(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)
|
return self.success(data=result)
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class BanWordFilter(filter_model.ContentFilter):
|
|||||||
return entities.FilterResult(
|
return entities.FilterResult(
|
||||||
level=entities.ResultLevel.BLOCK,
|
level=entities.ResultLevel.BLOCK,
|
||||||
replacement='',
|
replacement='',
|
||||||
user_notice='内容检查规则执行失败,请联系管理员',
|
user_notice='内容安全检查配置有误,请检查敏感词设置',
|
||||||
console_notice=f'Sensitive-word regex rejected: {exc}',
|
console_notice=f'Sensitive-word regex rejected: {exc}',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ from collections.abc import Sequence
|
|||||||
import regex
|
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_PATTERN_CHARS = 1024
|
||||||
MAX_INPUT_CHARS = 1024 * 1024
|
MAX_INPUT_CHARS = 1024 * 1024
|
||||||
MAX_REPLACEMENT_CHARS = 64
|
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)]
|
[message async for message in orchestrator.run_from_query(query)]
|
||||||
|
|
||||||
assert exc_info.value.retryable is True
|
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() == []
|
assert await get_session_registry().list_active_runs() == []
|
||||||
|
|
||||||
|
|
||||||
@@ -1012,6 +1012,7 @@ class TestQueryEntrySessionQueryId:
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
ap = FakeApplication(plugin_connector, db_engine)
|
ap = FakeApplication(plugin_connector, db_engine)
|
||||||
|
|
||||||
async def build_resource_context(execution_query):
|
async def build_resource_context(execution_query):
|
||||||
from langbot.pkg.provider.tools.loaders.mcp import (
|
from langbot.pkg.provider.tools.loaders.mcp import (
|
||||||
_execution_context_from_query,
|
_execution_context_from_query,
|
||||||
@@ -1025,9 +1026,7 @@ class TestQueryEntrySessionQueryId:
|
|||||||
return 'Pinned documentation'
|
return 'Pinned documentation'
|
||||||
|
|
||||||
mcp_loader = types.SimpleNamespace(
|
mcp_loader = types.SimpleNamespace(
|
||||||
build_resource_context_for_query=AsyncMock(
|
build_resource_context_for_query=AsyncMock(side_effect=build_resource_context)
|
||||||
side_effect=build_resource_context
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
ap.tool_mgr = types.SimpleNamespace(mcp_tool_loader=mcp_loader)
|
ap.tool_mgr = types.SimpleNamespace(mcp_tool_loader=mcp_loader)
|
||||||
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
|
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
|
||||||
@@ -1105,14 +1104,8 @@ class TestQueryEntrySessionQueryId:
|
|||||||
assert 'Pinned documentation' in plugin_connector.contexts[0]['input']['contents'][0]['text']
|
assert 'Pinned documentation' in plugin_connector.contexts[0]['input']['contents'][0]['text']
|
||||||
assert event.input.text == 'hello'
|
assert event.input.text == 'hello'
|
||||||
assert event.input.contents[0].text == 'hello'
|
assert event.input.contents[0].text == 'hello'
|
||||||
assert (
|
assert plugin_connector.contexts[0]['conversation']['workspace_id'] == TEST_CONTEXT.workspace_uuid
|
||||||
plugin_connector.contexts[0]['conversation']['workspace_id']
|
assert plugin_connector.contexts[0]['runtime']['metadata']['workspace_id'] == TEST_CONTEXT.workspace_uuid
|
||||||
== 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)
|
assert 'Pinned documentation' not in str(execution_query.user_message.content)
|
||||||
mcp_loader.build_resource_context_for_query.assert_awaited_once_with(execution_query)
|
mcp_loader.build_resource_context_for_query.assert_awaited_once_with(execution_query)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Tests for agent runner result normalizer."""
|
"""Tests for agent runner result normalizer."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -12,6 +13,7 @@ from langbot_plugin.api.entities.builtin.provider import message as provider_mes
|
|||||||
|
|
||||||
class FakeApplication:
|
class FakeApplication:
|
||||||
"""Fake Application for testing."""
|
"""Fake Application for testing."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
class FakeLogger:
|
class FakeLogger:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -19,10 +21,13 @@ class FakeApplication:
|
|||||||
|
|
||||||
def info(self, msg):
|
def info(self, msg):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def debug(self, msg):
|
def debug(self, msg):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def warning(self, msg):
|
def warning(self, msg):
|
||||||
self.warnings.append(msg)
|
self.warnings.append(msg)
|
||||||
|
|
||||||
def error(self, msg):
|
def error(self, msg):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -192,6 +197,7 @@ class TestNormalizeRunFailed:
|
|||||||
|
|
||||||
assert exc_info.value.runner_id == 'plugin:langbot-team/LocalAgent/default'
|
assert exc_info.value.runner_id == 'plugin:langbot-team/LocalAgent/default'
|
||||||
assert exc_info.value.retryable is True
|
assert exc_info.value.retryable is True
|
||||||
|
assert exc_info.value.error_code == 'upstream.timeout'
|
||||||
assert 'timeout' in str(exc_info.value)
|
assert 'timeout' in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
@@ -290,6 +296,7 @@ class TestNormalizeNonMessageResults:
|
|||||||
assert result is None
|
assert result is None
|
||||||
assert app.logger.warnings
|
assert app.logger.warnings
|
||||||
|
|
||||||
|
|
||||||
class TestNormalizeInvalidResults:
|
class TestNormalizeInvalidResults:
|
||||||
"""Tests for handling invalid results."""
|
"""Tests for handling invalid results."""
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from unittest.mock import ANY, AsyncMock
|
|||||||
import pytest
|
import pytest
|
||||||
import quart
|
import quart
|
||||||
|
|
||||||
|
from langbot.pkg.agent.runner.errors import RunnerExecutionError
|
||||||
|
|
||||||
core_app_module = types.ModuleType('langbot.pkg.core.app')
|
core_app_module = types.ModuleType('langbot.pkg.core.app')
|
||||||
core_app_module.Application = object
|
core_app_module.Application = object
|
||||||
sys.modules.setdefault('langbot.pkg.core.app', core_app_module)
|
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,
|
'code': -1,
|
||||||
'msg': 'Invalid event_type',
|
'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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import threading
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_mask_patterns_bounds_replacement_growth_and_masks_matches():
|
async def test_mask_patterns_bounds_replacement_growth_and_masks_matches():
|
||||||
found, masked = await safe_regex.mask_patterns(
|
found, masked = await safe_regex.mask_patterns(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
@@ -10,6 +10,7 @@ import PipelineDetailContent from '@/app/home/pipelines/PipelineDetailContent';
|
|||||||
import AgentCreateContent from './components/AgentCreateContent';
|
import AgentCreateContent from './components/AgentCreateContent';
|
||||||
import AgentDebugPanel from './components/AgentDebugPanel';
|
import AgentDebugPanel from './components/AgentDebugPanel';
|
||||||
import AgentFormComponent, {
|
import AgentFormComponent, {
|
||||||
|
AgentFormHandle,
|
||||||
AgentRunnerStatus,
|
AgentRunnerStatus,
|
||||||
} from './components/AgentFormComponent';
|
} from './components/AgentFormComponent';
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
|||||||
const [runnerStatus, setRunnerStatus] = useState<AgentRunnerStatus | null>(
|
const [runnerStatus, setRunnerStatus] = useState<AgentRunnerStatus | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const agentFormRef = useRef<AgentFormHandle>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCreateMode) {
|
if (isCreateMode) {
|
||||||
@@ -89,7 +91,7 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
|||||||
return (
|
return (
|
||||||
<ProcessorDetailWorkbench
|
<ProcessorDetailWorkbench
|
||||||
key={id}
|
key={id}
|
||||||
title={t('agents.editAgent')}
|
title={`${agent.emoji || '🤖'} ${agent.name}`}
|
||||||
status={runnerStatus}
|
status={runnerStatus}
|
||||||
saveLabel={t('common.save')}
|
saveLabel={t('common.save')}
|
||||||
saveFormId="agent-form"
|
saveFormId="agent-form"
|
||||||
@@ -100,8 +102,14 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
|||||||
configContent={
|
configContent={
|
||||||
<fieldset className="contents" disabled={!canManage}>
|
<fieldset className="contents" disabled={!canManage}>
|
||||||
<AgentFormComponent
|
<AgentFormComponent
|
||||||
|
ref={agentFormRef}
|
||||||
agentId={id}
|
agentId={id}
|
||||||
onFinish={() => {
|
onFinish={(updatedAgent) => {
|
||||||
|
if (updatedAgent) {
|
||||||
|
setAgent((current) =>
|
||||||
|
current ? { ...current, ...updatedAgent } : current,
|
||||||
|
);
|
||||||
|
}
|
||||||
refreshPipelines();
|
refreshPipelines();
|
||||||
}}
|
}}
|
||||||
onDeleted={() => {
|
onDeleted={() => {
|
||||||
@@ -119,6 +127,11 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
|||||||
canOperate ? (
|
canOperate ? (
|
||||||
<AgentDebugPanel
|
<AgentDebugPanel
|
||||||
agentId={id}
|
agentId={id}
|
||||||
|
hasUnsavedChanges={formDirty}
|
||||||
|
beforeRun={async () => agentFormRef.current?.save() ?? false}
|
||||||
|
onOpenRunnerConfig={() =>
|
||||||
|
agentFormRef.current?.openSection('runner_config')
|
||||||
|
}
|
||||||
supportedEventPatterns={
|
supportedEventPatterns={
|
||||||
agent.supported_event_patterns ??
|
agent.supported_event_patterns ??
|
||||||
agent.capability?.supported_event_patterns ?? ['*']
|
agent.capability?.supported_event_patterns ?? ['*']
|
||||||
|
|||||||
@@ -51,9 +51,12 @@ export default function AgentCreateContent({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function handleKindChange(nextKind: AgentKind) {
|
function handleKindChange(nextKind: AgentKind) {
|
||||||
|
const previousDefaultEmoji = kind === 'pipeline' ? '⚙️' : '🤖';
|
||||||
|
const nextDefaultEmoji = nextKind === 'pipeline' ? '⚙️' : '🤖';
|
||||||
setKind(nextKind);
|
setKind(nextKind);
|
||||||
if (!form.getValues('emoji')) {
|
const currentEmoji = form.getValues('emoji');
|
||||||
form.setValue('emoji', nextKind === 'pipeline' ? '⚙️' : '🤖');
|
if (!currentEmoji || currentEmoji === previousDefaultEmoji) {
|
||||||
|
form.setValue('emoji', nextDefaultEmoji);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { LoaderCircle, Play, RotateCcw } from 'lucide-react';
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
ChevronDown,
|
||||||
|
CircleHelp,
|
||||||
|
LoaderCircle,
|
||||||
|
Play,
|
||||||
|
RotateCcw,
|
||||||
|
} from 'lucide-react';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -15,10 +22,19 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from '@/components/ui/collapsible';
|
||||||
|
|
||||||
interface AgentDebugPanelProps {
|
interface AgentDebugPanelProps {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
supportedEventPatterns?: string[];
|
supportedEventPatterns?: string[];
|
||||||
|
beforeRun?: () => Promise<boolean>;
|
||||||
|
hasUnsavedChanges?: boolean;
|
||||||
|
onOpenRunnerConfig?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DebugEntry {
|
interface DebugEntry {
|
||||||
@@ -26,6 +42,8 @@ interface DebugEntry {
|
|||||||
direction: 'input' | 'output' | 'error';
|
direction: 'input' | 'output' | 'error';
|
||||||
eventType: string;
|
eventType: string;
|
||||||
text: string;
|
text: string;
|
||||||
|
errorCode?: string;
|
||||||
|
detail?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EVENT_PRESETS = [
|
const EVENT_PRESETS = [
|
||||||
@@ -87,9 +105,17 @@ function createDebugSessionId(agentId: string) {
|
|||||||
return `webui:${agentId}:${nonce}`;
|
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({
|
export default function AgentDebugPanel({
|
||||||
agentId,
|
agentId,
|
||||||
supportedEventPatterns = ['*'],
|
supportedEventPatterns = ['*'],
|
||||||
|
beforeRun,
|
||||||
|
hasUnsavedChanges = false,
|
||||||
|
onOpenRunnerConfig,
|
||||||
}: AgentDebugPanelProps) {
|
}: AgentDebugPanelProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [preset, setPreset] = useState('message.received');
|
const [preset, setPreset] = useState('message.received');
|
||||||
@@ -106,6 +132,22 @@ export default function AgentDebugPanel({
|
|||||||
() => supportedEventPatterns.join(', '),
|
() => supportedEventPatterns.join(', '),
|
||||||
[supportedEventPatterns],
|
[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) {
|
function selectPreset(value: string) {
|
||||||
setPreset(value);
|
setPreset(value);
|
||||||
@@ -129,6 +171,14 @@ export default function AgentDebugPanel({
|
|||||||
toast.error(t('agents.debugInputRequired'));
|
toast.error(t('agents.debugInputRequired'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
!supportedEventPatterns.some((pattern) =>
|
||||||
|
matchesEventPattern(pattern, eventType),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
toast.error(t('agents.debugUnsupportedEvent'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let eventData: Record<string, unknown>;
|
let eventData: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
@@ -142,6 +192,12 @@ export default function AgentDebugPanel({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setRunning(true);
|
||||||
|
if (hasUnsavedChanges && beforeRun && !(await beforeRun())) {
|
||||||
|
setRunning(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const requestId = globalThis.crypto?.randomUUID?.() ?? String(Date.now());
|
const requestId = globalThis.crypto?.randomUUID?.() ?? String(Date.now());
|
||||||
setEntries((current) => [
|
setEntries((current) => [
|
||||||
...current,
|
...current,
|
||||||
@@ -152,7 +208,6 @@ export default function AgentDebugPanel({
|
|||||||
text: inputText.trim() || JSON.stringify(eventData, null, 2),
|
text: inputText.trim() || JSON.stringify(eventData, null, 2),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
setRunning(true);
|
|
||||||
try {
|
try {
|
||||||
const result = await httpClient.debugAgent(agentId, {
|
const result = await httpClient.debugAgent(agentId, {
|
||||||
event_type: eventType,
|
event_type: eventType,
|
||||||
@@ -171,17 +226,41 @@ export default function AgentDebugPanel({
|
|||||||
]);
|
]);
|
||||||
if (isMessageEvent) setInputText('');
|
if (isMessageEvent) setInputText('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const errorCode =
|
||||||
|
typeof error === 'object' && error && 'code' in error
|
||||||
|
? String((error as { code?: string }).code || '')
|
||||||
|
: '';
|
||||||
const message =
|
const message =
|
||||||
typeof error === 'object' && error && 'msg' in error
|
typeof error === 'object' && error && 'msg' in error
|
||||||
? String((error as { msg?: string }).msg || '')
|
? String((error as { msg?: string }).msg || '')
|
||||||
: t('agents.debugRunFailed');
|
: 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) => [
|
setEntries((current) => [
|
||||||
...current,
|
...current,
|
||||||
{
|
{
|
||||||
id: `error:${requestId}`,
|
id: `error:${requestId}`,
|
||||||
direction: 'error',
|
direction: 'error',
|
||||||
eventType,
|
eventType,
|
||||||
text: message || t('agents.debugRunFailed'),
|
text: friendlyMessage,
|
||||||
|
errorCode,
|
||||||
|
detail:
|
||||||
|
isExecutionError || isTimeout
|
||||||
|
? message || t('agents.debugRunFailed')
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -200,7 +279,7 @@ export default function AgentDebugPanel({
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{EVENT_PRESETS.map((item) => (
|
{availablePresets.map((item) => (
|
||||||
<SelectItem key={item.value} value={item.value}>
|
<SelectItem key={item.value} value={item.value}>
|
||||||
{t(item.labelKey)}
|
{t(item.labelKey)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -242,22 +321,30 @@ export default function AgentDebugPanel({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{entries.length === 0 ? (
|
{entries.length === 0 ? (
|
||||||
<div className="flex min-h-48 items-center justify-center rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
<Alert className="my-4 bg-muted/20">
|
||||||
{t('agents.debugEmptyTranscript')}
|
<CircleHelp className="size-4" />
|
||||||
</div>
|
<AlertTitle>{t('agents.debugEmptyTitle')}</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{t('agents.debugEmptyTranscript')}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{entries.map((entry) => (
|
{entries.map((entry) => (
|
||||||
<div
|
<Alert
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
className={`rounded-lg border p-3 ${
|
variant={
|
||||||
|
entry.direction === 'error' ? 'destructive' : 'default'
|
||||||
|
}
|
||||||
|
className={
|
||||||
entry.direction === 'output'
|
entry.direction === 'output'
|
||||||
? 'border-primary/20 bg-primary/5'
|
? 'border-primary/20 bg-primary/5'
|
||||||
: entry.direction === 'error'
|
: entry.direction === 'input'
|
||||||
? 'border-destructive/30 bg-destructive/5'
|
? 'bg-muted/40'
|
||||||
: 'bg-muted/40'
|
: undefined
|
||||||
}`}
|
}
|
||||||
>
|
>
|
||||||
|
{entry.direction === 'error' && <AlertCircle />}
|
||||||
<div className="mb-2 flex items-center justify-between gap-2">
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
<Badge variant="outline">{entry.eventType}</Badge>
|
<Badge variant="outline">{entry.eventType}</Badge>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
@@ -271,7 +358,36 @@ export default function AgentDebugPanel({
|
|||||||
<pre className="min-w-0 whitespace-pre-wrap break-words font-sans text-sm leading-relaxed">
|
<pre className="min-w-0 whitespace-pre-wrap break-words font-sans text-sm leading-relaxed">
|
||||||
{entry.text}
|
{entry.text}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
{entry.detail && (
|
||||||
|
<Collapsible className="mt-3">
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button type="button" variant="ghost" size="sm">
|
||||||
|
{t('agents.debugErrorDetails')}
|
||||||
|
<ChevronDown className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<pre className="mt-2 max-h-32 overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted p-2 font-mono text-xs text-muted-foreground">
|
||||||
|
{entry.detail}
|
||||||
|
</pre>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
)}
|
||||||
|
{(entry.errorCode?.endsWith('.config_invalid') ||
|
||||||
|
entry.errorCode === 'runner_execution_failed' ||
|
||||||
|
entry.errorCode === 'runner.timeout') &&
|
||||||
|
onOpenRunnerConfig && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-3"
|
||||||
|
onClick={onOpenRunnerConfig}
|
||||||
|
>
|
||||||
|
{t('agents.debugReviewRunnerConfig')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -322,7 +438,11 @@ export default function AgentDebugPanel({
|
|||||||
) : (
|
) : (
|
||||||
<Play className="size-4" />
|
<Play className="size-4" />
|
||||||
)}
|
)}
|
||||||
{running ? t('agents.debugRunning') : t('agents.debugRun')}
|
{running
|
||||||
|
? t('agents.debugRunning')
|
||||||
|
: hasUnsavedChanges
|
||||||
|
? t('agents.debugSaveAndRun')
|
||||||
|
: t('agents.debugRun')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import {
|
||||||
|
forwardRef,
|
||||||
|
type ForwardedRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -51,23 +60,62 @@ export interface AgentRunnerStatus {
|
|||||||
|
|
||||||
interface AgentFormComponentProps {
|
interface AgentFormComponentProps {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
onFinish: () => void;
|
onFinish: (agent?: Partial<Agent>) => void;
|
||||||
onDeleted: () => void;
|
onDeleted: () => void;
|
||||||
onDirtyChange?: (dirty: boolean) => void;
|
onDirtyChange?: (dirty: boolean) => void;
|
||||||
onSavingChange?: (saving: boolean) => void;
|
onSavingChange?: (saving: boolean) => void;
|
||||||
onRunnerStatusChange?: (status: AgentRunnerStatus) => void;
|
onRunnerStatusChange?: (status: AgentRunnerStatus) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentConfigSection = 'events' | 'runner' | 'runner_config' | 'basic';
|
export type AgentConfigSection =
|
||||||
|
'events' | 'runner' | 'runner_config' | 'basic';
|
||||||
|
|
||||||
export default function AgentFormComponent({
|
export interface AgentFormHandle {
|
||||||
agentId,
|
openSection: (section: AgentConfigSection) => void;
|
||||||
onFinish,
|
save: () => Promise<boolean>;
|
||||||
onDeleted,
|
}
|
||||||
onDirtyChange,
|
|
||||||
onSavingChange,
|
function isRequiredRunnerValueMissing(value: unknown): boolean {
|
||||||
onRunnerStatusChange,
|
if (value === null || value === undefined) return true;
|
||||||
}: AgentFormComponentProps) {
|
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 { t } = useTranslation();
|
||||||
const [runnerConfigSchema, setRunnerConfigSchema] =
|
const [runnerConfigSchema, setRunnerConfigSchema] =
|
||||||
useState<PipelineConfigTab | null>(null);
|
useState<PipelineConfigTab | null>(null);
|
||||||
@@ -80,6 +128,7 @@ export default function AgentFormComponent({
|
|||||||
const [activeSection, setActiveSection] =
|
const [activeSection, setActiveSection] =
|
||||||
useState<AgentConfigSection>('basic');
|
useState<AgentConfigSection>('basic');
|
||||||
const isSavingRef = useRef(false);
|
const isSavingRef = useRef(false);
|
||||||
|
const hasUnsavedChangesRef = useRef(false);
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
basic: z.object({
|
basic: z.object({
|
||||||
@@ -116,6 +165,7 @@ export default function AgentFormComponent({
|
|||||||
if (!savedSnapshotRef.current) return false;
|
if (!savedSnapshotRef.current) return false;
|
||||||
return JSON.stringify(watchedValues) !== savedSnapshotRef.current;
|
return JSON.stringify(watchedValues) !== savedSnapshotRef.current;
|
||||||
})();
|
})();
|
||||||
|
hasUnsavedChangesRef.current = hasUnsavedChanges;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onDirtyChange?.(hasUnsavedChanges);
|
onDirtyChange?.(hasUnsavedChanges);
|
||||||
@@ -191,6 +241,24 @@ export default function AgentFormComponent({
|
|||||||
const activeRunnerStage = runnerConfigSchema?.stages.find(
|
const activeRunnerStage = runnerConfigSchema?.stages.find(
|
||||||
(stage) => stage.name === currentRunner,
|
(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<{
|
const primarySections: Array<{
|
||||||
name: AgentConfigSection;
|
name: AgentConfigSection;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -270,6 +338,18 @@ export default function AgentFormComponent({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (missingRunnerFields.length > 0) {
|
||||||
|
return {
|
||||||
|
label: t('agents.runnerConfigIncomplete'),
|
||||||
|
description: t('agents.runnerConfigIncompleteDescription', {
|
||||||
|
fields: missingRunnerFields
|
||||||
|
.map((field) => extractI18nObject(field.label))
|
||||||
|
.join(', '),
|
||||||
|
}),
|
||||||
|
tone: 'warning',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: t('agents.runnerReady'),
|
label: t('agents.runnerReady'),
|
||||||
description: t('agents.runnerReadyDescription', {
|
description: t('agents.runnerReadyDescription', {
|
||||||
@@ -283,6 +363,7 @@ export default function AgentFormComponent({
|
|||||||
pluginStatusLoading,
|
pluginStatusLoading,
|
||||||
pluginSystemStatus,
|
pluginSystemStatus,
|
||||||
runnerOptions.length,
|
runnerOptions.length,
|
||||||
|
missingRunnerFields,
|
||||||
selectedRunnerOption,
|
selectedRunnerOption,
|
||||||
t,
|
t,
|
||||||
]);
|
]);
|
||||||
@@ -369,45 +450,70 @@ export default function AgentFormComponent({
|
|||||||
return patterns.length > 0 ? patterns : ['*'];
|
return patterns.length > 0 ? patterns : ['*'];
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit(values: FormValues) {
|
const saveValues = useCallback(
|
||||||
if (isSavingRef.current) return;
|
async (values: FormValues) => {
|
||||||
const submittedSnapshot = JSON.stringify(values);
|
if (isSavingRef.current) return false;
|
||||||
const runner = values.runner || {};
|
const submittedSnapshot = JSON.stringify(values);
|
||||||
const agent: Partial<Agent> = {
|
const runner = values.runner || {};
|
||||||
name: values.basic.name,
|
const agent: Partial<Agent> = {
|
||||||
description: values.basic.description ?? '',
|
name: values.basic.name,
|
||||||
emoji: values.basic.emoji,
|
description: values.basic.description ?? '',
|
||||||
enabled: values.basic.enabled ?? true,
|
emoji: values.basic.emoji,
|
||||||
component_ref: (runner.id as string) || null,
|
enabled: values.basic.enabled ?? true,
|
||||||
supported_event_patterns: normalizeEventPatterns(
|
component_ref: (runner.id as string) || null,
|
||||||
values.supported_event_patterns_text,
|
supported_event_patterns: normalizeEventPatterns(
|
||||||
),
|
values.supported_event_patterns_text,
|
||||||
config: {
|
),
|
||||||
runner,
|
config: {
|
||||||
runner_config: values.runner_config ?? {},
|
runner,
|
||||||
},
|
runner_config: values.runner_config ?? {},
|
||||||
};
|
},
|
||||||
|
};
|
||||||
|
|
||||||
isSavingRef.current = true;
|
isSavingRef.current = true;
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
onSavingChange?.(true);
|
onSavingChange?.(true);
|
||||||
httpClient
|
try {
|
||||||
.updateAgent(agentId, agent)
|
await httpClient.updateAgent(agentId, agent);
|
||||||
.then(() => {
|
|
||||||
savedSnapshotRef.current = submittedSnapshot;
|
savedSnapshotRef.current = submittedSnapshot;
|
||||||
onFinish();
|
onFinish(agent);
|
||||||
toast.success(t('agents.saveSuccess'));
|
toast.success(t('agents.saveSuccess'));
|
||||||
})
|
return true;
|
||||||
.catch((err) => {
|
} catch (err) {
|
||||||
toast.error(t('agents.saveError') + err.msg);
|
const message =
|
||||||
})
|
typeof err === 'object' && err && 'msg' in err
|
||||||
.finally(() => {
|
? String((err as { msg?: string }).msg || '')
|
||||||
|
: '';
|
||||||
|
toast.error(t('agents.saveError') + message);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
isSavingRef.current = false;
|
isSavingRef.current = false;
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
onSavingChange?.(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() {
|
function confirmDelete() {
|
||||||
httpClient
|
httpClient
|
||||||
.deleteAgent(agentId)
|
.deleteAgent(agentId)
|
||||||
@@ -672,3 +778,5 @@ export default function AgentFormComponent({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default forwardRef(AgentFormComponent);
|
||||||
|
|||||||
@@ -1752,6 +1752,17 @@ function findSidebarChildForPath(pathname: string): SidebarChildVO | undefined {
|
|||||||
);
|
);
|
||||||
if (matchedChild) return matchedChild;
|
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 (
|
if (
|
||||||
pathname === '/home/mcp' ||
|
pathname === '/home/mcp' ||
|
||||||
pathname === '/home/skills' ||
|
pathname === '/home/skills' ||
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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
|
<div
|
||||||
className={`mx-auto h-full w-full min-w-0 ${HOME_CONTENT_MAX_WIDTH}`}
|
className={`mx-auto h-full w-full min-w-0 ${HOME_CONTENT_MAX_WIDTH}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Button } from '@/components/ui/button';
|
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 DebugDialog from '@/app/home/pipelines/components/debug-dialog/DebugDialog';
|
||||||
import PipelineMonitoringTab from '@/app/home/pipelines/components/monitoring-tab/PipelineMonitoringTab';
|
import PipelineMonitoringTab from '@/app/home/pipelines/components/monitoring-tab/PipelineMonitoringTab';
|
||||||
import ProcessorDetailWorkbench from '@/app/home/components/processor-detail/ProcessorDetailWorkbench';
|
import ProcessorDetailWorkbench from '@/app/home/components/processor-detail/ProcessorDetailWorkbench';
|
||||||
@@ -42,6 +44,8 @@ export default function PipelineDetailContent({
|
|||||||
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
|
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
|
||||||
const [formDirty, setFormDirty] = useState(false);
|
const [formDirty, setFormDirty] = useState(false);
|
||||||
const [formSaving, setFormSaving] = useState(false);
|
const [formSaving, setFormSaving] = useState(false);
|
||||||
|
const pipelineFormRef = useRef<PipelineFormHandle>(null);
|
||||||
|
const pipeline = pipelines.find((item) => item.id === id);
|
||||||
|
|
||||||
function handleFinish() {
|
function handleFinish() {
|
||||||
refreshPipelines();
|
refreshPipelines();
|
||||||
@@ -96,7 +100,7 @@ export default function PipelineDetailContent({
|
|||||||
return (
|
return (
|
||||||
<ProcessorDetailWorkbench
|
<ProcessorDetailWorkbench
|
||||||
key={id}
|
key={id}
|
||||||
title={t('pipelines.editPipeline')}
|
title={`${pipeline?.emoji || '⚙️'} ${pipeline?.name || t('pipelines.editPipeline')}`}
|
||||||
saveLabel={t('common.save')}
|
saveLabel={t('common.save')}
|
||||||
saveFormId="pipeline-form"
|
saveFormId="pipeline-form"
|
||||||
canSave={canManage}
|
canSave={canManage}
|
||||||
@@ -106,6 +110,7 @@ export default function PipelineDetailContent({
|
|||||||
configContent={
|
configContent={
|
||||||
<fieldset className="contents" disabled={!canManage}>
|
<fieldset className="contents" disabled={!canManage}>
|
||||||
<PipelineFormComponent
|
<PipelineFormComponent
|
||||||
|
ref={pipelineFormRef}
|
||||||
pipelineId={id}
|
pipelineId={id}
|
||||||
isEditMode={true}
|
isEditMode={true}
|
||||||
disableForm={!canManage}
|
disableForm={!canManage}
|
||||||
@@ -130,6 +135,8 @@ export default function PipelineDetailContent({
|
|||||||
pipelineId={id}
|
pipelineId={id}
|
||||||
isEmbedded={true}
|
isEmbedded={true}
|
||||||
compact={true}
|
compact={true}
|
||||||
|
hasUnsavedChanges={formDirty}
|
||||||
|
beforeSend={async () => pipelineFormRef.current?.save() ?? false}
|
||||||
onConnectionStatusChange={setIsWebSocketConnected}
|
onConnectionStatusChange={setIsWebSocketConnected}
|
||||||
/>
|
/>
|
||||||
) : undefined
|
) : undefined
|
||||||
|
|||||||
@@ -40,7 +40,13 @@ import {
|
|||||||
Music,
|
Music,
|
||||||
Code,
|
Code,
|
||||||
AlignLeft,
|
AlignLeft,
|
||||||
|
RotateCcw,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip';
|
||||||
|
|
||||||
interface DebugDialogProps {
|
interface DebugDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -48,6 +54,8 @@ interface DebugDialogProps {
|
|||||||
isEmbedded?: boolean;
|
isEmbedded?: boolean;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
onConnectionStatusChange?: (isConnected: boolean) => void;
|
onConnectionStatusChange?: (isConnected: boolean) => void;
|
||||||
|
beforeSend?: () => Promise<boolean>;
|
||||||
|
hasUnsavedChanges?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AuthenticatedMessageImage({
|
function AuthenticatedMessageImage({
|
||||||
@@ -118,6 +126,8 @@ export default function DebugDialog({
|
|||||||
isEmbedded = false,
|
isEmbedded = false,
|
||||||
compact = false,
|
compact = false,
|
||||||
onConnectionStatusChange,
|
onConnectionStatusChange,
|
||||||
|
beforeSend,
|
||||||
|
hasUnsavedChanges = false,
|
||||||
}: DebugDialogProps) {
|
}: DebugDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [selectedPipelineId, setSelectedPipelineId] = useState(pipelineId);
|
const [selectedPipelineId, setSelectedPipelineId] = useState(pipelineId);
|
||||||
@@ -177,7 +187,7 @@ export default function DebugDialog({
|
|||||||
sessionType,
|
sessionType,
|
||||||
);
|
);
|
||||||
if (generation !== historyRequestGenerationRef.current) return;
|
if (generation !== historyRequestGenerationRef.current) return;
|
||||||
setMessages(response.messages);
|
setMessages(Array.isArray(response.messages) ? response.messages : []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (generation !== historyRequestGenerationRef.current) return;
|
if (generation !== historyRequestGenerationRef.current) return;
|
||||||
console.error('Failed to load messages:', error);
|
console.error('Failed to load messages:', error);
|
||||||
@@ -186,6 +196,19 @@ export default function DebugDialog({
|
|||||||
[sessionType],
|
[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
|
// Initialize WebSocket connection
|
||||||
const initWebSocket = useCallback(
|
const initWebSocket = useCallback(
|
||||||
async (pipelineId: string) => {
|
async (pipelineId: string) => {
|
||||||
@@ -435,6 +458,9 @@ export default function DebugDialog({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
|
if (hasUnsavedChanges && beforeSend && !(await beforeSend())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const messageChain = [];
|
const messageChain = [];
|
||||||
|
|
||||||
@@ -834,32 +860,65 @@ export default function DebugDialog({
|
|||||||
compact && 'w-12 p-1.5 pl-1',
|
compact && 'w-12 p-1.5 pl-1',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Button
|
<Tooltip>
|
||||||
variant="ghost"
|
<TooltipTrigger asChild>
|
||||||
size="icon"
|
<Button
|
||||||
className={cn(
|
variant="ghost"
|
||||||
'w-10 h-10 justify-center rounded-md transition-none border-0 shadow-none',
|
size="icon"
|
||||||
sessionType === 'person'
|
aria-label={t('pipelines.debugDialog.privateChat')}
|
||||||
? 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground'
|
className={cn(
|
||||||
: 'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
'w-10 h-10 justify-center rounded-md transition-none border-0 shadow-none',
|
||||||
)}
|
sessionType === 'person'
|
||||||
onClick={() => setSessionType('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',
|
||||||
<User className="size-5" />
|
)}
|
||||||
</Button>
|
onClick={() => setSessionType('person')}
|
||||||
<Button
|
>
|
||||||
variant="ghost"
|
<User className="size-5" />
|
||||||
size="icon"
|
</Button>
|
||||||
className={cn(
|
</TooltipTrigger>
|
||||||
'w-10 h-10 justify-center rounded-md transition-none border-0 shadow-none',
|
<TooltipContent side="right">
|
||||||
sessionType === 'group'
|
{t('pipelines.debugDialog.privateChat')}
|
||||||
? 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground'
|
</TooltipContent>
|
||||||
: 'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
</Tooltip>
|
||||||
)}
|
<Tooltip>
|
||||||
onClick={() => setSessionType('group')}
|
<TooltipTrigger asChild>
|
||||||
>
|
<Button
|
||||||
<Users className="size-5" />
|
variant="ghost"
|
||||||
</Button>
|
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>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col w-[10rem] h-full min-h-0">
|
<div className="flex-1 flex flex-col w-[10rem] h-full min-h-0">
|
||||||
@@ -1120,7 +1179,9 @@ export default function DebugDialog({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Send className="size-4" />
|
<Send className="size-4" />
|
||||||
{t('pipelines.debugDialog.send')}
|
{hasUnsavedChanges
|
||||||
|
? t('pipelines.debugDialog.saveAndSend')
|
||||||
|
: t('pipelines.debugDialog.send')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</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 { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { GetPipelineResponseData, Pipeline } from '@/app/infra/entities/api';
|
import { GetPipelineResponseData, Pipeline } from '@/app/infra/entities/api';
|
||||||
import {
|
import {
|
||||||
@@ -51,17 +58,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import PipelineExtension from '@/app/home/pipelines/components/pipeline-extensions/PipelineExtension';
|
import PipelineExtension from '@/app/home/pipelines/components/pipeline-extensions/PipelineExtension';
|
||||||
|
|
||||||
export default function PipelineFormComponent({
|
interface PipelineFormComponentProps {
|
||||||
onFinish,
|
|
||||||
onNewPipelineCreated,
|
|
||||||
isEditMode,
|
|
||||||
pipelineId,
|
|
||||||
showButtons = true,
|
|
||||||
onDeletePipeline,
|
|
||||||
onCancel,
|
|
||||||
onDirtyChange,
|
|
||||||
onSavingChange,
|
|
||||||
}: {
|
|
||||||
pipelineId?: string;
|
pipelineId?: string;
|
||||||
isEditMode: boolean;
|
isEditMode: boolean;
|
||||||
disableForm: boolean;
|
disableForm: boolean;
|
||||||
@@ -72,7 +69,29 @@ export default function PipelineFormComponent({
|
|||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
onDirtyChange?: (dirty: boolean) => void;
|
onDirtyChange?: (dirty: boolean) => void;
|
||||||
onSavingChange?: (saving: 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 { t } = useTranslation();
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [showCopyConfirm, setShowCopyConfirm] = useState(false);
|
const [showCopyConfirm, setShowCopyConfirm] = useState(false);
|
||||||
@@ -268,7 +287,7 @@ export default function PipelineFormComponent({
|
|||||||
|
|
||||||
function handleFormSubmit(values: FormValues) {
|
function handleFormSubmit(values: FormValues) {
|
||||||
if (isEditMode) {
|
if (isEditMode) {
|
||||||
handleModify(values);
|
void handleModify(values);
|
||||||
} else {
|
} else {
|
||||||
handleCreate(values);
|
handleCreate(values);
|
||||||
}
|
}
|
||||||
@@ -302,8 +321,8 @@ export default function PipelineFormComponent({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleModify(values: FormValues) {
|
async function handleModify(values: FormValues): Promise<boolean> {
|
||||||
if (isSavingRef.current) return;
|
if (isSavingRef.current) return false;
|
||||||
const submittedSnapshot = JSON.stringify(values);
|
const submittedSnapshot = JSON.stringify(values);
|
||||||
const realConfig = {
|
const realConfig = {
|
||||||
ai: values.ai,
|
ai: values.ai,
|
||||||
@@ -327,23 +346,36 @@ export default function PipelineFormComponent({
|
|||||||
isSavingRef.current = true;
|
isSavingRef.current = true;
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
onSavingChange?.(true);
|
onSavingChange?.(true);
|
||||||
httpClient
|
try {
|
||||||
.updatePipeline(pipelineId || '', pipeline)
|
await httpClient.updatePipeline(pipelineId || '', pipeline);
|
||||||
.then(() => {
|
savedSnapshotRef.current = submittedSnapshot;
|
||||||
savedSnapshotRef.current = submittedSnapshot;
|
onFinish();
|
||||||
onFinish();
|
toast.success(t('pipelines.saveSuccess'));
|
||||||
toast.success(t('pipelines.saveSuccess'));
|
return true;
|
||||||
})
|
} catch (err) {
|
||||||
.catch((err) => {
|
const message =
|
||||||
toast.error(t('pipelines.saveError') + err.msg);
|
typeof err === 'object' && err && 'msg' in err
|
||||||
})
|
? String((err as { msg?: string }).msg || '')
|
||||||
.finally(() => {
|
: '';
|
||||||
isSavingRef.current = false;
|
toast.error(t('pipelines.saveError') + message);
|
||||||
setIsSaving(false);
|
return false;
|
||||||
onSavingChange?.(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.
|
// Called from DynamicFormComponent onSubmit callbacks.
|
||||||
// On the first emission for a stage (mount-time default filling), the
|
// On the first emission for a stage (mount-time default filling), the
|
||||||
// snapshot is synchronously re-captured so that hasUnsavedChanges stays false.
|
// snapshot is synchronously re-captured so that hasUnsavedChanges stays false.
|
||||||
@@ -877,7 +909,9 @@ export default function PipelineFormComponent({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
export default PipelineFormComponent;
|
||||||
interface SectionItem {
|
interface SectionItem {
|
||||||
label: string;
|
label: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ function SidebarProvider({
|
|||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -566,7 +566,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
|
|||||||
<main
|
<main
|
||||||
data-slot="sidebar-inset"
|
data-slot="sidebar-inset"
|
||||||
className={cn(
|
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',
|
'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',
|
'dark:md:peer-data-[variant=inset]:border dark:md:peer-data-[variant=inset]:border-sidebar-border',
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -727,6 +727,8 @@ const enUS = {
|
|||||||
selectedRunnerUnavailableDescription:
|
selectedRunnerUnavailableDescription:
|
||||||
'{{runner}} is not currently registered. Select another runner or restore its extension.',
|
'{{runner}} is not currently registered. Select another runner or restore its extension.',
|
||||||
noRunnerSelected: 'No runner selected',
|
noRunnerSelected: 'No runner selected',
|
||||||
|
runnerConfigIncomplete: 'Runner configuration incomplete',
|
||||||
|
runnerConfigIncompleteDescription: 'Complete required fields: {{fields}}',
|
||||||
runnerReady: 'Runner ready',
|
runnerReady: 'Runner ready',
|
||||||
runnerReadyDescription:
|
runnerReadyDescription:
|
||||||
'{{runner}} is registered and the plugin runtime is connected.',
|
'{{runner}} is registered and the plugin runtime is connected.',
|
||||||
@@ -749,18 +751,32 @@ const enUS = {
|
|||||||
debugEventPayload: 'Event payload (JSON)',
|
debugEventPayload: 'Event payload (JSON)',
|
||||||
debugSupportedEvents: 'Agent supports',
|
debugSupportedEvents: 'Agent supports',
|
||||||
debugRun: 'Run test',
|
debugRun: 'Run test',
|
||||||
|
debugSaveAndRun: 'Save and run',
|
||||||
debugRunning: 'Running',
|
debugRunning: 'Running',
|
||||||
debugTranscript: 'Debug transcript',
|
debugTranscript: 'Debug transcript',
|
||||||
debugTranscriptDescription:
|
debugTranscriptDescription:
|
||||||
'Inputs and Agent outputs from the current debug session.',
|
'Inputs and Agent outputs from the current debug session.',
|
||||||
|
debugEmptyTitle: 'Verify how this Agent behaves',
|
||||||
debugEmptyTranscript:
|
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',
|
debugAgentOutput: 'Agent output',
|
||||||
debugTestInput: 'Test input',
|
debugTestInput: 'Test input',
|
||||||
debugNoTextOutput: 'The run completed without textual output.',
|
debugNoTextOutput: 'The run completed without textual output.',
|
||||||
debugEventTypeRequired: 'Enter an event type',
|
debugEventTypeRequired: 'Enter an event type',
|
||||||
debugInputRequired: 'Enter a conversation input',
|
debugInputRequired: 'Enter a conversation input',
|
||||||
debugInvalidPayload: 'The event payload must be a valid JSON object',
|
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',
|
debugRunFailed: 'Agent debug run failed',
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
@@ -1324,6 +1340,7 @@ const enUS = {
|
|||||||
privateChat: 'Private Chat',
|
privateChat: 'Private Chat',
|
||||||
groupChat: 'Group Chat',
|
groupChat: 'Group Chat',
|
||||||
send: 'Send',
|
send: 'Send',
|
||||||
|
saveAndSend: 'Save and send',
|
||||||
reset: 'Reset Conversation',
|
reset: 'Reset Conversation',
|
||||||
inputPlaceholder: 'Send {{type}} message...',
|
inputPlaceholder: 'Send {{type}} message...',
|
||||||
noMessages: 'No messages',
|
noMessages: 'No messages',
|
||||||
|
|||||||
@@ -696,6 +696,8 @@ const zhHans = {
|
|||||||
selectedRunnerUnavailableDescription:
|
selectedRunnerUnavailableDescription:
|
||||||
'{{runner}} 当前未注册。请选择其他运行器,或恢复对应扩展。',
|
'{{runner}} 当前未注册。请选择其他运行器,或恢复对应扩展。',
|
||||||
noRunnerSelected: '尚未选择运行器',
|
noRunnerSelected: '尚未选择运行器',
|
||||||
|
runnerConfigIncomplete: '运行器配置待完善',
|
||||||
|
runnerConfigIncompleteDescription: '请填写必填项:{{fields}}',
|
||||||
runnerReady: '运行器已就绪',
|
runnerReady: '运行器已就绪',
|
||||||
runnerReadyDescription: '{{runner}} 已注册,插件运行时连接正常。',
|
runnerReadyDescription: '{{runner}} 已注册,插件运行时连接正常。',
|
||||||
debugTab: '事件调试',
|
debugTab: '事件调试',
|
||||||
@@ -716,16 +718,28 @@ const zhHans = {
|
|||||||
debugEventPayload: '事件载荷(JSON)',
|
debugEventPayload: '事件载荷(JSON)',
|
||||||
debugSupportedEvents: 'Agent 支持',
|
debugSupportedEvents: 'Agent 支持',
|
||||||
debugRun: '运行测试',
|
debugRun: '运行测试',
|
||||||
|
debugSaveAndRun: '保存并运行',
|
||||||
debugRunning: '运行中',
|
debugRunning: '运行中',
|
||||||
debugTranscript: '调试记录',
|
debugTranscript: '调试记录',
|
||||||
debugTranscriptDescription: '当前调试会话中的输入与 Agent 输出。',
|
debugTranscriptDescription: '当前调试会话中的输入与 Agent 输出。',
|
||||||
debugEmptyTranscript: '选择事件并运行测试后,结果会显示在这里。',
|
debugEmptyTitle: '在这里验证 Agent 的实际效果',
|
||||||
|
debugEmptyTranscript:
|
||||||
|
'选择事件类型,填写测试内容,然后点击“运行测试”。结果只会显示在这里。',
|
||||||
debugAgentOutput: 'Agent 输出',
|
debugAgentOutput: 'Agent 输出',
|
||||||
debugTestInput: '测试输入',
|
debugTestInput: '测试输入',
|
||||||
debugNoTextOutput: '运行完成,但没有产生文本输出。',
|
debugNoTextOutput: '运行完成,但没有产生文本输出。',
|
||||||
debugEventTypeRequired: '请输入事件类型',
|
debugEventTypeRequired: '请输入事件类型',
|
||||||
debugInputRequired: '请输入对话内容',
|
debugInputRequired: '请输入对话内容',
|
||||||
debugInvalidPayload: '事件载荷必须是有效的 JSON 对象',
|
debugInvalidPayload: '事件载荷必须是有效的 JSON 对象',
|
||||||
|
debugUnsupportedEvent: '这个事件不在当前 Agent 的可绑定事件范围内',
|
||||||
|
debugRunnerConfigInvalidDescription: '运行器配置不完整:{{message}}',
|
||||||
|
debugRunnerExecutionFailedDescription:
|
||||||
|
'本次运行失败。请检查所选模型和运行器配置后重试。',
|
||||||
|
debugRunnerTimeoutDescription: '运行超时。请稍后重试或调整运行器超时时间。',
|
||||||
|
debugApiKeyRequired: 'API Key 未填写',
|
||||||
|
debugOpenRunnerConfig: '前往运行器配置',
|
||||||
|
debugReviewRunnerConfig: '检查运行器配置',
|
||||||
|
debugErrorDetails: '查看详细错误',
|
||||||
debugRunFailed: 'Agent 调试运行失败',
|
debugRunFailed: 'Agent 调试运行失败',
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
@@ -1267,6 +1281,7 @@ const zhHans = {
|
|||||||
privateChat: '私聊',
|
privateChat: '私聊',
|
||||||
groupChat: '群聊',
|
groupChat: '群聊',
|
||||||
send: '发送',
|
send: '发送',
|
||||||
|
saveAndSend: '保存并发送',
|
||||||
reset: '重置对话',
|
reset: '重置对话',
|
||||||
inputPlaceholder: '发送 {{type}} 消息...',
|
inputPlaceholder: '发送 {{type}} 消息...',
|
||||||
noMessages: '暂无消息',
|
noMessages: '暂无消息',
|
||||||
|
|||||||
@@ -706,6 +706,24 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
|||||||
return fulfillJson(route, { agents: state.pipelines });
|
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\/([^/]+)$/);
|
const agentMatch = path.match(/^\/api\/v1\/agents\/([^/]+)$/);
|
||||||
if (agentMatch) {
|
if (agentMatch) {
|
||||||
const agentId = decodeURIComponent(agentMatch[1]);
|
const agentId = decodeURIComponent(agentMatch[1]);
|
||||||
@@ -756,6 +774,16 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
|||||||
return fulfillJson(route, { pipelines: state.pipelines });
|
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\/([^/]+)$/);
|
const pipelineMatch = path.match(/^\/api\/v1\/pipelines\/([^/]+)$/);
|
||||||
if (pipelineMatch) {
|
if (pipelineMatch) {
|
||||||
const pipelineId = decodeURIComponent(pipelineMatch[1]);
|
const pipelineId = decodeURIComponent(pipelineMatch[1]);
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ test.describe('processor detail workbench', () => {
|
|||||||
const configPanel = page.getByRole('region', { name: 'Configuration' });
|
const configPanel = page.getByRole('region', { name: 'Configuration' });
|
||||||
await expect(debugPanel).toBeVisible();
|
await expect(debugPanel).toBeVisible();
|
||||||
await expect(configPanel).toBeVisible();
|
await expect(configPanel).toBeVisible();
|
||||||
|
|
||||||
const debugBox = await debugPanel.boundingBox();
|
const debugBox = await debugPanel.boundingBox();
|
||||||
const configBox = await configPanel.boundingBox();
|
const configBox = await configPanel.boundingBox();
|
||||||
expect(debugBox).not.toBeNull();
|
expect(debugBox).not.toBeNull();
|
||||||
@@ -28,9 +27,21 @@ test.describe('processor detail workbench', () => {
|
|||||||
expect(configBox!.width).toBeGreaterThan(debugBox!.width);
|
expect(configBox!.width).toBeGreaterThan(debugBox!.width);
|
||||||
|
|
||||||
const appShell = page.locator('[class*="group/sidebar-wrapper"]');
|
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
|
await expect
|
||||||
.poll(() => appShell.evaluate((element) => element.scrollTop))
|
.poll(() => appShell.evaluate((element) => element.scrollTop))
|
||||||
.toBe(0);
|
.toBe(0);
|
||||||
|
await expect
|
||||||
|
.poll(() => sidebarInset.evaluate((element) => element.scrollTop))
|
||||||
|
.toBe(0);
|
||||||
expect(debugBox!.y).toBeGreaterThanOrEqual(0);
|
expect(debugBox!.y).toBeGreaterThanOrEqual(0);
|
||||||
|
|
||||||
const flow = configPanel.getByRole('tablist');
|
const flow = configPanel.getByRole('tablist');
|
||||||
@@ -66,10 +77,95 @@ test.describe('processor detail workbench', () => {
|
|||||||
).toBeVisible();
|
).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 ({
|
test('pipeline keeps debug chat left and exposes its main flow first', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await installLangBotApiMocks(page, { authenticated: true });
|
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');
|
await page.goto('/home/pipelines?id=pipeline-workbench');
|
||||||
|
|
||||||
@@ -77,6 +173,18 @@ test.describe('processor detail workbench', () => {
|
|||||||
const configPanel = page.getByRole('region', { name: 'Configuration' });
|
const configPanel = page.getByRole('region', { name: 'Configuration' });
|
||||||
await expect(debugPanel).toBeVisible();
|
await expect(debugPanel).toBeVisible();
|
||||||
await expect(configPanel).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 debugBox = await debugPanel.boundingBox();
|
||||||
const configBox = await configPanel.boundingBox();
|
const configBox = await configPanel.boundingBox();
|
||||||
@@ -86,19 +194,18 @@ test.describe('processor detail workbench', () => {
|
|||||||
expect(configBox!.width).toBeGreaterThan(debugBox!.width);
|
expect(configBox!.width).toBeGreaterThan(debugBox!.width);
|
||||||
|
|
||||||
const appShell = page.locator('[class*="group/sidebar-wrapper"]');
|
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
|
await expect
|
||||||
.poll(() => appShell.evaluate((element) => element.scrollTop))
|
.poll(() => appShell.evaluate((element) => element.scrollTop))
|
||||||
.toBe(0);
|
.toBe(0);
|
||||||
expect(debugBox!.y).toBeGreaterThanOrEqual(0);
|
expect(debugBox!.y).toBeGreaterThanOrEqual(0);
|
||||||
|
|
||||||
const flow = configPanel.getByRole('tablist');
|
const flow = configPanel.getByRole('tablist');
|
||||||
await expect(flow.getByRole('tab').nth(0)).toContainText(
|
await expect(flow.getByRole('tab').nth(0)).toContainText('Trigger');
|
||||||
'Trigger Conditions',
|
await expect(flow.getByRole('tab').nth(1)).toContainText('AI');
|
||||||
);
|
await expect(flow.getByRole('tab').nth(2)).toContainText('Output');
|
||||||
await expect(flow.getByRole('tab').nth(1)).toContainText('AI Capabilities');
|
|
||||||
await expect(flow.getByRole('tab').nth(2)).toContainText(
|
|
||||||
'Output Processing',
|
|
||||||
);
|
|
||||||
|
|
||||||
await flow.getByRole('tab').nth(1).click();
|
await flow.getByRole('tab').nth(1).click();
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
Reference in New Issue
Block a user