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