diff --git a/src/langbot/pkg/agent/runner/errors.py b/src/langbot/pkg/agent/runner/errors.py index 1755eff9f..bbf636fad 100644 --- a/src/langbot/pkg/agent/runner/errors.py +++ b/src/langbot/pkg/agent/runner/errors.py @@ -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}') diff --git a/src/langbot/pkg/agent/runner/invoker.py b/src/langbot/pkg/agent/runner/invoker.py index 4f45747b6..89285bfb9 100644 --- a/src/langbot/pkg/agent/runner/invoker.py +++ b/src/langbot/pkg/agent/runner/invoker.py @@ -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), diff --git a/src/langbot/pkg/agent/runner/result_normalizer.py b/src/langbot/pkg/agent/runner/result_normalizer.py index bb47c873a..d06cd1034 100644 --- a/src/langbot/pkg/agent/runner/result_normalizer.py +++ b/src/langbot/pkg/agent/runner/result_normalizer.py @@ -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': diff --git a/src/langbot/pkg/api/http/controller/groups/agents.py b/src/langbot/pkg/api/http/controller/groups/agents.py index 0584d62a1..4722d9135 100644 --- a/src/langbot/pkg/api/http/controller/groups/agents.py +++ b/src/langbot/pkg/api/http/controller/groups/agents.py @@ -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( diff --git a/src/langbot/pkg/pipeline/cntfilter/filters/banwords.py b/src/langbot/pkg/pipeline/cntfilter/filters/banwords.py index 39796b33c..889e34ac7 100644 --- a/src/langbot/pkg/pipeline/cntfilter/filters/banwords.py +++ b/src/langbot/pkg/pipeline/cntfilter/filters/banwords.py @@ -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}', ) diff --git a/src/langbot/pkg/utils/safe_regex.py b/src/langbot/pkg/utils/safe_regex.py index 32ae27843..24fa4cf49 100644 --- a/src/langbot/pkg/utils/safe_regex.py +++ b/src/langbot/pkg/utils/safe_regex.py @@ -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 diff --git a/tests/unit_tests/agent/test_orchestrator_integration.py b/tests/unit_tests/agent/test_orchestrator_integration.py index e7db121bb..f8c5f06da 100644 --- a/tests/unit_tests/agent/test_orchestrator_integration.py +++ b/tests/unit_tests/agent/test_orchestrator_integration.py @@ -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) diff --git a/tests/unit_tests/agent/test_result_normalizer.py b/tests/unit_tests/agent/test_result_normalizer.py index e276bd059..69a60ab39 100644 --- a/tests/unit_tests/agent/test_result_normalizer.py +++ b/tests/unit_tests/agent/test_result_normalizer.py @@ -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.""" diff --git a/tests/unit_tests/api/test_agents_controller.py b/tests/unit_tests/api/test_agents_controller.py index 224192c0a..7f0f111e8 100644 --- a/tests/unit_tests/api/test_agents_controller.py +++ b/tests/unit_tests/api/test_agents_controller.py @@ -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', + } diff --git a/tests/unit_tests/utils/test_safe_regex.py b/tests/unit_tests/utils/test_safe_regex.py index 6f0f985f5..c19fb10c4 100644 --- a/tests/unit_tests/utils/test_safe_regex.py +++ b/tests/unit_tests/utils/test_safe_regex.py @@ -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( diff --git a/web/src/app/home/agents/AgentDetailContent.tsx b/web/src/app/home/agents/AgentDetailContent.tsx index 133f6459b..ec69b5700 100644 --- a/web/src/app/home/agents/AgentDetailContent.tsx +++ b/web/src/app/home/agents/AgentDetailContent.tsx @@ -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( null, ); + const agentFormRef = useRef(null); useEffect(() => { if (isCreateMode) { @@ -89,7 +91,7 @@ export default function AgentDetailContent({ id }: { id: string }) { return ( { + onFinish={(updatedAgent) => { + if (updatedAgent) { + setAgent((current) => + current ? { ...current, ...updatedAgent } : current, + ); + } refreshPipelines(); }} onDeleted={() => { @@ -119,6 +127,11 @@ export default function AgentDetailContent({ id }: { id: string }) { canOperate ? ( agentFormRef.current?.save() ?? false} + onOpenRunnerConfig={() => + agentFormRef.current?.openSection('runner_config') + } supportedEventPatterns={ agent.supported_event_patterns ?? agent.capability?.supported_event_patterns ?? ['*'] diff --git a/web/src/app/home/agents/components/AgentCreateContent.tsx b/web/src/app/home/agents/components/AgentCreateContent.tsx index 654aaa041..d9cdd4962 100644 --- a/web/src/app/home/agents/components/AgentCreateContent.tsx +++ b/web/src/app/home/agents/components/AgentCreateContent.tsx @@ -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); } } diff --git a/web/src/app/home/agents/components/AgentDebugPanel.tsx b/web/src/app/home/agents/components/AgentDebugPanel.tsx index efdf046e3..37b7b5570 100644 --- a/web/src/app/home/agents/components/AgentDebugPanel.tsx +++ b/web/src/app/home/agents/components/AgentDebugPanel.tsx @@ -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; + 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; 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({ - {EVENT_PRESETS.map((item) => ( + {availablePresets.map((item) => ( {t(item.labelKey)} @@ -242,22 +321,30 @@ export default function AgentDebugPanel({

{entries.length === 0 ? ( -
- {t('agents.debugEmptyTranscript')} -
+ + + {t('agents.debugEmptyTitle')} + + {t('agents.debugEmptyTranscript')} + + ) : (
{entries.map((entry) => ( -
+ {entry.direction === 'error' && }
{entry.eventType} @@ -271,7 +358,36 @@ export default function AgentDebugPanel({
                   {entry.text}
                 
-
+ {entry.detail && ( + + + + + +
+                        {entry.detail}
+                      
+
+
+ )} + {(entry.errorCode?.endsWith('.config_invalid') || + entry.errorCode === 'runner_execution_failed' || + entry.errorCode === 'runner.timeout') && + onOpenRunnerConfig && ( + + )} + ))}
)} @@ -322,7 +438,11 @@ export default function AgentDebugPanel({ ) : ( )} - {running ? t('agents.debugRunning') : t('agents.debugRun')} + {running + ? t('agents.debugRunning') + : hasUnsavedChanges + ? t('agents.debugSaveAndRun') + : t('agents.debugRun')}
diff --git a/web/src/app/home/agents/components/AgentFormComponent.tsx b/web/src/app/home/agents/components/AgentFormComponent.tsx index 5a28e1d7f..f1eda3ac1 100644 --- a/web/src/app/home/agents/components/AgentFormComponent.tsx +++ b/web/src/app/home/agents/components/AgentFormComponent.tsx @@ -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) => 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; +} + +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, +) { + 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, +) { const { t } = useTranslation(); const [runnerConfigSchema, setRunnerConfigSchema] = useState(null); @@ -80,6 +128,7 @@ export default function AgentFormComponent({ const [activeSection, setActiveSection] = useState('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 + >; + 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 = { - 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 = { + 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); diff --git a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx index b6012a806..50450ddc1 100644 --- a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx +++ b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx @@ -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' || diff --git a/web/src/app/home/layout.tsx b/web/src/app/home/layout.tsx index 625a90e98..29864873a 100644 --- a/web/src/app/home/layout.tsx +++ b/web/src/app/home/layout.tsx @@ -269,7 +269,7 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) { -
+
diff --git a/web/src/app/home/pipelines/PipelineDetailContent.tsx b/web/src/app/home/pipelines/PipelineDetailContent.tsx index 769703190..85d6a4524 100644 --- a/web/src/app/home/pipelines/PipelineDetailContent.tsx +++ b/web/src/app/home/pipelines/PipelineDetailContent.tsx @@ -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(null); + const pipeline = pipelines.find((item) => item.id === id); function handleFinish() { refreshPipelines(); @@ -96,7 +100,7 @@ export default function PipelineDetailContent({ return ( pipelineFormRef.current?.save() ?? false} onConnectionStatusChange={setIsWebSocketConnected} /> ) : undefined diff --git a/web/src/app/home/pipelines/components/debug-dialog/DebugDialog.tsx b/web/src/app/home/pipelines/components/debug-dialog/DebugDialog.tsx index 370bb079a..3cc973190 100644 --- a/web/src/app/home/pipelines/components/debug-dialog/DebugDialog.tsx +++ b/web/src/app/home/pipelines/components/debug-dialog/DebugDialog.tsx @@ -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; + 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', )} > - - + + + + + + {t('pipelines.debugDialog.privateChat')} + + + + + + + + {t('pipelines.debugDialog.groupChat')} + + + + + + + + {t('pipelines.debugDialog.reset')} + +
@@ -1120,7 +1179,9 @@ export default function DebugDialog({ ) : ( <> - {t('pipelines.debugDialog.send')} + {hasUnsavedChanges + ? t('pipelines.debugDialog.saveAndSend') + : t('pipelines.debugDialog.send')} )} diff --git a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx index d0d4c5c2c..a743321b0 100644 --- a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx +++ b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx @@ -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; +} + +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 { + 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({ ); -} +}); + +export default PipelineFormComponent; interface SectionItem { label: string; name: string; diff --git a/web/src/components/ui/sidebar.tsx b/web/src/components/ui/sidebar.tsx index 95ae80ee2..e664817c3 100644 --- a/web/src/components/ui/sidebar.tsx +++ b/web/src/components/ui/sidebar.tsx @@ -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'>) {
{ 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(