From cc8a0ec84701ce68c4e5e0914172d23b6a2af4d1 Mon Sep 17 00:00:00 2001 From: fdc310 <2213070223@qq.com> Date: Sat, 19 Sep 2026 02:39:35 +0800 Subject: [PATCH] feat: simplify resource setup and detail guides --- pyproject.toml | 2 +- src/langbot/pkg/api/http/service/knowledge.py | 83 +- src/langbot/pkg/entity/persistence/rag.py | 7 + .../versions/0026_knowledge_base_drafts.py | 31 + src/langbot/pkg/rag/knowledge/kbmgr.py | 33 +- .../api/service/test_knowledge_service.py | 73 ++ tests/unit_tests/rag/test_kbmgr.py | 20 + uv.lock | 8 +- .../app/home/agents/AgentDetailContent.tsx | 1 - .../agents/components/AgentCreateContent.tsx | 38 +- .../agents/components/AgentFormComponent.tsx | 19 +- .../home/agents/components/RunnerSelect.tsx | 2 +- web/src/app/home/bots/BotDetailContent.tsx | 7 +- .../home/bots/components/bot-form/BotForm.tsx | 984 ++++++++++-------- .../components/guided-tour/GuidedTour.tsx | 90 +- .../guided-tour/dynamic-form-progress.ts | 46 - .../app/home/knowledge/KBDetailContent.tsx | 35 +- .../knowledge/components/kb-form/KBForm.tsx | 66 +- web/src/app/infra/entities/api/index.ts | 3 + web/src/i18n/locales/en-US.ts | 23 +- web/src/i18n/locales/zh-Hans.ts | 23 +- web/tests/e2e/bot-save-errors.spec.ts | 12 +- web/tests/e2e/contextual-guides.spec.ts | 327 ++++-- web/tests/e2e/crud-smoke.spec.ts | 95 +- web/tests/e2e/fixtures/langbot-api.ts | 23 +- web/tests/e2e/plugin-subscriptions.spec.ts | 6 +- .../e2e/wizard-platform-regressions.spec.ts | 20 +- 27 files changed, 1237 insertions(+), 840 deletions(-) create mode 100644 src/langbot/pkg/persistence/alembic/versions/0026_knowledge_base_drafts.py delete mode 100644 web/src/app/home/components/guided-tour/dynamic-form-progress.ts diff --git a/pyproject.toml b/pyproject.toml index 6e99165cf..202aeb90f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,7 @@ dependencies = [ "langchain-text-splitters>=1.1.2", "chromadb>=1.0.0,<2.0.0", "qdrant-client (>=1.15.1,<2.0.0)", - "langbot-plugin==0.6.0b2", + "langbot-plugin==0.6.0b3", "asyncpg>=0.30.0", "line-bot-sdk>=3.19.0", "matrix-nio>=0.25.2", diff --git a/src/langbot/pkg/api/http/service/knowledge.py b/src/langbot/pkg/api/http/service/knowledge.py index 06ba047b0..4f15ec1f6 100644 --- a/src/langbot/pkg/api/http/service/knowledge.py +++ b/src/langbot/pkg/api/http/service/knowledge.py @@ -71,23 +71,26 @@ class KnowledgeService: creation_settings = restore_secret_placeholders(kb_data.get('creation_settings', {})) retrieval_settings = kb_data.get('retrieval_settings', {}) + defer_initialization = kb_data.get('defer_initialization') is True - # Validate required fields based on plugin's creation_schema and retrieval_schema - await self._validate_schema_required_fields( - context, - knowledge_engine_plugin_id, - creation_settings, - retrieval_settings, - ) + if not defer_initialization: + await self._validate_schema_required_fields( + context, + knowledge_engine_plugin_id, + creation_settings, + retrieval_settings, + ) - kb = await self.ap.rag_mgr.create_knowledge_base( - context, - name=kb_data.get('name', 'Untitled'), - knowledge_engine_plugin_id=knowledge_engine_plugin_id, - creation_settings=creation_settings, - retrieval_settings=retrieval_settings, - description=kb_data.get('description', ''), - ) + create_kwargs = { + 'name': kb_data.get('name', 'Untitled'), + 'knowledge_engine_plugin_id': knowledge_engine_plugin_id, + 'creation_settings': creation_settings, + 'retrieval_settings': retrieval_settings, + 'description': kb_data.get('description', ''), + } + if defer_initialization: + create_kwargs['initialize'] = False + kb = await self.ap.rag_mgr.create_knowledge_base(context, **create_kwargs) return kb.uuid async def _validate_schema_required_fields( @@ -205,10 +208,32 @@ class KnowledgeService: ) -> None: """更新知识库""" workspace_uuid = require_workspace_uuid(context) - if await self.get_knowledge_base(context, kb_uuid) is None: + current = await self.get_knowledge_base(context, kb_uuid, include_secret=True) + if current is None: raise WorkspaceNotFoundError('Knowledge base not found') - # Filter to only mutable fields - filtered_data = {k: v for k, v in kb_data.items() if k in persistence_rag.KnowledgeBase.MUTABLE_FIELDS} + + should_initialize = current.get('initialized', True) is False and kb_data.get('initialize_engine') is True + if should_initialize: + plugin_id = current.get('knowledge_engine_plugin_id') + if not plugin_id: + raise ValueError('knowledge_engine_plugin_id is required') + creation_settings = restore_secret_placeholders(kb_data.get('creation_settings', {})) + retrieval_settings = kb_data.get('retrieval_settings', {}) + await self._validate_schema_required_fields( + context, + plugin_id, + creation_settings, + retrieval_settings, + ) + filtered_data = { + 'name': kb_data.get('name', current.get('name', 'Untitled')), + 'description': kb_data.get('description', current.get('description', '')), + 'creation_settings': creation_settings, + 'retrieval_settings': retrieval_settings, + 'initialized': True, + } + else: + filtered_data = {k: v for k, v in kb_data.items() if k in persistence_rag.KnowledgeBase.MUTABLE_FIELDS} if not filtered_data: return @@ -224,8 +249,28 @@ class KnowledgeService: kb = await self.get_knowledge_base(context, kb_uuid, include_secret=True) if kb is None: raise WorkspaceNotFoundError('Knowledge base not found') + if kb.get('initialized', True) is False: + return - await self.ap.rag_mgr.load_knowledge_base(context, kb) + runtime_kb = await self.ap.rag_mgr.load_knowledge_base(context, kb) + if should_initialize: + try: + await runtime_kb._on_kb_create(self._execution_context(context)) + except Exception: + await self.ap.rag_mgr.remove_knowledge_base_from_runtime(context, kb_uuid) + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(persistence_rag.KnowledgeBase) + .values( + name=current.get('name', 'Untitled'), + description=current.get('description', ''), + creation_settings=current.get('creation_settings', {}), + retrieval_settings=current.get('retrieval_settings', {}), + initialized=False, + ) + .where(persistence_rag.KnowledgeBase.workspace_uuid == workspace_uuid) + .where(persistence_rag.KnowledgeBase.uuid == kb_uuid) + ) + raise async def _check_doc_capability(self, context: TenantContext, kb_uuid: str, operation: str) -> None: """Check if the KB's Knowledge Engine supports document operations. diff --git a/src/langbot/pkg/entity/persistence/rag.py b/src/langbot/pkg/entity/persistence/rag.py index 8cd1592eb..29ce72177 100644 --- a/src/langbot/pkg/entity/persistence/rag.py +++ b/src/langbot/pkg/entity/persistence/rag.py @@ -29,6 +29,12 @@ class KnowledgeBase(Base): ) creation_settings = sqlalchemy.Column(sqlalchemy.JSON, nullable=True, default=None) retrieval_settings = sqlalchemy.Column(sqlalchemy.JSON, nullable=True, default=None) + initialized = sqlalchemy.Column( + sqlalchemy.Boolean, + nullable=False, + default=True, + server_default=sqlalchemy.true(), + ) # Server-selected pgvector dimension. ``None`` means no embedding has been # written yet; the first pgvector upsert binds it atomically. embedding_dimension = sqlalchemy.Column(sqlalchemy.Integer, nullable=True) @@ -44,6 +50,7 @@ class KnowledgeBase(Base): 'workspace_uuid', 'legacy_vector_collection', 'embedding_dimension', + 'initialized', 'emoji', 'created_at', 'updated_at', diff --git a/src/langbot/pkg/persistence/alembic/versions/0026_knowledge_base_drafts.py b/src/langbot/pkg/persistence/alembic/versions/0026_knowledge_base_drafts.py new file mode 100644 index 000000000..ebeca4cbc --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0026_knowledge_base_drafts.py @@ -0,0 +1,31 @@ +"""Add deferred initialization state for knowledge bases.""" + +from alembic import op +import sqlalchemy as sa + + +revision = '0026_knowledge_base_drafts' +down_revision = '0025_bot_plugin_processors' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + inspector = sa.inspect(bind) + if not inspector.has_table('knowledge_bases'): + return + if 'initialized' not in {column['name'] for column in inspector.get_columns('knowledge_bases')}: + op.add_column( + 'knowledge_bases', + sa.Column('initialized', sa.Boolean(), nullable=False, server_default=sa.true()), + ) + + +def downgrade(): + bind = op.get_bind() + inspector = sa.inspect(bind) + if not inspector.has_table('knowledge_bases'): + return + if 'initialized' in {column['name'] for column in inspector.get_columns('knowledge_bases')}: + op.drop_column('knowledge_bases', 'initialized') diff --git a/src/langbot/pkg/rag/knowledge/kbmgr.py b/src/langbot/pkg/rag/knowledge/kbmgr.py index 8042b3271..1c992103b 100644 --- a/src/langbot/pkg/rag/knowledge/kbmgr.py +++ b/src/langbot/pkg/rag/knowledge/kbmgr.py @@ -802,6 +802,7 @@ class RAGManager: creation_settings: dict, retrieval_settings: dict | None = None, description: str = '', + initialize: bool = True, ) -> persistence_rag.KnowledgeBase: """Create a new knowledge base using a RAG plugin.""" execution_context = await self._to_execution_context(context) @@ -831,6 +832,7 @@ class RAGManager: 'collection_id': collection_id, 'creation_settings': creation_settings, 'retrieval_settings': retrieval_settings or {}, + 'initialized': initialize, } # Create Entity @@ -839,20 +841,21 @@ class RAGManager: # Persist await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_rag.KnowledgeBase).values(kb_data)) - # Load into Runtime - runtime_kb = await self.load_knowledge_base(execution_context, kb) + if initialize: + # Drafts stay out of the runtime until their engine settings are saved. + runtime_kb = await self.load_knowledge_base(execution_context, kb) - # Notify Plugin — rollback DB record and runtime entry on failure - try: - await runtime_kb._on_kb_create(execution_context) - except Exception: - self._pop_runtime(execution_context, kb_uuid) - await self.ap.persistence_mgr.execute_async( - sqlalchemy.delete(persistence_rag.KnowledgeBase) - .where(persistence_rag.KnowledgeBase.workspace_uuid == execution_context.workspace_uuid) - .where(persistence_rag.KnowledgeBase.uuid == kb_uuid) - ) - raise + # Roll back the record and runtime entry if plugin initialization fails. + try: + await runtime_kb._on_kb_create(execution_context) + except Exception: + self._pop_runtime(execution_context, kb_uuid) + await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(persistence_rag.KnowledgeBase) + .where(persistence_rag.KnowledgeBase.workspace_uuid == execution_context.workspace_uuid) + .where(persistence_rag.KnowledgeBase.uuid == kb_uuid) + ) + raise self.ap.logger.info(f'Created new Knowledge Base {name} ({kb_uuid}) using plugin {knowledge_engine_plugin_id}') return kb @@ -878,6 +881,8 @@ class RAGManager: .order_by(persistence_rag.KnowledgeBase.uuid) ) for knowledge_base in result.all(): + if knowledge_base.initialized is False: + continue try: await self.load_knowledge_base( ExecutionContext( @@ -899,6 +904,8 @@ class RAGManager: knowledge_bases = result.all() for knowledge_base in knowledge_bases: + if knowledge_base.initialized is False: + continue try: binding = await self.ap.workspace_service.get_execution_binding(knowledge_base.workspace_uuid) execution_context = ExecutionContext( diff --git a/tests/unit_tests/api/service/test_knowledge_service.py b/tests/unit_tests/api/service/test_knowledge_service.py index 20d955f69..908a6f434 100644 --- a/tests/unit_tests/api/service/test_knowledge_service.py +++ b/tests/unit_tests/api/service/test_knowledge_service.py @@ -120,6 +120,79 @@ async def test_create_validates_schema_and_binds_context(): ) +@pytest.mark.asyncio +async def test_create_draft_defers_required_validation_and_engine_initialization(): + app = _app() + app.plugin_connector.get_rag_creation_schema.return_value = { + 'schema': [{'name': 'endpoint', 'label': {'en_US': 'Endpoint'}, 'required': True}] + } + app.rag_mgr.create_knowledge_base.return_value = SimpleNamespace(uuid='kb-draft') + + result = await KnowledgeService(app).create_knowledge_base( + CONTEXT, + { + 'name': 'Draft KB', + 'knowledge_engine_plugin_id': 'author/engine', + 'defer_initialization': True, + }, + ) + + assert result == 'kb-draft' + app.plugin_connector.get_rag_creation_schema.assert_not_awaited() + app.rag_mgr.create_knowledge_base.assert_awaited_once_with( + CONTEXT, + name='Draft KB', + knowledge_engine_plugin_id='author/engine', + creation_settings={}, + retrieval_settings={}, + description='', + initialize=False, + ) + + +@pytest.mark.asyncio +async def test_update_initializes_a_draft_after_required_settings_are_complete(): + app = _app() + draft = { + 'uuid': 'kb-draft', + 'workspace_uuid': 'workspace-a', + 'name': 'Draft KB', + 'description': '', + 'knowledge_engine_plugin_id': 'author/engine', + 'creation_settings': {}, + 'retrieval_settings': {}, + 'initialized': False, + } + initialized = { + **draft, + 'creation_settings': {'endpoint': 'https://example.invalid'}, + 'initialized': True, + } + app.rag_mgr.get_knowledge_base_details.side_effect = [draft, initialized] + app.plugin_connector.get_rag_creation_schema.return_value = { + 'schema': [{'name': 'endpoint', 'label': {'en_US': 'Endpoint'}, 'required': True}] + } + runtime = SimpleNamespace(_on_kb_create=AsyncMock()) + app.rag_mgr.load_knowledge_base.return_value = runtime + + await KnowledgeService(app).update_knowledge_base( + CONTEXT, + 'kb-draft', + { + 'name': 'Draft KB', + 'creation_settings': {'endpoint': 'https://example.invalid'}, + 'retrieval_settings': {}, + 'initialize_engine': True, + }, + ) + + runtime._on_kb_create.assert_awaited_once_with(CONTEXT) + update_statement = app.persistence_mgr.execute_async.await_args_list[0].args[0] + params = update_statement.compile().params + assert params['creation_settings'] == {'endpoint': 'https://example.invalid'} + assert params['initialized'] is True + + @pytest.mark.asyncio async def test_create_enforces_workspace_knowledge_base_limit(): app = _app() diff --git a/tests/unit_tests/rag/test_kbmgr.py b/tests/unit_tests/rag/test_kbmgr.py index 9699269d9..f43814870 100644 --- a/tests/unit_tests/rag/test_kbmgr.py +++ b/tests/unit_tests/rag/test_kbmgr.py @@ -49,6 +49,7 @@ def _entity(*, kb_uuid='kb-a', workspace_uuid='workspace-a', plugin_id='author/e collection_id=kb_uuid, creation_settings={}, retrieval_settings={}, + initialized=True, ) @@ -67,6 +68,7 @@ def _app(): 'collection_id': row.collection_id, 'creation_settings': row.creation_settings, 'retrieval_settings': row.retrieval_settings, + 'initialized': row.initialized, } ), ), @@ -124,6 +126,24 @@ async def test_create_binds_workspace_and_uses_tuple_runtime_key(): ) +@pytest.mark.asyncio +async def test_create_draft_persists_without_loading_or_notifying_plugin(): + app = _app() + manager = RAGManager(app) + + kb = await manager.create_knowledge_base( + CONTEXT_A, + name='Draft', + knowledge_engine_plugin_id='author/engine', + creation_settings={}, + initialize=False, + ) + + assert kb.initialized is False + assert ('workspace-a', kb.uuid) not in manager.knowledge_bases + app.plugin_connector.rag_on_kb_create.assert_not_awaited() + + @pytest.mark.asyncio async def test_create_rejects_unknown_engine_and_rolls_back_plugin_failure(): app = _app() diff --git a/uv.lock b/uv.lock index 1cbfd8425..0b10e74cc 100644 --- a/uv.lock +++ b/uv.lock @@ -2119,7 +2119,7 @@ requires-dist = [ { name = "ebooklib", specifier = ">=0.18" }, { name = "gewechat-client", specifier = ">=0.1.5" }, { name = "html2text", specifier = ">=2024.2.26" }, - { name = "langbot-plugin", specifier = "==0.6.0b2" }, + { name = "langbot-plugin", specifier = "==0.6.0b3" }, { name = "langchain", specifier = ">=1.3.9" }, { name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" }, @@ -2184,7 +2184,7 @@ dev = [ [[package]] name = "langbot-plugin" -version = "0.6.0b2" +version = "0.6.0b3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiofiles" }, @@ -2205,9 +2205,9 @@ dependencies = [ { name = "watchdog" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bf/d9/fc410f8b7c754196ca72124ca9a76a41a334114487938137795099130bd9/langbot_plugin-0.6.0b2.tar.gz", hash = "sha256:f895ab6da4e9ab3e1c7dd037b610b5303ed1a9946129b3b776643452a6ec1caf", size = 600560 } +sdist = { url = "https://files.pythonhosted.org/packages/dc/6c/198eee31ef8b1ada209cc3c1588637ae3649f16fb4c0c41acc8296559cfb/langbot_plugin-0.6.0b3.tar.gz", hash = "sha256:2becc1de2b2543c29609c88b7cd2de9267b94d11d2d055dfd46ecfb9e9bbd496", size = 607092 } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/4f/2ad61ed4eeca03f532def1c5385cf0cdc0802b98207ef6efe82b4625238b/langbot_plugin-0.6.0b2-py3-none-any.whl", hash = "sha256:9890f8cfd9089b5fdf54597ea019cd30a426d5ea5f75f0fa378014e7ed52fcbe", size = 400234 }, + { url = "https://files.pythonhosted.org/packages/7c/f0/41ffb773c114924d95cc78824d218d76a7d7973c79dc3b43a12d4e93992b/langbot_plugin-0.6.0b3-py3-none-any.whl", hash = "sha256:a4aa0c7427267685fed8b96c8f02457dad81728758f75373816f0146c4f72c8c", size = 404923 }, ] [[package]] diff --git a/web/src/app/home/agents/AgentDetailContent.tsx b/web/src/app/home/agents/AgentDetailContent.tsx index 4532532c9..dcc02100a 100644 --- a/web/src/app/home/agents/AgentDetailContent.tsx +++ b/web/src/app/home/agents/AgentDetailContent.tsx @@ -115,7 +115,6 @@ export default function AgentDetailContent({ id }: { id: string }) { if (isCreateMode) { return ( { refreshPipelines(); navigate(`/home/agents?id=${encodeURIComponent(newAgentId)}`); diff --git a/web/src/app/home/agents/components/AgentCreateContent.tsx b/web/src/app/home/agents/components/AgentCreateContent.tsx index d18a41ead..9c5fcf852 100644 --- a/web/src/app/home/agents/components/AgentCreateContent.tsx +++ b/web/src/app/home/agents/components/AgentCreateContent.tsx @@ -27,16 +27,11 @@ import { import { Input } from '@/components/ui/input'; import EmojiPicker from '@/components/ui/emoji-picker'; import ProcessorTypeDiagram from './ProcessorTypeDiagram'; -import GuidedTour, { - GuidedTourStep, -} from '@/app/home/components/guided-tour/GuidedTour'; export default function AgentCreateContent({ onCreated, - guideEnabled = true, }: { onCreated: (agentId: string) => void; - guideEnabled?: boolean; }) { const { t } = useTranslation(); const [kind, setKind] = useState('agent'); @@ -114,29 +109,6 @@ export default function AgentCreateContent({ description: t('agents.eventProcessor.description'), }, ]; - const guideSteps: GuidedTourStep[] = [ - { - id: 'type', - target: '[data-guide="processor-type"]', - title: t('guidedTour.processorCreate.type.title'), - description: t('guidedTour.processorCreate.type.description'), - }, - { - id: 'basic', - target: '[data-guide="processor-basic"]', - title: t('guidedTour.processorCreate.basic.title'), - description: t('guidedTour.processorCreate.basic.description'), - complete: Boolean(form.watch('name')?.trim()), - requirement: t('guidedTour.processorCreate.basic.requirement'), - }, - { - id: 'submit', - target: '[data-guide="processor-submit"]', - title: t('guidedTour.processorCreate.submit.title'), - description: t('guidedTour.processorCreate.submit.description'), - }, - ]; - return (
@@ -147,7 +119,6 @@ export default function AgentCreateContent({ type="submit" form="agent-create-form" disabled={form.formState.isSubmitting} - data-guide="processor-submit" > {t('common.submit')} @@ -160,7 +131,6 @@ export default function AgentCreateContent({

- + {t('agents.basicInfo')} @@ -287,12 +257,6 @@ export default function AgentCreateContent({
- ); } diff --git a/web/src/app/home/agents/components/AgentFormComponent.tsx b/web/src/app/home/agents/components/AgentFormComponent.tsx index 28ef93d61..9b67f5f97 100644 --- a/web/src/app/home/agents/components/AgentFormComponent.tsx +++ b/web/src/app/home/agents/components/AgentFormComponent.tsx @@ -48,7 +48,6 @@ import RunnerSelect from './RunnerSelect'; import GuidedTour, { GuidedTourStep, } from '@/app/home/components/guided-tour/GuidedTour'; -import { areRequiredDynamicFieldsComplete } from '@/app/home/components/guided-tour/dynamic-form-progress'; import AgentApiToolPicker from './AgentApiToolPicker'; const OTHER_TOOL_SCOPES = [ @@ -482,8 +481,6 @@ function AgentFormComponent( target: '[data-guide="runner-selector"]', title: t('guidedTour.runner.select.title'), description: t('guidedTour.runner.select.description'), - complete: Boolean(currentRunner && selectedRunnerOption), - requirement: t('guidedTour.runner.select.requirement'), action: { href: 'https://space.langbot.app/market?type=plugin&component=Runner&runner_usage=agent', label: t('guidedTour.runner.select.action'), @@ -497,11 +494,6 @@ function AgentFormComponent( target: '[data-guide="runner-parameters"]', title: t('guidedTour.runner.parameters.title'), description: t('guidedTour.runner.parameters.description'), - complete: areRequiredDynamicFieldsComplete( - activeRunnerStage.config, - activeRunnerValues, - ), - requirement: t('guidedTour.runner.parameters.requirement'), }); } @@ -510,18 +502,9 @@ function AgentFormComponent( target: '[data-guide="agent-sections"]', title: t('guidedTour.runner.events.title'), description: t('guidedTour.runner.events.description'), - complete: activeSection === 'events_and_tools', - requirement: t('guidedTour.runner.events.requirement'), }); return steps; - }, [ - activeRunnerStage, - activeRunnerValues, - activeSection, - currentRunner, - selectedRunnerOption, - t, - ]); + }, [activeRunnerStage, t]); useEffect(() => { onRunnerStatusChange?.(runnerStatus); diff --git a/web/src/app/home/agents/components/RunnerSelect.tsx b/web/src/app/home/agents/components/RunnerSelect.tsx index 1c0f2549e..cb706091c 100644 --- a/web/src/app/home/agents/components/RunnerSelect.tsx +++ b/web/src/app/home/agents/components/RunnerSelect.tsx @@ -348,7 +348,7 @@ export default function RunnerSelect({ )} - + diff --git a/web/src/app/home/bots/BotDetailContent.tsx b/web/src/app/home/bots/BotDetailContent.tsx index 6a165ce10..6ed4ca775 100644 --- a/web/src/app/home/bots/BotDetailContent.tsx +++ b/web/src/app/home/bots/BotDetailContent.tsx @@ -162,7 +162,7 @@ export default function BotDetailContent({ id }: { id: string }) { {/* Header */}

{t('bots.createBot')}

- {canManage && ( + {canManage && adapterLabel && ( @@ -171,13 +171,14 @@ export default function BotDetailContent({ id }: { id: string }) { {/* Content */}
-
+
@@ -236,6 +237,7 @@ export default function BotDetailContent({ id }: { id: string }) { form="bot-form" disabled={!formDirty} className={activeTab !== 'config' ? 'invisible' : ''} + data-guide="bot-config-save" > {t('common.save')} @@ -318,6 +320,7 @@ export default function BotDetailContent({ id }: { id: string }) { onNewBotCreated={handleNewBotCreated} onDirtyChange={setFormDirty} onAdapterLabelChange={setAdapterLabel} + guideEnabled={canManage} />
diff --git a/web/src/app/home/bots/components/bot-form/BotForm.tsx b/web/src/app/home/bots/components/bot-form/BotForm.tsx index 7cbb7fd5f..d8b6f2123 100644 --- a/web/src/app/home/bots/components/bot-form/BotForm.tsx +++ b/web/src/app/home/bots/components/bot-form/BotForm.tsx @@ -27,6 +27,7 @@ import { Agent, Bot } from '@/app/infra/entities/api'; import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs'; import { Cable, + Check, ExternalLink, ChevronDown, ChevronRight, @@ -78,7 +79,6 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import GuidedTour, { GuidedTourStep, } from '@/app/home/components/guided-tour/GuidedTour'; -import { areRequiredDynamicFieldsComplete } from '@/app/home/components/guided-tour/dynamic-form-progress'; type ConnectionMode = 'webhook' | 'persistent'; @@ -266,7 +266,6 @@ const BotForm = forwardRef(function BotForm( ); // Watch adapter and adapter_config for filtering - const currentBotName = form.watch('name'); const currentAdapter = form.watch('adapter'); const adapterLabel = adapterNameList.find((adapter) => adapter.value === currentAdapter) @@ -349,7 +348,7 @@ const BotForm = forwardRef(function BotForm( event_bindings: val.event_bindings || [], plugin_processors: val.plugin_processors || [], }); - handleAdapterSelect(val.adapter); + handleAdapterSelect(val.adapter, true); if (val.webhook_full_url) { setWebhookUrl(val.webhook_full_url); @@ -489,12 +488,12 @@ const BotForm = forwardRef(function BotForm( }); } - function handleAdapterSelect(adapterName: string) { + function handleAdapterSelect(adapterName: string, preserveConfig = false) { if (adapterName) { const adapterConfig = adapterNameToDynamicConfigMap.get(adapterName); if (adapterConfig) { setDynamicFormConfigList(adapterConfig); - if (!initBotId) { + if (!preserveConfig) { const defaultValues = getDefaultValues(adapterConfig); const supportedModes = getSupportedConnectionModes(adapterConfig); const nextMode = @@ -522,6 +521,14 @@ const BotForm = forwardRef(function BotForm( } } + function selectAdapter(adapterName: string) { + form.setValue('adapter', adapterName, { + shouldDirty: !isInitializing.current, + shouldValidate: true, + }); + handleAdapterSelect(adapterName, false); + } + function handleConnectionModeChange(mode: ConnectionMode) { if (!currentAdapter) return; const adapterConfig = @@ -546,41 +553,20 @@ const BotForm = forwardRef(function BotForm( ); const botGuideSteps = useMemo(() => { - const steps: GuidedTourStep[] = [ - { - id: 'basic', - target: '[data-guide="bot-basic"]', - title: t('guidedTour.bot.basic.title'), - description: t('guidedTour.bot.basic.description'), - complete: Boolean(currentBotName?.trim()), - requirement: t('guidedTour.bot.basic.requirement'), - }, - { - id: 'adapter', - target: '[data-guide="bot-adapter"]', - title: t('guidedTour.bot.adapter.title'), - description: t('guidedTour.bot.adapter.description'), - complete: Boolean(currentAdapter), - advanceOnComplete: true, - requirement: t('guidedTour.bot.adapter.requirement'), - }, - ]; + const steps: GuidedTourStep[] = []; - if (currentAdapter) { + if (supportedConnectionModes.length > 1) { steps.push({ id: 'connection', target: '[data-guide="bot-connection-mode"]', title: t('guidedTour.bot.connection.title'), description: t('guidedTour.bot.connection.description'), - complete: connectionMode !== null, - advanceOnComplete: true, - requirement: t('guidedTour.bot.connection.requirement'), }); } - if (currentAdapter && dynamicFormConfigList.length > 0) { + if (dynamicFormConfigList.length > 0) { const docsUrl = getAdapterDocUrl( - adapterHelpLinks[currentAdapter], + currentAdapter ? adapterHelpLinks[currentAdapter] : undefined, i18n.language, ); steps.push({ @@ -588,11 +574,6 @@ const BotForm = forwardRef(function BotForm( target: '[data-guide="bot-adapter-parameters"]', title: t('guidedTour.bot.parameters.title'), description: t('guidedTour.bot.parameters.description'), - complete: areRequiredDynamicFieldsComplete( - dynamicFormConfigList, - currentAdapterConfig, - ), - requirement: t('guidedTour.bot.parameters.requirement'), action: docsUrl ? { href: docsUrl, @@ -602,92 +583,92 @@ const BotForm = forwardRef(function BotForm( }); } - if (currentAdapter) { - steps.push({ - id: 'routing', - target: '[data-guide="bot-routing"]', - title: t('guidedTour.bot.routing.title'), - description: t('guidedTour.bot.routing.description'), - }); - } - steps.push({ - id: 'submit', - target: '[data-guide="bot-submit"]', - title: t('guidedTour.bot.submit.title'), - description: t('guidedTour.bot.submit.description'), + id: 'routing', + target: '[data-guide="bot-routing"]', + title: t('guidedTour.bot.routing.title'), + description: t('guidedTour.bot.routing.description'), + }); + steps.push({ + id: 'save', + target: '[data-guide="bot-config-save"]', + title: t('guidedTour.bot.save.title'), + description: t('guidedTour.bot.save.description'), }); return steps; }, [ adapterHelpLinks, - connectionMode, currentAdapter, - currentAdapterConfig, - currentBotName, dynamicFormConfigList, + supportedConnectionModes.length, t, ]); - function onDynamicFormSubmit() { + async function createDraft() { + if (initBotId || !currentAdapter) return; setIsLoading(true); - if (initBotId) { - const updateBot: Bot = { - uuid: initBotId, - name: form.getValues().name, - description: form.getValues().description ?? '', - adapter: form.getValues().adapter, - adapter_config: form.getValues().adapter_config, - enable: form.getValues().enable, - event_bindings: form.getValues().event_bindings ?? [], - plugin_processors: form.getValues().plugin_processors ?? [], - }; - httpClient - .updateBot(initBotId, updateBot) - .then(() => { - // Reset dirty baseline to current values so isDirty becomes false - form.reset(form.getValues()); - onFormSubmit(form.getValues()); - toast.success(t('bots.saveSuccess')); - }) - .catch((err) => { - showBotError(err, t('bots.saveError'), t); - }) - .finally(() => { - setIsLoading(false); - }); - } else { - const newBot: Bot = { - name: form.getValues().name, - description: form.getValues().description ?? '', - adapter: form.getValues().adapter, - adapter_config: form.getValues().adapter_config, - enable: form.getValues().enable, - event_bindings: form.getValues().event_bindings ?? [], - plugin_processors: form.getValues().plugin_processors ?? [], - }; - httpClient - .createBot(newBot) - .then((res) => { - toast.success(t('bots.createSuccess')); - initBotId = res.uuid; + const newBot: Bot = { + name: form.getValues('name'), + description: form.getValues('description') ?? '', + adapter: currentAdapter, + adapter_config: form.getValues('adapter_config') || {}, + enable: false, + event_bindings: [], + plugin_processors: [], + }; - setBotFormValues(); - - onNewBotCreated(res.uuid); - }) - .catch((err) => { - showBotError(err, t('bots.createError'), t); - if (err.code === 'bot_apply_failed' && err.data?.uuid) { - onNewBotCreated(err.data.uuid); - } - }) - .finally(() => { - setIsLoading(false); - form.reset(); - }); + try { + const res = await httpClient.createBot(newBot); + toast.success(t('bots.createSuccess')); + onNewBotCreated(res.uuid); + } catch (error) { + const err = error as CustomApiError & { + code?: string; + data?: { uuid?: string }; + }; + showBotError(err, t('bots.createError'), t); + if (err.code === 'bot_apply_failed' && err.data?.uuid) { + onNewBotCreated(err.data.uuid); + return; + } + throw error; + } finally { + setIsLoading(false); } } + function onDynamicFormSubmit() { + if (!initBotId) { + void createDraft(); + return; + } + setIsLoading(true); + const updateBot: Bot = { + uuid: initBotId, + name: form.getValues().name, + description: form.getValues().description ?? '', + adapter: form.getValues().adapter, + adapter_config: form.getValues().adapter_config, + enable: form.getValues().enable, + event_bindings: form.getValues().event_bindings ?? [], + plugin_processors: form.getValues().plugin_processors ?? [], + }; + httpClient + .updateBot(initBotId, updateBot) + .then(() => { + // Reset dirty baseline to current values so isDirty becomes false + form.reset(form.getValues()); + onFormSubmit(form.getValues()); + toast.success(t('bots.saveSuccess')); + }) + .catch((err) => { + showBotError(err, t('bots.saveError'), t); + }) + .finally(() => { + setIsLoading(false); + }); + } + if (loadFailed) return ( setLoadAttempt((n) => n + 1)} /> @@ -707,339 +688,480 @@ const BotForm = forwardRef(function BotForm( 'w-full min-w-0 max-w-full', initBotId ? 'grid gap-4 lg:h-full lg:min-h-0 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)] lg:grid-rows-[minmax(0,1fr)]' - : 'space-y-6', + : 'grid items-start gap-6 lg:grid-cols-[minmax(16rem,0.7fr)_minmax(0,2fr)]', )} disabled={isLoading} > - {!initBotId && ( - + {/* Create: basic information and adapter selection */} + {!initBotId ? ( + <> + + + {t('bots.basicInfo')} + + {t('bots.basicInfoDescription')} + + + + ( + + + {t('bots.botName')} + * + + + + + + + )} + /> + ( + + {t('bots.botDescription')} + + + + + + )} + /> + + + +
+
+

+ {t('guidedTour.bot.adapter.title')} +

+

+ {t('guidedTour.bot.adapter.description')} +

+
+ +
+ {groupedAdapters.map((group) => ( +
+
+

+ {group.categoryId + ? getCategoryLabel(t, group.categoryId) + : t('bots.selectAdapter')} +

+ + {group.items.length} + +
+
+ {group.items.map((item) => ( + + ))} +
+
+ ))} + + {legacyAdapters.length > 0 && ( +
+ + {showLegacyAdapters && ( +
+ {legacyAdapters.map((item) => ( + + ))} +
+ )} +
+ )} +
+
+ + ) : ( + - {t('bots.basicInfo')} + {t('bots.adapterConfig')} - {t('bots.basicInfoDescription')} + {t('bots.adapterConfigDescription')} - - ( - - - {t('bots.botName')} - * - - - - - - - )} - /> - ( - - {t('bots.botDescription')} - - - - - - )} - /> + +
+ ( + + + {t('bots.platformAdapter')} + * + + +
+ + {currentAdapter && + (() => { + const docUrl = getAdapterDocUrl( + adapterHelpLinks[currentAdapter], + i18n.language, + ); + return docUrl ? ( + + {t('bots.viewAdapterDocs')} + + + ) : null; + })()} +
+
+ {currentAdapter && + adapterDescriptionList[currentAdapter] && ( + + {adapterDescriptionList[currentAdapter]} + + )} + +
+ )} + /> +
+ + {currentAdapter && supportedConnectionModes.length > 1 && ( +
+
+

+ {t('bots.connectionMode')} +

+

+ {t('bots.connectionModeDescription')} +

+
+ { + if (value) { + handleConnectionModeChange(value as ConnectionMode); + } + }} + variant="outline" + className="grid w-full grid-cols-1 gap-3 sm:grid-cols-2" + spacing={3} + > + + + + + {t('bots.connectionWebhook')} + + + {t('bots.connectionWebhookDescription')} + + + + + + + + {t('bots.connectionPersistent')} + + + {t('bots.connectionPersistentDescription')} + + + + +
+ )} + + {showDynamicForm && dynamicFormConfigList.length > 0 && ( +
+ { + form.setValue('adapter_config', values, { + shouldDirty: !isInitializing.current, + }); + }} + systemContext={{ + webhook_url: webhookUrl, + extra_webhook_url: extraWebhookUrl, + bot_uuid: initBotId || '', + adapter_config: form.getValues('adapter_config') || {}, + outbound_ips: systemInfo.outbound_ips, + }} + /> +
+ )}
)} - {/* Card 2: Adapter Configuration */} - - - {t('bots.adapterConfig')} - - {t('bots.adapterConfigDescription')} - - - -
- ( - - - {t('bots.platformAdapter')} - * - - -
- - {currentAdapter && - (() => { - const docUrl = getAdapterDocUrl( - adapterHelpLinks[currentAdapter], - i18n.language, - ); - return docUrl ? ( - - {t('bots.viewAdapterDocs')} - - - ) : null; - })()} -
-
- {currentAdapter && - adapterDescriptionList[currentAdapter] && ( - - {adapterDescriptionList[currentAdapter]} - - )} - -
- )} - /> -
- - {!initBotId && currentAdapter && ( -
-
-

- {t('bots.connectionMode')} -

-

- {t('bots.connectionModeDescription')} -

-
- { - if (value) { - handleConnectionModeChange(value as ConnectionMode); - } - }} - variant="outline" - className="grid w-full grid-cols-1 gap-3 sm:grid-cols-2" - spacing={3} - > - - - - - {t('bots.connectionWebhook')} - - - {t('bots.connectionWebhookDescription')} - - - - - - - - {t('bots.connectionPersistent')} - - - {t('bots.connectionPersistentDescription')} - - - - -
- )} - - {showDynamicForm && dynamicFormConfigList.length > 0 && ( -
- { - form.setValue('adapter_config', values, { - shouldDirty: !isInitializing.current, - }); - }} - systemContext={{ - webhook_url: webhookUrl, - extra_webhook_url: extraWebhookUrl, - bot_uuid: initBotId || '', - adapter_config: form.getValues('adapter_config') || {}, - outbound_ips: systemInfo.outbound_ips, - }} - /> -
- )} -
-
- - {/* Card 3: Event Routing */} - {currentAdapter && ( + {/* Card 2: Event Routing */} + {initBotId && currentAdapter && ( (function BotForm( ); diff --git a/web/src/app/home/components/guided-tour/GuidedTour.tsx b/web/src/app/home/components/guided-tour/GuidedTour.tsx index 6f8692f15..fdc22fdf3 100644 --- a/web/src/app/home/components/guided-tour/GuidedTour.tsx +++ b/web/src/app/home/components/guided-tour/GuidedTour.tsx @@ -1,6 +1,6 @@ import { createPortal } from 'react-dom'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Check, ChevronRight, ExternalLink, LockKeyhole } from 'lucide-react'; +import { Check, ChevronRight, ExternalLink, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; @@ -24,9 +24,6 @@ export interface GuidedTourStep { target: string; title: string; description: string; - complete?: boolean; - advanceOnComplete?: boolean; - requirement?: string; action?: { href: string; label: string; @@ -78,14 +75,9 @@ export default function GuidedTour({ useState(null); const popoverRef = useRef(null); const previousStorageKeyRef = useRef(storageKey); - const previousCompletionRef = useRef<{ - stepId: string; - complete: boolean; - } | null>(null); const activeIndex = steps.findIndex((step) => step.id === activeStepId); const activeStep = activeIndex >= 0 ? steps[activeIndex] : undefined; - const isComplete = activeStep?.complete !== false; const isPopoverPositioned = popoverPosition !== null; useEffect(() => { @@ -105,15 +97,7 @@ export default function GuidedTour({ } const currentIndex = steps.findIndex((step) => step.id === activeStepId); - const firstIncompleteIndex = steps.findIndex( - (step) => step.complete === false, - ); - const nextIndex = - currentIndex < 0 - ? 0 - : firstIncompleteIndex >= 0 && firstIncompleteIndex < currentIndex - ? firstIncompleteIndex - : currentIndex; + const nextIndex = currentIndex < 0 ? 0 : currentIndex; if (nextIndex !== currentIndex) { const nextStep = steps[nextIndex]; @@ -208,27 +192,33 @@ export default function GuidedTour({ if (isPopoverPositioned) measure(); }, [activeStep?.id, isPopoverPositioned, measure]); + const finishTour = useCallback(() => { + setTargetRect(null); + setPopoverPosition(null); + storeProgress(storageKey, 'completed'); + setFinished(true); + setActiveStepId(null); + }, [storageKey]); + const handleNext = useCallback(() => { - if (!activeStep || !isComplete) return; + if (!activeStep) return; const nextStep = steps[activeIndex + 1]; setTargetRect(null); setPopoverPosition(null); if (!nextStep) { - storeProgress(storageKey, 'completed'); - setFinished(true); - setActiveStepId(null); + finishTour(); return; } storeProgress(storageKey, nextStep.id); setActiveStepId(nextStep.id); - }, [activeIndex, activeStep, isComplete, steps, storageKey]); + }, [activeIndex, activeStep, finishTour, steps, storageKey]); useEffect(() => { const handleNativeClick = (event: MouseEvent) => { const target = event.target; if (!(target instanceof Element)) return; const button = target.closest( - '[data-guided-tour-action="next"]', + '[data-guided-tour-action]', ); if ( !button || @@ -237,33 +227,18 @@ export default function GuidedTour({ ) { return; } - handleNext(); + if (button.dataset.guidedTourAction === 'skip') { + finishTour(); + } else if (button.dataset.guidedTourAction === 'next') { + handleNext(); + } }; // Translation extensions can rewrite nodes inside the popover and detach // React's delegated handler. Capture the command by its stable data marker. document.addEventListener('click', handleNativeClick, true); return () => document.removeEventListener('click', handleNativeClick, true); - }, [handleNext, testId]); - - useEffect(() => { - if (!activeStep) return; - const previous = previousCompletionRef.current; - previousCompletionRef.current = { - stepId: activeStep.id, - complete: isComplete, - }; - if ( - !activeStep.advanceOnComplete || - previous?.stepId !== activeStep.id || - previous.complete || - !isComplete - ) { - return; - } - - window.setTimeout(handleNext, 0); - }, [activeStep, handleNext, isComplete]); + }, [finishTour, handleNext, testId]); if ( !enabled || @@ -282,7 +257,6 @@ export default function GuidedTour({
@@ -301,19 +275,30 @@ export default function GuidedTour({ ref={popoverRef} role="dialog" aria-labelledby={titleId} - className="pointer-events-auto fixed z-[62] rounded-lg border bg-popover p-4 text-popover-foreground shadow-xl" + className="pointer-events-auto fixed z-[80] rounded-lg border bg-popover p-4 text-popover-foreground shadow-xl" style={popoverPosition} > -
+
{t('guidedTour.label')} - + {t('guidedTour.progress', { current: activeIndex + 1, total: steps.length, })} +

{activeStep.title} @@ -332,18 +317,11 @@ export default function GuidedTour({ )} - {!isComplete && activeStep.requirement && ( -
- - {activeStep.requirement} -
- )}

{canManage && ( )}
@@ -215,6 +215,7 @@ export default function KBDetailContent({ id }: { id: string }) { form="kb-form" disabled={!formDirty} className={activeTab !== 'metadata' ? 'invisible' : ''} + data-guide="knowledge-config-save" > {t('common.save')} @@ -233,16 +234,18 @@ export default function KBDetailContent({ id }: { id: string }) { {t('knowledge.metadata')} - {hasDocumentCapability() && ( + {kbInfo.initialized !== false && hasDocumentCapability() && ( {t('knowledge.documents')} )} - - - {t('knowledge.retrieve')} - + {kbInfo.initialized !== false && ( + + + {t('knowledge.retrieve')} + + )} {/* Tab: Metadata */} @@ -258,6 +261,7 @@ export default function KBDetailContent({ id }: { id: string }) { onNewKbCreated={handleNewKbCreated} onKbUpdated={handleKbUpdated} onDirtyChange={setFormDirty} + guideEnabled={canManage} /> @@ -299,7 +303,7 @@ export default function KBDetailContent({ id }: { id: string }) { {/* Tab: Documents */} - {hasDocumentCapability() && ( + {kbInfo.initialized !== false && hasDocumentCapability() && ( - - + {kbInfo.initialized !== false && ( + + + + )}
diff --git a/web/src/app/home/knowledge/components/kb-form/KBForm.tsx b/web/src/app/home/knowledge/components/kb-form/KBForm.tsx index ad6d6df81..18bb43ffa 100644 --- a/web/src/app/home/knowledge/components/kb-form/KBForm.tsx +++ b/web/src/app/home/knowledge/components/kb-form/KBForm.tsx @@ -40,7 +40,6 @@ import KnowledgeEngineSelect from './KnowledgeEngineSelect'; import GuidedTour, { GuidedTourStep, } from '@/app/home/components/guided-tour/GuidedTour'; -import { areRequiredDynamicFieldsComplete } from '@/app/home/components/guided-tour/dynamic-form-progress'; const KNOWLEDGE_ENGINE_MARKETPLACE_URL = 'https://space.langbot.app/market?type=plugin&component=KnowledgeEngine'; @@ -104,6 +103,7 @@ export default function KBForm({ Record >({}); const [isEditing, setIsEditing] = useState(Boolean(initKbId)); + const [engineInitialized, setEngineInitialized] = useState(true); const [loadFailed, setLoadFailed] = useState(false); const [loadAttempt, setLoadAttempt] = useState(0); const [initialDataLoaded, setInitialDataLoaded] = useState(false); @@ -220,11 +220,13 @@ export default function KBForm({ setConfigSettings(kb.creation_settings || {}); setRetrievalSettings(kb.retrieval_settings || {}); + setEngineInitialized(kb.initialized !== false); // Capture snapshot after a tick so dynamic forms have emitted initial values setTimeout(() => { captureSnapshot(); isInitializing.current = false; + onDirtyChange?.(kb.initialized === false); }, 500); } catch (err) { isInitializing.current = false; @@ -269,8 +271,8 @@ export default function KBForm({ }, []); const onSubmit = async (data: z.infer) => { - // Validate dynamic forms before submission - if (configValidateRef.current) { + // Engine parameters are configured only after the draft has been created. + if (initKbId && configValidateRef.current) { const configValid = await configValidateRef.current(); if (!configValid) { toast.error(t('knowledge.engineSettingsInvalid')); @@ -278,7 +280,7 @@ export default function KBForm({ } } - if (retrievalValidateRef.current) { + if (initKbId && retrievalValidateRef.current) { const retrievalValid = await retrievalValidateRef.current(); if (!retrievalValid) { toast.error(t('knowledge.retrievalSettingsInvalid')); @@ -293,12 +295,16 @@ export default function KBForm({ knowledge_engine_plugin_id: selectedEngineId, creation_settings: configSettings, retrieval_settings: retrievalSettings, + ...(initKbId + ? { initialize_engine: !engineInitialized } + : { defer_initialization: true }), }; if (initKbId) { httpClient .updateKnowledgeBase(initKbId, kbData) .then((res) => { + setEngineInitialized(true); captureSnapshot(); onDirtyChange?.(false); onKbUpdated(res.uuid); @@ -339,21 +345,11 @@ export default function KBForm({ const guideSteps = useMemo(() => { const steps: GuidedTourStep[] = [ - { - id: 'basic', - target: '[data-guide="knowledge-basic"]', - title: t('guidedTour.knowledge.basic.title'), - description: t('guidedTour.knowledge.basic.description'), - complete: Boolean(watchedFormValues.name?.trim()), - requirement: t('guidedTour.knowledge.basic.requirement'), - }, { id: 'engine', target: '[data-guide="knowledge-engine"]', title: t('guidedTour.knowledge.engine.title'), description: t('guidedTour.knowledge.engine.description'), - complete: Boolean(selectedEngineId), - requirement: t('guidedTour.knowledge.engine.requirement'), action: { href: KNOWLEDGE_ENGINE_MARKETPLACE_URL, label: t('guidedTour.knowledge.engine.action'), @@ -367,12 +363,6 @@ export default function KBForm({ target: '[data-guide="knowledge-engine-parameters"]', title: t('guidedTour.knowledge.parameters.title'), description: t('guidedTour.knowledge.parameters.description'), - complete: areRequiredDynamicFieldsComplete( - configFormItems, - configSettings, - retrievalSettings, - ), - requirement: t('guidedTour.knowledge.parameters.requirement'), }); } @@ -382,31 +372,17 @@ export default function KBForm({ target: '[data-guide="knowledge-retrieval"]', title: t('guidedTour.knowledge.retrieval.title'), description: t('guidedTour.knowledge.retrieval.description'), - complete: areRequiredDynamicFieldsComplete( - retrievalFormItems, - retrievalSettings, - configSettings, - ), - requirement: t('guidedTour.knowledge.retrieval.requirement'), }); } steps.push({ - id: 'submit', - target: '[data-guide="knowledge-submit"]', - title: t('guidedTour.knowledge.submit.title'), - description: t('guidedTour.knowledge.submit.description'), + id: 'save', + target: '[data-guide="knowledge-config-save"]', + title: t('guidedTour.knowledge.save.title'), + description: t('guidedTour.knowledge.save.description'), }); return steps; - }, [ - configFormItems, - configSettings, - retrievalFormItems, - retrievalSettings, - selectedEngineId, - t, - watchedFormValues.name, - ]); + }, [configFormItems, retrievalFormItems, t]); if (loadFailed) return ( @@ -529,7 +505,7 @@ export default function KBForm({ )} /> - {configFormItems.length > 0 && ( + {isEditing && configFormItems.length > 0 && (
setConfigSettings(val as Record) } - isEditing={isEditing} + isEditing={engineInitialized} externalDependentValues={retrievalSettings} onValidate={(validateFn) => (configValidateRef.current = validateFn) @@ -553,7 +529,7 @@ export default function KBForm({ {/* Retrieval Settings (dynamic form from retrieval_schema) */} - {retrievalFormItems.length > 0 && ( + {isEditing && retrievalFormItems.length > 0 && ( {t('knowledge.retrievalSettings')} @@ -578,10 +554,10 @@ export default function KBForm({ )} ); diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index 4d7926593..22197170a 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -431,6 +431,9 @@ export interface KnowledgeBase { knowledge_engine_plugin_id?: string; creation_settings?: Record; retrieval_settings?: Record; + initialized?: boolean; + defer_initialization?: boolean; + initialize_engine?: boolean; knowledge_engine?: KnowledgeEngineInfo; } diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index d1b66c66b..83087bc1b 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -52,6 +52,7 @@ const enUS = { progress: '{{current}} of {{total}}', next: 'Next', finish: 'Finish', + skip: 'Skip', bot: { connection: { title: 'Choose a connection method', @@ -68,7 +69,7 @@ const enUS = { adapter: { title: 'Choose a platform adapter', description: - 'Choose the platform first. LangBot will then show whether this adapter supports Webhook, persistent connection, or both.', + 'Choose the platform adapter for this bot. Connection methods and platform parameters are configured after creation.', requirement: 'Select an adapter to continue.', }, parameters: { @@ -81,12 +82,17 @@ const enUS = { routing: { title: 'Route incoming events', description: - 'Choose which processor handles each event. You can add more routes after the bot is created.', + 'Choose which processor handles each event received by this bot.', + }, + save: { + title: 'Save the bot configuration', + description: + 'Save after checking the connection parameters and event routing. You can then enable the bot when it is ready.', }, submit: { title: 'Create the bot', description: - 'Create the bot to apply the connection. Webhook URLs generated by LangBot are shown in the saved bot configuration.', + 'Create a disabled bot, then continue with its connection method and platform parameters on the bot page.', }, }, processorCreate: { @@ -136,9 +142,9 @@ const enUS = { requirement: 'Enter a knowledge base name to continue.', }, engine: { - title: 'Choose or install an engine', + title: 'Review the knowledge engine', description: - 'Select an installed knowledge engine, or install one from the Marketplace section in this selector.', + 'Confirm the engine used by this knowledge base. Its parameters and retrieval settings are configured below.', requirement: 'Select a knowledge engine to continue.', action: 'Browse Knowledge Engine Marketplace', }, @@ -154,6 +160,11 @@ const enUS = { 'Set how this engine searches and returns relevant content to processors.', requirement: 'Complete every visible required retrieval parameter.', }, + save: { + title: 'Save the knowledge base configuration', + description: + 'Save after checking the engine parameters and retrieval settings.', + }, submit: { title: 'Create the knowledge base', description: @@ -570,7 +581,7 @@ const enUS = { getBotConfigError: 'Failed to get bot configuration: ', saveSuccess: 'Saved successfully', saveError: 'Save failed: ', - createSuccess: 'Created successfully. Please configure event routing', + createSuccess: 'Created successfully. Continue configuring the bot', createError: 'Creation failed: ', deleteSuccess: 'Deleted successfully', deleteError: 'Delete failed: ', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 29701f833..321b5d91f 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -48,6 +48,7 @@ const zhHans = { progress: '第 {{current}} 步,共 {{total}} 步', next: '下一步', finish: '完成引导', + skip: '跳过', bot: { connection: { title: '选择接入方式', @@ -63,7 +64,7 @@ const zhHans = { adapter: { title: '选择平台适配器', description: - '先选择平台,LangBot 会再判断该适配器支持 Webhook、长连接或两者都支持。', + '选择机器人使用的平台适配器;接入方式和平台参数将在创建后配置。', requirement: '请选择一个适配器。', }, parameters: { @@ -75,13 +76,16 @@ const zhHans = { }, routing: { title: '设置事件路由', - description: - '选择各类事件交给哪个处理器;机器人创建后仍可继续添加路由。', + description: '选择该机器人收到的各类事件交给哪个处理器。', + }, + save: { + title: '保存机器人配置', + description: '确认接入参数和事件路由后保存;准备完成后即可启用机器人。', }, submit: { title: '创建机器人', description: - '创建后连接配置才会生效;LangBot 生成的 Webhook 地址会显示在已保存机器人的配置中。', + '先创建未启用的机器人,再到机器人页面配置接入方式和平台参数。', }, }, processorCreate: { @@ -130,9 +134,8 @@ const zhHans = { requirement: '请先填写知识库名称。', }, engine: { - title: '选择或安装知识引擎', - description: - '选择已安装的知识引擎,也可以在此选择器的市场区域直接安装。', + title: '确认知识引擎', + description: '确认该知识库使用的引擎,并在下方配置引擎参数和检索方式。', requirement: '请选择一个知识引擎。', action: '浏览知识引擎市场', }, @@ -146,6 +149,10 @@ const zhHans = { description: '设置引擎如何搜索内容,以及如何把相关结果返回给处理器。', requirement: '请填写当前可见的全部必填检索参数。', }, + save: { + title: '保存知识库配置', + description: '确认引擎参数和检索设置后保存配置。', + }, submit: { title: '创建知识库', description: '创建后即可添加文档,或连接所选引擎支持的外部知识源。', @@ -537,7 +544,7 @@ const zhHans = { getBotConfigError: '获取机器人配置失败:', saveSuccess: '保存成功', saveError: '保存失败:', - createSuccess: '创建成功,请配置事件路由', + createSuccess: '创建成功,请继续配置机器人', createError: '创建失败:', deleteSuccess: '删除成功', deleteError: '删除失败:', diff --git a/web/tests/e2e/bot-save-errors.spec.ts b/web/tests/e2e/bot-save-errors.spec.ts index fd2da4ea9..abb0a6a31 100644 --- a/web/tests/e2e/bot-save-errors.spec.ts +++ b/web/tests/e2e/bot-save-errors.spec.ts @@ -22,8 +22,10 @@ for (const failure of [ test(`bot save displays actionable ${failure.code}`, async ({ page }) => { await installLangBotApiMocks(page, { authenticated: true }); await page.goto('/home/bots?id=new'); - await page.getByRole('combobox').click(); - await page.getByRole('option', { name: 'Playwright Adapter' }).click(); + await page + .getByTestId('adapter-gallery') + .getByRole('button', { name: /Playwright Adapter/ }) + .click(); await page.locator('input[name="name"]').fill('Error Test Bot'); await page.getByRole('button', { name: /^Submit$/ }).click(); await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/); @@ -36,9 +38,9 @@ for (const failure of [ }); }); await page.getByRole('button', { name: 'Edit basic information' }).click(); - const dialog = page.getByRole('dialog'); - await dialog.getByLabel('Name', { exact: true }).fill('Edited Bot'); - await dialog.getByRole('button', { name: /^Save$/ }).click(); + const editDialog = page.getByRole('dialog'); + await editDialog.getByLabel('Name', { exact: true }).fill('Edited Bot'); + await editDialog.getByRole('button', { name: /^Save$/ }).click(); if (failure.code === 'internal_error') { await expect( page.getByText('Error reference: bot-save-test-reference'), diff --git a/web/tests/e2e/contextual-guides.spec.ts b/web/tests/e2e/contextual-guides.spec.ts index 7b9f112d5..80691a4e0 100644 --- a/web/tests/e2e/contextual-guides.spec.ts +++ b/web/tests/e2e/contextual-guides.spec.ts @@ -2,54 +2,111 @@ import { expect, test } from '@playwright/test'; import { installLangBotApiMocks } from './fixtures/langbot-api'; -test('knowledge setup guide advances only after required state is complete', async ({ +const categorizedAdapters = [ + ['popular-adapter', 'Popular Adapter', 'popular', false], + ['china-adapter', 'China Adapter', 'china', false], + ['global-adapter', 'Global Adapter', 'global', false], + ['protocol-adapter', 'Protocol Adapter', 'protocol', false], + ['legacy-adapter', 'Legacy Adapter', 'protocol', true], +].map(([name, label, category, legacy]) => ({ + name, + label: { en_US: label, zh_Hans: label }, + description: { + en_US: `${label} description`, + zh_Hans: `${label} description`, + }, + spec: { categories: [category], legacy, config: [] }, +})); + +test('knowledge guide starts only after creation opens the detail page', async ({ page, }) => { await installLangBotApiMocks(page, { authenticated: true, - storage: { langbot_knowledge_create_guide_v1: 'basic' }, + storage: { langbot_knowledge_detail_guide_v1: 'engine' }, }); + await page.route('**/api/v1/knowledge/engines', (route) => + route.fulfill({ + json: { + code: 0, + data: { + engines: [ + { + plugin_id: 'builtin/minimal-knowledge', + name: { en_US: 'Guided Knowledge Engine' }, + description: { en_US: 'Engine with detail-page setup.' }, + capabilities: ['text_retrieval'], + creation_schema: [ + { + name: 'endpoint', + label: { en_US: 'Engine endpoint' }, + type: 'text', + required: true, + default: '', + }, + ], + retrieval_schema: [], + }, + ], + }, + }, + }), + ); await page.goto('/home/knowledge?id=new'); - const guide = page.getByTestId('knowledge-create-guide'); + await expect(page.getByTestId('knowledge-detail-guide')).toHaveCount(0); await expect( - guide.getByRole('heading', { name: 'Describe the knowledge base' }), - ).toBeVisible(); - await expect(guide.getByRole('button', { name: 'Next' })).toBeDisabled(); - + page.locator('[data-guide="knowledge-engine-parameters"]'), + ).toHaveCount(0); + await expect(page.locator('[data-guide="knowledge-retrieval"]')).toHaveCount( + 0, + ); await page.locator('input[name="name"]').fill('Guided Knowledge'); + await page.getByRole('button', { name: /^Save$/ }).click(); + + await expect(page).toHaveURL(/\/home\/knowledge\?id=knowledge-1$/); + const guide = page.getByTestId('knowledge-detail-guide'); + await expect( + guide.getByRole('heading', { name: 'Review the knowledge engine' }), + ).toBeVisible(); await expect(guide.getByRole('button', { name: 'Next' })).toBeEnabled(); await guide.getByRole('button', { name: 'Next' }).click(); await expect( - guide.getByRole('heading', { name: 'Choose or install an engine' }), + guide.getByRole('heading', { name: 'Configure engine parameters' }), ).toBeVisible(); - await expect( - guide.getByRole('link', { name: /Marketplace/ }), - ).toHaveAttribute('target', '_blank'); + await expect(guide.getByRole('button', { name: 'Next' })).toBeEnabled(); await guide.getByRole('button', { name: 'Next' }).click(); await expect( - guide.getByRole('heading', { name: 'Create the knowledge base' }), + guide.getByRole('heading', { + name: 'Save the knowledge base configuration', + }), ).toBeVisible(); + await page + .locator('[data-guide="knowledge-engine-parameters"]') + .getByRole('textbox') + .fill('https://example.invalid'); + await page.getByRole('button', { name: /^Save$/ }).click(); + await expect(page.getByRole('tab', { name: 'Retrieve' })).toBeVisible(); await guide.getByRole('button', { name: 'Finish' }).click(); await expect(guide).toHaveCount(0); await expect .poll(() => page.evaluate(() => - localStorage.getItem('langbot_knowledge_create_guide_v1'), + localStorage.getItem('langbot_knowledge_detail_guide_v1'), ), ) .toBe('completed'); }); -test('bot setup guide chooses the adapter before its connection method', async ({ +test('bot guide starts on the detail page after adapter-only creation', async ({ page, }) => { await installLangBotApiMocks(page, { authenticated: true, - storage: { langbot_bot_create_guide_v4: 'basic' }, + storage: { langbot_bot_detail_guide_v1: 'connection' }, }); await page.route('**/api/v1/platform/adapters', (route) => route.fulfill({ @@ -96,92 +153,167 @@ test('bot setup guide chooses the adapter before its connection method', async ( await page.goto('/home/bots?id=new'); - const guide = page.getByTestId('bot-create-guide'); - await expect( - guide.getByRole('heading', { name: 'Name this bot' }), - ).toBeVisible(); - await expect(guide.getByRole('button', { name: 'Next' })).toBeDisabled(); + await expect(page.getByTestId('bot-detail-guide')).toHaveCount(0); + await page + .getByTestId('adapter-gallery') + .getByRole('button', { name: /Dual Mode Adapter/ }) + .click(); + await page.locator('input[name="name"]').fill('Guided Bot'); - await guide.getByRole('button', { name: 'Next' }).click(); - - await expect( - guide.getByRole('heading', { name: 'Choose a platform adapter' }), - ).toBeVisible(); - await page.getByRole('combobox').click(); - await page.getByRole('option', { name: 'Dual Mode Adapter' }).click(); + await page + .locator('input[name="description"]') + .fill('Created before configuration.'); + await page.getByRole('button', { name: /^Submit$/ }).click(); + await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/); + await expect(page.getByRole('heading', { name: 'Guided Bot' })).toBeVisible(); + const guide = page.getByTestId('bot-detail-guide'); await expect( guide.getByRole('heading', { name: 'Choose a connection method' }), ).toBeVisible(); - await expect(page.getByRole('radio', { name: /^Webhook/ })).not.toBeChecked(); + await expect( + page.getByRole('radio', { name: /^Persistent connection/ }), + ).toBeChecked(); await page.getByRole('radio', { name: /^Webhook/ }).click(); + const adapterCard = page.locator('[data-slot="card"]').filter({ + has: page.getByText('Adapter Configuration', { exact: true }), + }); + await expect(adapterCard.getByRole('switch')).toBeChecked(); + await guide.getByRole('button', { name: 'Next' }).click(); await expect( guide.getByRole('heading', { name: 'Configure the platform' }), ).toBeVisible(); - await expect(page.getByRole('switch')).toBeChecked(); - await page.getByRole('radio', { name: /^Persistent connection/ }).click(); - await expect(page.getByRole('switch')).not.toBeChecked(); - await page.getByRole('radio', { name: /^Webhook/ }).click(); await guide.getByRole('button', { name: 'Next' }).click(); await expect( guide.getByRole('heading', { name: 'Route incoming events' }), ).toBeVisible(); await guide.getByRole('button', { name: 'Next' }).click(); + await expect( - guide.getByRole('heading', { name: 'Create the bot' }), + guide.getByRole('heading', { name: 'Save the bot configuration' }), ).toBeVisible(); await guide.getByRole('button', { name: 'Finish' }).click(); await expect(guide).toHaveCount(0); await expect .poll(() => - page.evaluate(() => localStorage.getItem('langbot_bot_create_guide_v4')), + page.evaluate(() => localStorage.getItem('langbot_bot_detail_guide_v1')), ) .toBe('completed'); - await page.waitForTimeout(500); - await expect(guide).toHaveCount(0); - await expect(page.locator('input[name="name"]')).toHaveValue('Guided Bot'); await page.reload(); await expect(guide).toHaveCount(0); }); -test('bot setup guide restores missing basic info before a saved adapter step', async ({ - page, -}) => { +test('bot creation page never renders a contextual guide', async ({ page }) => { await page.setViewportSize({ width: 1645, height: 478 }); await installLangBotApiMocks(page, { authenticated: true, language: 'zh-Hans', - storage: { langbot_bot_create_guide_v4: 'adapter' }, + storage: { langbot_bot_detail_guide_v1: 'connection' }, }); + await page.route('**/api/v1/platform/adapters', (route) => + route.fulfill({ + json: { + code: 0, + data: { + adapters: categorizedAdapters, + }, + }, + }), + ); await page.goto('/home/bots?id=new'); - const guide = page.getByTestId('bot-create-guide'); - await expect(guide).toHaveAttribute('data-active-step', 'basic'); - await expect(guide).toHaveAttribute('data-step-complete', 'false'); - await page.locator('input[name="name"]').fill('test'); - await expect(guide).toHaveAttribute('data-step-complete', 'true'); + await expect(page.getByTestId('bot-detail-guide')).toHaveCount(0); + await expect(page.locator('[data-guide="bot-adapter"]')).toBeVisible(); + await expect(page.locator('[data-guide="bot-basic"]')).toBeVisible(); + for (const category of ['popular', 'china', 'global', 'protocol', 'legacy']) { + await expect( + page.locator(`[data-adapter-category="${category}"]`), + ).toBeVisible(); + } + await expect(page.getByRole('button', { name: /^提交$/ })).toHaveCount(0); +}); - const nextButton = guide.getByRole('button', { name: '下一步' }); - await nextButton.evaluate((button) => { - button.replaceWith(button.cloneNode(true)); +test('bot adapter gallery keeps categories and fits desktop and mobile', async ({ + page, +}) => { + await installLangBotApiMocks(page, { + authenticated: true, }); - const buttonBox = await nextButton.boundingBox(); - expect(buttonBox).not.toBeNull(); - await page.mouse.click( - buttonBox!.x + buttonBox!.width / 2, - buttonBox!.y + buttonBox!.height / 2, + await page.route('**/api/v1/platform/adapters', (route) => + route.fulfill({ + json: { + code: 0, + data: { adapters: categorizedAdapters }, + }, + }), ); - await expect(guide).toHaveAttribute('data-active-step', 'adapter'); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto('/home/bots?id=new'); + + const gallery = page.getByTestId('adapter-gallery'); + const basicInfo = page.locator('[data-guide="bot-basic"]'); + await expect(gallery).toBeVisible(); + await expect(basicInfo).toBeVisible(); + for (const category of ['popular', 'global', 'china', 'protocol', 'legacy']) { + await expect( + gallery.locator(`[data-adapter-category="${category}"]`), + ).toBeVisible(); + } + await expect( + gallery.getByRole('button', { name: /Legacy Adapter/ }), + ).toHaveCount(0); + const desktopBasicBox = await basicInfo.boundingBox(); + const desktopGalleryBox = await gallery.boundingBox(); + expect(desktopBasicBox).not.toBeNull(); + expect(desktopGalleryBox).not.toBeNull(); + expect(desktopBasicBox!.x + desktopBasicBox!.width).toBeLessThan( + desktopGalleryBox!.x, + ); + + await page.setViewportSize({ width: 390, height: 844 }); + const mobileBasicBox = await basicInfo.boundingBox(); + const mobileGalleryBox = await gallery.boundingBox(); + expect(mobileBasicBox).not.toBeNull(); + expect(mobileGalleryBox).not.toBeNull(); + expect(mobileBasicBox!.y + mobileBasicBox!.height).toBeLessThanOrEqual( + mobileGalleryBox!.y, + ); await expect .poll(() => - page.evaluate(() => localStorage.getItem('langbot_bot_create_guide_v4')), + page.evaluate( + () => + document.documentElement.scrollWidth <= + document.documentElement.clientWidth, + ), ) - .toBe('adapter'); + .toBe(true); + + await gallery.getByRole('button', { name: /Legacy adapters/ }).click(); + await expect( + gallery.getByRole('button', { name: /Legacy Adapter/ }), + ).toBeVisible(); + await gallery.getByRole('button', { name: /Popular Adapter/ }).click(); + + await expect(gallery).toBeVisible(); + await expect( + gallery.getByRole('button', { name: /Popular Adapter/ }), + ).toHaveAttribute('aria-pressed', 'true'); + await expect(page.getByRole('button', { name: /^Submit$/ })).toBeVisible(); + await expect(page.locator('input[name="name"]')).toBeVisible(); + await expect + .poll(() => + page.evaluate( + () => + document.documentElement.scrollWidth <= + document.documentElement.clientWidth, + ), + ) + .toBe(true); }); test('runner guide covers selection, parameters, and event tools', async ({ @@ -201,6 +333,35 @@ test('runner guide covers selection, parameters, and event tools', async ({ await expect( guide.getByRole('link', { name: /Runner Marketplace/ }), ).toHaveAttribute('target', '_blank'); + + await page + .locator('[data-guide="runner-selector"]') + .getByRole('combobox') + .click(); + const runnerOptions = page.locator('[data-slot="select-content"]'); + await expect(runnerOptions).toBeVisible(); + await expect + .poll(() => + runnerOptions.evaluate((element) => + Number.parseInt(getComputedStyle(element).zIndex, 10), + ), + ) + .toBeGreaterThan(61); + const runnerOptionsZIndex = await runnerOptions.evaluate((element) => + Number.parseInt(getComputedStyle(element).zIndex, 10), + ); + await expect + .poll(() => + guide.evaluate((element) => { + const popover = element.querySelector('[role="dialog"]'); + return popover + ? Number.parseInt(getComputedStyle(popover).zIndex, 10) + : 0; + }), + ) + .toBeGreaterThan(runnerOptionsZIndex); + await page.keyboard.press('Escape'); + await guide.getByRole('button', { name: 'Next' }).click(); await expect( @@ -211,38 +372,48 @@ test('runner guide covers selection, parameters, and event tools', async ({ await expect( guide.getByRole('heading', { name: 'Set events and tools' }), ).toBeVisible(); - await expect(guide.getByRole('button', { name: 'Finish' })).toBeDisabled(); - await page.getByRole('tab', { name: 'Events & tools' }).click(); await expect(guide.getByRole('button', { name: 'Finish' })).toBeEnabled(); await guide.getByRole('button', { name: 'Finish' }).click(); await expect(guide).toHaveCount(0); }); -test('processor creation guide explains the type before required details', async ({ +test('detail guide can be skipped from the popover corner', async ({ page, }) => { await installLangBotApiMocks(page, { authenticated: true, - storage: { langbot_processor_create_guide_v1: 'type' }, + storage: { langbot_runner_setup_guide_v1: 'runner' }, + }); + + await page.goto('/home/agents?id=agent-guide'); + + const guide = page.getByTestId('runner-setup-guide'); + await expect(guide.getByRole('button', { name: 'Skip' })).toBeVisible(); + await guide.getByRole('button', { name: 'Skip' }).click(); + await expect(guide).toHaveCount(0); + await expect + .poll(() => + page.evaluate(() => + localStorage.getItem('langbot_runner_setup_guide_v1'), + ), + ) + .toBe('completed'); + + await page.reload(); + await expect(guide).toHaveCount(0); +}); + +test('processor creation page does not render the runner detail guide', async ({ + page, +}) => { + await installLangBotApiMocks(page, { + authenticated: true, + storage: { langbot_runner_setup_guide_v1: 'runner' }, }); await page.goto('/home/agents?id=new'); - const guide = page.getByTestId('processor-create-guide'); - await expect( - guide.getByRole('heading', { name: 'Choose a processor type' }), - ).toBeVisible(); - await guide.getByRole('button', { name: 'Next' }).click(); - - await expect( - guide.getByRole('heading', { name: 'Name the processor' }), - ).toBeVisible(); - await expect(guide.getByRole('button', { name: 'Next' })).toBeDisabled(); - await page.locator('input[name="name"]').fill('Guided Processor'); - await expect(guide.getByRole('button', { name: 'Next' })).toBeEnabled(); - await guide.getByRole('button', { name: 'Next' }).click(); - - await expect( - guide.getByRole('heading', { name: 'Create and continue setup' }), - ).toBeVisible(); + await expect(page.getByTestId('runner-setup-guide')).toHaveCount(0); + await expect(page.getByRole('button', { name: /^Submit$/ })).toBeVisible(); + await expect(page.locator('input[name="name"]')).toBeVisible(); }); diff --git a/web/tests/e2e/crud-smoke.spec.ts b/web/tests/e2e/crud-smoke.spec.ts index fd7a4e0c3..5fa7f155f 100644 --- a/web/tests/e2e/crud-smoke.spec.ts +++ b/web/tests/e2e/crud-smoke.spec.ts @@ -16,8 +16,19 @@ async function submit(page: Page) { } async function selectPlaywrightAdapter(page: Page) { - await page.getByRole('combobox').click(); - await page.getByRole('option', { name: 'Playwright Adapter' }).click(); + await page + .getByTestId('adapter-gallery') + .getByRole('button', { name: /Playwright Adapter/ }) + .click(); +} + +async function createPlaywrightBot(page: Page, name: string, description = '') { + await selectPlaywrightAdapter(page); + await page.locator('input[name="name"]').fill(name); + if (description) { + await page.locator('input[name="description"]').fill(description); + } + await submit(page); } async function confirmDelete(page: Page) { @@ -107,12 +118,11 @@ test.describe('frontend CRUD smoke flows', () => { }); await page.goto('/home/bots?id=new'); - await selectPlaywrightAdapter(page); - await page.locator('input[name="name"]').fill('Viewer Test Bot'); - await page - .locator('input[name="description"]') - .fill('Proves monitoring is ordinary resource visibility.'); - await submit(page); + await createPlaywrightBot( + page, + 'Viewer Test Bot', + 'Proves monitoring is ordinary resource visibility.', + ); await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/); await page.goto('/home/agents?id=new'); @@ -152,14 +162,22 @@ test.describe('frontend CRUD smoke flows', () => { await installLangBotApiMocks(page, { authenticated: true }); await page.goto('/home/bots?id=new'); - await selectPlaywrightAdapter(page); - - await expect(page.locator('input[name="name"]')).toBeVisible(); - await page.locator('input[name="name"]').fill('Support Bot'); - await page - .locator('input[name="description"]') - .fill('Answers customer support questions.'); - await submit(page); + const createRequest = page.waitForRequest( + (request) => + request.method() === 'POST' && + request.url().endsWith('/api/v1/platform/bots'), + ); + await createPlaywrightBot( + page, + 'Support Bot', + 'Answers customer support questions.', + ); + expect((await createRequest).postDataJSON()).toMatchObject({ + name: 'Support Bot', + description: 'Answers customer support questions.', + adapter: 'playwright-adapter', + enable: false, + }); await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/); await page.reload(); @@ -293,7 +311,7 @@ test.describe('frontend CRUD smoke flows', () => { await page .locator('input[name="description"]') .fill('Source material for support answers.'); - await submit(page); + await save(page); await expect(page).toHaveURL(/\/home\/knowledge\?id=knowledge-1$/); await page.reload(); @@ -481,9 +499,7 @@ test.describe('bot advanced flows', () => { }); }); await page.goto('/home/bots?id=new'); - await selectPlaywrightAdapter(page); - await page.locator('input[name="name"]').fill('Route Status Bot'); - await submit(page); + await createPlaywrightBot(page, 'Route Status Bot'); await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/); await expect(page.getByText('Supported events')).toBeVisible(); @@ -629,14 +645,14 @@ test.describe('bot advanced flows', () => { ).toBeVisible(); }); - test('toggles bot enable/disable state', async ({ page }) => { + test('creates a disabled bot and enables it from the detail page', async ({ + page, + }) => { await installLangBotApiMocks(page, { authenticated: true }); // Create a bot first await page.goto('/home/bots?id=new'); - await selectPlaywrightAdapter(page); - await page.locator('input[name="name"]').fill('Toggle Test Bot'); - await submit(page); + await createPlaywrightBot(page, 'Toggle Test Bot'); await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/); @@ -645,16 +661,16 @@ test.describe('bot advanced flows', () => { timeout: 5000, }); - // Verify initial state is enabled - await expect(page.locator('#bot-enable-switch')).toBeChecked(); - - // Toggle to disabled - await page.locator('#bot-enable-switch').click(); + // Draft bots remain disabled until their adapter configuration is ready. await expect(page.locator('#bot-enable-switch')).not.toBeChecked(); + // Enable after reaching the detail page. + await page.locator('#bot-enable-switch').click(); + await expect(page.locator('#bot-enable-switch')).toBeChecked(); + // Reload and verify state persisted await page.reload(); - await expect(page.locator('#bot-enable-switch')).not.toBeChecked(); + await expect(page.locator('#bot-enable-switch')).toBeChecked(); }); test('switches between bot detail tabs', async ({ page }) => { @@ -662,9 +678,7 @@ test.describe('bot advanced flows', () => { // Create a bot await page.goto('/home/bots?id=new'); - await selectPlaywrightAdapter(page); - await page.locator('input[name="name"]').fill('Tab Test Bot'); - await submit(page); + await createPlaywrightBot(page, 'Tab Test Bot'); // Verify we're on the Configuration tab await expect( @@ -700,9 +714,7 @@ test.describe('bot advanced flows', () => { // Create a bot await page.goto('/home/bots?id=new'); - await selectPlaywrightAdapter(page); - await page.locator('input[name="name"]').fill('Clean Form Bot'); - await submit(page); + await createPlaywrightBot(page, 'Clean Form Bot'); await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/); // Reload the persisted record so post-create initialization has completed. @@ -727,11 +739,10 @@ test.describe('bot advanced flows', () => { await page.goto('/home/bots?id=new'); - // Select adapter but leave name empty + // Select an adapter but leave the required name empty. await selectPlaywrightAdapter(page); await submit(page); - // Should show validation error for name (zod validation) await expect(page.getByText(/cannot be empty/i)).toBeVisible(); await expect(page).toHaveURL(/\/home\/bots\?id=new$/); }); @@ -1325,9 +1336,7 @@ test.describe('cross-resource flows', () => { }); await page.goto('/home/bots?id=new'); - await selectPlaywrightAdapter(page); - await page.locator('input[name="name"]').fill('Routing Bot'); - await submit(page); + await createPlaywrightBot(page, 'Routing Bot'); await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/); await page.getByRole('button', { name: 'Add behavior' }).click(); @@ -1412,9 +1421,7 @@ test.describe('cross-resource flows', () => { // Create a bot await page.goto('/home/bots?id=new'); - await selectPlaywrightAdapter(page); - await page.locator('input[name="name"]').fill('Bound Bot'); - await submit(page); + await createPlaywrightBot(page, 'Bound Bot'); await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/); // Wait for form to fully load diff --git a/web/tests/e2e/fixtures/langbot-api.ts b/web/tests/e2e/fixtures/langbot-api.ts index 4074cce3c..c6c137523 100644 --- a/web/tests/e2e/fixtures/langbot-api.ts +++ b/web/tests/e2e/fixtures/langbot-api.ts @@ -30,6 +30,7 @@ interface KnowledgeBaseMock { knowledge_engine_plugin_id: string; creation_settings: JsonRecord; retrieval_settings: JsonRecord; + initialized: boolean; knowledge_engine: { plugin_id: string; name: { @@ -465,6 +466,10 @@ function makeKnowledgeBase( creation_settings: (data.creation_settings as JsonRecord | undefined) || {}, retrieval_settings: (data.retrieval_settings as JsonRecord | undefined) || {}, + initialized: + data.initialized === false || data.defer_initialization === true + ? false + : true, knowledge_engine: { plugin_id: engine.plugin_id, name: engine.name, @@ -896,7 +901,18 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) { const baseId = decodeURIComponent(knowledgeBaseMatch[1]); if (method === 'PUT') { - const base = makeKnowledgeBase(state, parseJsonBody(route), baseId); + const current = state.knowledgeBases.find((item) => item.uuid === baseId); + const payload = parseJsonBody(route); + const base = makeKnowledgeBase( + state, + { + ...(current || {}), + ...payload, + initialized: + payload.initialize_engine === true ? true : current?.initialized, + }, + baseId, + ); state.knowledgeBases = [ ...state.knowledgeBases.filter((item) => item.uuid !== baseId), base, @@ -1299,10 +1315,9 @@ export async function installLangBotApiMocks( localStorage.setItem('langbot_sidebar_guide_v1', 'completed'); } const contextualGuides = [ - 'langbot_bot_create_guide_v4', - 'langbot_processor_create_guide_v1', + 'langbot_bot_detail_guide_v1', 'langbot_runner_setup_guide_v1', - 'langbot_knowledge_create_guide_v1', + 'langbot_knowledge_detail_guide_v1', ]; for (const guideKey of contextualGuides) { if (!Object.hasOwn(storage, guideKey)) { diff --git a/web/tests/e2e/plugin-subscriptions.spec.ts b/web/tests/e2e/plugin-subscriptions.spec.ts index 11bb885dc..61a1bdb46 100644 --- a/web/tests/e2e/plugin-subscriptions.spec.ts +++ b/web/tests/e2e/plugin-subscriptions.spec.ts @@ -56,8 +56,10 @@ test('creates a configured processor, persists subscriptions separately and reus return route.fulfill({ json: { code: 0, data: { agents: processors } } }); }); await page.goto('/home/bots?id=new'); - await page.getByRole('combobox').click(); - await page.getByRole('option', { name: 'Playwright Adapter' }).click(); + await page + .getByTestId('adapter-gallery') + .getByRole('button', { name: /Playwright Adapter/ }) + .click(); await page.locator('input[name="name"]').fill('Subscription Bot'); await page.getByRole('button', { name: /^Submit$/ }).click(); await expect(page).toHaveURL(/id=bot-1$/); diff --git a/web/tests/e2e/wizard-platform-regressions.spec.ts b/web/tests/e2e/wizard-platform-regressions.spec.ts index 8b50d61b3..280dcb3ce 100644 --- a/web/tests/e2e/wizard-platform-regressions.spec.ts +++ b/web/tests/e2e/wizard-platform-regressions.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test'; +import { expect, Page, test } from '@playwright/test'; import fs from 'node:fs'; import path from 'node:path'; @@ -80,6 +80,16 @@ function adapterWithQrLogin( }; } +async function createDraftBot(page: Page, adapterLabel: string) { + await page + .getByTestId('adapter-gallery') + .getByRole('button', { name: new RegExp(adapterLabel) }) + .click(); + await page.locator('input[name="name"]').fill(`${adapterLabel} Bot`); + await page.getByRole('button', { name: /^Submit$/ }).click(); + await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/); +} + test.describe('wizard and QR platform regressions', () => { test('opens the Page Bot test panel after the first and every later save', async ({ page, @@ -476,8 +486,7 @@ test.describe('wizard and QR platform regressions', () => { }); await page.goto('/home/bots?id=new'); - await page.getByRole('combobox').click(); - await page.getByRole('option', { name: qrPlatform.label }).click(); + await createDraftBot(page, qrPlatform.label); await page.getByRole('button', { name: /^Start$/ }).click(); const qrImage = page.getByRole('img', { name: 'QR Code' }); @@ -515,8 +524,7 @@ test.describe('wizard and QR platform regressions', () => { }); await page.goto('/home/bots?id=new'); - await page.getByRole('combobox').click(); - await page.getByRole('option', { name: qrPlatform.label }).click(); + await createDraftBot(page, qrPlatform.label); await page.getByRole('button', { name: /^Start$/ }).click(); const dialog = page.getByRole('dialog'); @@ -524,6 +532,6 @@ test.describe('wizard and QR platform regressions', () => { await expect(dialog.getByRole('button', { name: 'Retry' })).toBeEnabled(); await dialog.getByRole('button', { name: 'Cancel' }).click(); await expect(dialog).toHaveCount(0); - await expect(page.getByRole('button', { name: /^Submit$/ })).toBeVisible(); + await expect(page.getByRole('button', { name: /^Save$/ })).toBeVisible(); }); });