mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-26 19:36:35 +08:00
feat: simplify resource setup and detail guides
This commit is contained in:
+1
-1
@@ -69,7 +69,7 @@ dependencies = [
|
|||||||
"langchain-text-splitters>=1.1.2",
|
"langchain-text-splitters>=1.1.2",
|
||||||
"chromadb>=1.0.0,<2.0.0",
|
"chromadb>=1.0.0,<2.0.0",
|
||||||
"qdrant-client (>=1.15.1,<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",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
"matrix-nio>=0.25.2",
|
"matrix-nio>=0.25.2",
|
||||||
|
|||||||
@@ -71,23 +71,26 @@ class KnowledgeService:
|
|||||||
|
|
||||||
creation_settings = restore_secret_placeholders(kb_data.get('creation_settings', {}))
|
creation_settings = restore_secret_placeholders(kb_data.get('creation_settings', {}))
|
||||||
retrieval_settings = kb_data.get('retrieval_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
|
if not defer_initialization:
|
||||||
await self._validate_schema_required_fields(
|
await self._validate_schema_required_fields(
|
||||||
context,
|
context,
|
||||||
knowledge_engine_plugin_id,
|
knowledge_engine_plugin_id,
|
||||||
creation_settings,
|
creation_settings,
|
||||||
retrieval_settings,
|
retrieval_settings,
|
||||||
)
|
)
|
||||||
|
|
||||||
kb = await self.ap.rag_mgr.create_knowledge_base(
|
create_kwargs = {
|
||||||
context,
|
'name': kb_data.get('name', 'Untitled'),
|
||||||
name=kb_data.get('name', 'Untitled'),
|
'knowledge_engine_plugin_id': knowledge_engine_plugin_id,
|
||||||
knowledge_engine_plugin_id=knowledge_engine_plugin_id,
|
'creation_settings': creation_settings,
|
||||||
creation_settings=creation_settings,
|
'retrieval_settings': retrieval_settings,
|
||||||
retrieval_settings=retrieval_settings,
|
'description': kb_data.get('description', ''),
|
||||||
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
|
return kb.uuid
|
||||||
|
|
||||||
async def _validate_schema_required_fields(
|
async def _validate_schema_required_fields(
|
||||||
@@ -205,10 +208,32 @@ class KnowledgeService:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""更新知识库"""
|
"""更新知识库"""
|
||||||
workspace_uuid = require_workspace_uuid(context)
|
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')
|
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:
|
if not filtered_data:
|
||||||
return
|
return
|
||||||
@@ -224,8 +249,28 @@ class KnowledgeService:
|
|||||||
kb = await self.get_knowledge_base(context, kb_uuid, include_secret=True)
|
kb = await self.get_knowledge_base(context, kb_uuid, include_secret=True)
|
||||||
if kb is None:
|
if kb is None:
|
||||||
raise WorkspaceNotFoundError('Knowledge base not found')
|
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:
|
async def _check_doc_capability(self, context: TenantContext, kb_uuid: str, operation: str) -> None:
|
||||||
"""Check if the KB's Knowledge Engine supports document operations.
|
"""Check if the KB's Knowledge Engine supports document operations.
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ class KnowledgeBase(Base):
|
|||||||
)
|
)
|
||||||
creation_settings = sqlalchemy.Column(sqlalchemy.JSON, nullable=True, default=None)
|
creation_settings = sqlalchemy.Column(sqlalchemy.JSON, nullable=True, default=None)
|
||||||
retrieval_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
|
# Server-selected pgvector dimension. ``None`` means no embedding has been
|
||||||
# written yet; the first pgvector upsert binds it atomically.
|
# written yet; the first pgvector upsert binds it atomically.
|
||||||
embedding_dimension = sqlalchemy.Column(sqlalchemy.Integer, nullable=True)
|
embedding_dimension = sqlalchemy.Column(sqlalchemy.Integer, nullable=True)
|
||||||
@@ -44,6 +50,7 @@ class KnowledgeBase(Base):
|
|||||||
'workspace_uuid',
|
'workspace_uuid',
|
||||||
'legacy_vector_collection',
|
'legacy_vector_collection',
|
||||||
'embedding_dimension',
|
'embedding_dimension',
|
||||||
|
'initialized',
|
||||||
'emoji',
|
'emoji',
|
||||||
'created_at',
|
'created_at',
|
||||||
'updated_at',
|
'updated_at',
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -802,6 +802,7 @@ class RAGManager:
|
|||||||
creation_settings: dict,
|
creation_settings: dict,
|
||||||
retrieval_settings: dict | None = None,
|
retrieval_settings: dict | None = None,
|
||||||
description: str = '',
|
description: str = '',
|
||||||
|
initialize: bool = True,
|
||||||
) -> persistence_rag.KnowledgeBase:
|
) -> persistence_rag.KnowledgeBase:
|
||||||
"""Create a new knowledge base using a RAG plugin."""
|
"""Create a new knowledge base using a RAG plugin."""
|
||||||
execution_context = await self._to_execution_context(context)
|
execution_context = await self._to_execution_context(context)
|
||||||
@@ -831,6 +832,7 @@ class RAGManager:
|
|||||||
'collection_id': collection_id,
|
'collection_id': collection_id,
|
||||||
'creation_settings': creation_settings,
|
'creation_settings': creation_settings,
|
||||||
'retrieval_settings': retrieval_settings or {},
|
'retrieval_settings': retrieval_settings or {},
|
||||||
|
'initialized': initialize,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Create Entity
|
# Create Entity
|
||||||
@@ -839,20 +841,21 @@ class RAGManager:
|
|||||||
# Persist
|
# Persist
|
||||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_rag.KnowledgeBase).values(kb_data))
|
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_rag.KnowledgeBase).values(kb_data))
|
||||||
|
|
||||||
# Load into Runtime
|
if initialize:
|
||||||
runtime_kb = await self.load_knowledge_base(execution_context, kb)
|
# 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
|
# Roll back the record and runtime entry if plugin initialization fails.
|
||||||
try:
|
try:
|
||||||
await runtime_kb._on_kb_create(execution_context)
|
await runtime_kb._on_kb_create(execution_context)
|
||||||
except Exception:
|
except Exception:
|
||||||
self._pop_runtime(execution_context, kb_uuid)
|
self._pop_runtime(execution_context, kb_uuid)
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.delete(persistence_rag.KnowledgeBase)
|
sqlalchemy.delete(persistence_rag.KnowledgeBase)
|
||||||
.where(persistence_rag.KnowledgeBase.workspace_uuid == execution_context.workspace_uuid)
|
.where(persistence_rag.KnowledgeBase.workspace_uuid == execution_context.workspace_uuid)
|
||||||
.where(persistence_rag.KnowledgeBase.uuid == kb_uuid)
|
.where(persistence_rag.KnowledgeBase.uuid == kb_uuid)
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
self.ap.logger.info(f'Created new Knowledge Base {name} ({kb_uuid}) using plugin {knowledge_engine_plugin_id}')
|
self.ap.logger.info(f'Created new Knowledge Base {name} ({kb_uuid}) using plugin {knowledge_engine_plugin_id}')
|
||||||
return kb
|
return kb
|
||||||
@@ -878,6 +881,8 @@ class RAGManager:
|
|||||||
.order_by(persistence_rag.KnowledgeBase.uuid)
|
.order_by(persistence_rag.KnowledgeBase.uuid)
|
||||||
)
|
)
|
||||||
for knowledge_base in result.all():
|
for knowledge_base in result.all():
|
||||||
|
if knowledge_base.initialized is False:
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
await self.load_knowledge_base(
|
await self.load_knowledge_base(
|
||||||
ExecutionContext(
|
ExecutionContext(
|
||||||
@@ -899,6 +904,8 @@ class RAGManager:
|
|||||||
knowledge_bases = result.all()
|
knowledge_bases = result.all()
|
||||||
|
|
||||||
for knowledge_base in knowledge_bases:
|
for knowledge_base in knowledge_bases:
|
||||||
|
if knowledge_base.initialized is False:
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
binding = await self.ap.workspace_service.get_execution_binding(knowledge_base.workspace_uuid)
|
binding = await self.ap.workspace_service.get_execution_binding(knowledge_base.workspace_uuid)
|
||||||
execution_context = ExecutionContext(
|
execution_context = ExecutionContext(
|
||||||
|
|||||||
@@ -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
|
@pytest.mark.asyncio
|
||||||
async def test_create_enforces_workspace_knowledge_base_limit():
|
async def test_create_enforces_workspace_knowledge_base_limit():
|
||||||
app = _app()
|
app = _app()
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ def _entity(*, kb_uuid='kb-a', workspace_uuid='workspace-a', plugin_id='author/e
|
|||||||
collection_id=kb_uuid,
|
collection_id=kb_uuid,
|
||||||
creation_settings={},
|
creation_settings={},
|
||||||
retrieval_settings={},
|
retrieval_settings={},
|
||||||
|
initialized=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -67,6 +68,7 @@ def _app():
|
|||||||
'collection_id': row.collection_id,
|
'collection_id': row.collection_id,
|
||||||
'creation_settings': row.creation_settings,
|
'creation_settings': row.creation_settings,
|
||||||
'retrieval_settings': row.retrieval_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
|
@pytest.mark.asyncio
|
||||||
async def test_create_rejects_unknown_engine_and_rolls_back_plugin_failure():
|
async def test_create_rejects_unknown_engine_and_rolls_back_plugin_failure():
|
||||||
app = _app()
|
app = _app()
|
||||||
|
|||||||
@@ -2119,7 +2119,7 @@ requires-dist = [
|
|||||||
{ name = "ebooklib", specifier = ">=0.18" },
|
{ name = "ebooklib", specifier = ">=0.18" },
|
||||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
{ 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", specifier = ">=1.3.9" },
|
||||||
{ name = "langchain-core", specifier = ">=1.3.3" },
|
{ name = "langchain-core", specifier = ">=1.3.3" },
|
||||||
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
||||||
@@ -2184,7 +2184,7 @@ dev = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot-plugin"
|
name = "langbot-plugin"
|
||||||
version = "0.6.0b2"
|
version = "0.6.0b3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiofiles" },
|
{ name = "aiofiles" },
|
||||||
@@ -2205,9 +2205,9 @@ dependencies = [
|
|||||||
{ name = "watchdog" },
|
{ name = "watchdog" },
|
||||||
{ name = "websockets" },
|
{ 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 = [
|
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]]
|
[[package]]
|
||||||
|
|||||||
@@ -115,7 +115,6 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
|||||||
if (isCreateMode) {
|
if (isCreateMode) {
|
||||||
return (
|
return (
|
||||||
<AgentCreateContent
|
<AgentCreateContent
|
||||||
guideEnabled={canManage}
|
|
||||||
onCreated={(newAgentId) => {
|
onCreated={(newAgentId) => {
|
||||||
refreshPipelines();
|
refreshPipelines();
|
||||||
navigate(`/home/agents?id=${encodeURIComponent(newAgentId)}`);
|
navigate(`/home/agents?id=${encodeURIComponent(newAgentId)}`);
|
||||||
|
|||||||
@@ -27,16 +27,11 @@ import {
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import EmojiPicker from '@/components/ui/emoji-picker';
|
import EmojiPicker from '@/components/ui/emoji-picker';
|
||||||
import ProcessorTypeDiagram from './ProcessorTypeDiagram';
|
import ProcessorTypeDiagram from './ProcessorTypeDiagram';
|
||||||
import GuidedTour, {
|
|
||||||
GuidedTourStep,
|
|
||||||
} from '@/app/home/components/guided-tour/GuidedTour';
|
|
||||||
|
|
||||||
export default function AgentCreateContent({
|
export default function AgentCreateContent({
|
||||||
onCreated,
|
onCreated,
|
||||||
guideEnabled = true,
|
|
||||||
}: {
|
}: {
|
||||||
onCreated: (agentId: string) => void;
|
onCreated: (agentId: string) => void;
|
||||||
guideEnabled?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [kind, setKind] = useState<AgentKind>('agent');
|
const [kind, setKind] = useState<AgentKind>('agent');
|
||||||
@@ -114,29 +109,6 @@ export default function AgentCreateContent({
|
|||||||
description: t('agents.eventProcessor.description'),
|
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 (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||||
@@ -147,7 +119,6 @@ export default function AgentCreateContent({
|
|||||||
type="submit"
|
type="submit"
|
||||||
form="agent-create-form"
|
form="agent-create-form"
|
||||||
disabled={form.formState.isSubmitting}
|
disabled={form.formState.isSubmitting}
|
||||||
data-guide="processor-submit"
|
|
||||||
>
|
>
|
||||||
{t('common.submit')}
|
{t('common.submit')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -160,7 +131,6 @@ export default function AgentCreateContent({
|
|||||||
<section
|
<section
|
||||||
aria-labelledby="processor-kind-heading"
|
aria-labelledby="processor-kind-heading"
|
||||||
className="space-y-3"
|
className="space-y-3"
|
||||||
data-guide="processor-type"
|
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h2
|
<h2
|
||||||
@@ -211,7 +181,7 @@ export default function AgentCreateContent({
|
|||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Card data-guide="processor-basic">
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('agents.basicInfo')}</CardTitle>
|
<CardTitle>{t('agents.basicInfo')}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
@@ -287,12 +257,6 @@ export default function AgentCreateContent({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<GuidedTour
|
|
||||||
enabled={guideEnabled}
|
|
||||||
storageKey="langbot_processor_create_guide_v1"
|
|
||||||
steps={guideSteps}
|
|
||||||
testId="processor-create-guide"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ import RunnerSelect from './RunnerSelect';
|
|||||||
import GuidedTour, {
|
import GuidedTour, {
|
||||||
GuidedTourStep,
|
GuidedTourStep,
|
||||||
} from '@/app/home/components/guided-tour/GuidedTour';
|
} from '@/app/home/components/guided-tour/GuidedTour';
|
||||||
import { areRequiredDynamicFieldsComplete } from '@/app/home/components/guided-tour/dynamic-form-progress';
|
|
||||||
import AgentApiToolPicker from './AgentApiToolPicker';
|
import AgentApiToolPicker from './AgentApiToolPicker';
|
||||||
|
|
||||||
const OTHER_TOOL_SCOPES = [
|
const OTHER_TOOL_SCOPES = [
|
||||||
@@ -482,8 +481,6 @@ function AgentFormComponent(
|
|||||||
target: '[data-guide="runner-selector"]',
|
target: '[data-guide="runner-selector"]',
|
||||||
title: t('guidedTour.runner.select.title'),
|
title: t('guidedTour.runner.select.title'),
|
||||||
description: t('guidedTour.runner.select.description'),
|
description: t('guidedTour.runner.select.description'),
|
||||||
complete: Boolean(currentRunner && selectedRunnerOption),
|
|
||||||
requirement: t('guidedTour.runner.select.requirement'),
|
|
||||||
action: {
|
action: {
|
||||||
href: 'https://space.langbot.app/market?type=plugin&component=Runner&runner_usage=agent',
|
href: 'https://space.langbot.app/market?type=plugin&component=Runner&runner_usage=agent',
|
||||||
label: t('guidedTour.runner.select.action'),
|
label: t('guidedTour.runner.select.action'),
|
||||||
@@ -497,11 +494,6 @@ function AgentFormComponent(
|
|||||||
target: '[data-guide="runner-parameters"]',
|
target: '[data-guide="runner-parameters"]',
|
||||||
title: t('guidedTour.runner.parameters.title'),
|
title: t('guidedTour.runner.parameters.title'),
|
||||||
description: t('guidedTour.runner.parameters.description'),
|
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"]',
|
target: '[data-guide="agent-sections"]',
|
||||||
title: t('guidedTour.runner.events.title'),
|
title: t('guidedTour.runner.events.title'),
|
||||||
description: t('guidedTour.runner.events.description'),
|
description: t('guidedTour.runner.events.description'),
|
||||||
complete: activeSection === 'events_and_tools',
|
|
||||||
requirement: t('guidedTour.runner.events.requirement'),
|
|
||||||
});
|
});
|
||||||
return steps;
|
return steps;
|
||||||
}, [
|
}, [activeRunnerStage, t]);
|
||||||
activeRunnerStage,
|
|
||||||
activeRunnerValues,
|
|
||||||
activeSection,
|
|
||||||
currentRunner,
|
|
||||||
selectedRunnerOption,
|
|
||||||
t,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onRunnerStatusChange?.(runnerStatus);
|
onRunnerStatusChange?.(runnerStatus);
|
||||||
|
|||||||
@@ -348,7 +348,7 @@ export default function RunnerSelect({
|
|||||||
<SelectValue placeholder={t('common.select')} />
|
<SelectValue placeholder={t('common.select')} />
|
||||||
)}
|
)}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="max-h-72 w-[var(--radix-select-trigger-width)] max-w-[calc(100vw-2rem)]">
|
<SelectContent className="z-[70] max-h-72 w-[var(--radix-select-trigger-width)] max-w-[calc(100vw-2rem)]">
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
<SelectLabel className="px-2 py-1 text-[11px] font-medium">
|
<SelectLabel className="px-2 py-1 text-[11px] font-medium">
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ export default function BotDetailContent({ id }: { id: string }) {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||||
<h1 className="text-xl font-semibold">{t('bots.createBot')}</h1>
|
<h1 className="text-xl font-semibold">{t('bots.createBot')}</h1>
|
||||||
{canManage && (
|
{canManage && adapterLabel && (
|
||||||
<Button type="submit" form="bot-form" data-guide="bot-submit">
|
<Button type="submit" form="bot-form" data-guide="bot-submit">
|
||||||
{t('common.submit')}
|
{t('common.submit')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -171,13 +171,14 @@ export default function BotDetailContent({ id }: { id: string }) {
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden">
|
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden">
|
||||||
<div className="mx-auto w-full min-w-0 max-w-3xl pb-8">
|
<div className="mx-auto w-full min-w-0 max-w-7xl pb-8">
|
||||||
<fieldset className="contents" disabled={!canManage}>
|
<fieldset className="contents" disabled={!canManage}>
|
||||||
<BotForm
|
<BotForm
|
||||||
initBotId={undefined}
|
initBotId={undefined}
|
||||||
onFormSubmit={handleFormSubmit}
|
onFormSubmit={handleFormSubmit}
|
||||||
onNewBotCreated={handleNewBotCreated}
|
onNewBotCreated={handleNewBotCreated}
|
||||||
guideEnabled={canManage}
|
guideEnabled={canManage}
|
||||||
|
onAdapterLabelChange={setAdapterLabel}
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
@@ -236,6 +237,7 @@ export default function BotDetailContent({ id }: { id: string }) {
|
|||||||
form="bot-form"
|
form="bot-form"
|
||||||
disabled={!formDirty}
|
disabled={!formDirty}
|
||||||
className={activeTab !== 'config' ? 'invisible' : ''}
|
className={activeTab !== 'config' ? 'invisible' : ''}
|
||||||
|
data-guide="bot-config-save"
|
||||||
>
|
>
|
||||||
{t('common.save')}
|
{t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -318,6 +320,7 @@ export default function BotDetailContent({ id }: { id: string }) {
|
|||||||
onNewBotCreated={handleNewBotCreated}
|
onNewBotCreated={handleNewBotCreated}
|
||||||
onDirtyChange={setFormDirty}
|
onDirtyChange={setFormDirty}
|
||||||
onAdapterLabelChange={setAdapterLabel}
|
onAdapterLabelChange={setAdapterLabel}
|
||||||
|
guideEnabled={canManage}
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
@@ -24,9 +24,6 @@ export interface GuidedTourStep {
|
|||||||
target: string;
|
target: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
complete?: boolean;
|
|
||||||
advanceOnComplete?: boolean;
|
|
||||||
requirement?: string;
|
|
||||||
action?: {
|
action?: {
|
||||||
href: string;
|
href: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -78,14 +75,9 @@ export default function GuidedTour({
|
|||||||
useState<PopoverPosition | null>(null);
|
useState<PopoverPosition | null>(null);
|
||||||
const popoverRef = useRef<HTMLDivElement | null>(null);
|
const popoverRef = useRef<HTMLDivElement | null>(null);
|
||||||
const previousStorageKeyRef = useRef(storageKey);
|
const previousStorageKeyRef = useRef(storageKey);
|
||||||
const previousCompletionRef = useRef<{
|
|
||||||
stepId: string;
|
|
||||||
complete: boolean;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const activeIndex = steps.findIndex((step) => step.id === activeStepId);
|
const activeIndex = steps.findIndex((step) => step.id === activeStepId);
|
||||||
const activeStep = activeIndex >= 0 ? steps[activeIndex] : undefined;
|
const activeStep = activeIndex >= 0 ? steps[activeIndex] : undefined;
|
||||||
const isComplete = activeStep?.complete !== false;
|
|
||||||
const isPopoverPositioned = popoverPosition !== null;
|
const isPopoverPositioned = popoverPosition !== null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -105,15 +97,7 @@ export default function GuidedTour({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentIndex = steps.findIndex((step) => step.id === activeStepId);
|
const currentIndex = steps.findIndex((step) => step.id === activeStepId);
|
||||||
const firstIncompleteIndex = steps.findIndex(
|
const nextIndex = currentIndex < 0 ? 0 : currentIndex;
|
||||||
(step) => step.complete === false,
|
|
||||||
);
|
|
||||||
const nextIndex =
|
|
||||||
currentIndex < 0
|
|
||||||
? 0
|
|
||||||
: firstIncompleteIndex >= 0 && firstIncompleteIndex < currentIndex
|
|
||||||
? firstIncompleteIndex
|
|
||||||
: currentIndex;
|
|
||||||
|
|
||||||
if (nextIndex !== currentIndex) {
|
if (nextIndex !== currentIndex) {
|
||||||
const nextStep = steps[nextIndex];
|
const nextStep = steps[nextIndex];
|
||||||
@@ -208,27 +192,33 @@ export default function GuidedTour({
|
|||||||
if (isPopoverPositioned) measure();
|
if (isPopoverPositioned) measure();
|
||||||
}, [activeStep?.id, isPopoverPositioned, measure]);
|
}, [activeStep?.id, isPopoverPositioned, measure]);
|
||||||
|
|
||||||
|
const finishTour = useCallback(() => {
|
||||||
|
setTargetRect(null);
|
||||||
|
setPopoverPosition(null);
|
||||||
|
storeProgress(storageKey, 'completed');
|
||||||
|
setFinished(true);
|
||||||
|
setActiveStepId(null);
|
||||||
|
}, [storageKey]);
|
||||||
|
|
||||||
const handleNext = useCallback(() => {
|
const handleNext = useCallback(() => {
|
||||||
if (!activeStep || !isComplete) return;
|
if (!activeStep) return;
|
||||||
const nextStep = steps[activeIndex + 1];
|
const nextStep = steps[activeIndex + 1];
|
||||||
setTargetRect(null);
|
setTargetRect(null);
|
||||||
setPopoverPosition(null);
|
setPopoverPosition(null);
|
||||||
if (!nextStep) {
|
if (!nextStep) {
|
||||||
storeProgress(storageKey, 'completed');
|
finishTour();
|
||||||
setFinished(true);
|
|
||||||
setActiveStepId(null);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
storeProgress(storageKey, nextStep.id);
|
storeProgress(storageKey, nextStep.id);
|
||||||
setActiveStepId(nextStep.id);
|
setActiveStepId(nextStep.id);
|
||||||
}, [activeIndex, activeStep, isComplete, steps, storageKey]);
|
}, [activeIndex, activeStep, finishTour, steps, storageKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleNativeClick = (event: MouseEvent) => {
|
const handleNativeClick = (event: MouseEvent) => {
|
||||||
const target = event.target;
|
const target = event.target;
|
||||||
if (!(target instanceof Element)) return;
|
if (!(target instanceof Element)) return;
|
||||||
const button = target.closest<HTMLButtonElement>(
|
const button = target.closest<HTMLButtonElement>(
|
||||||
'[data-guided-tour-action="next"]',
|
'[data-guided-tour-action]',
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
!button ||
|
!button ||
|
||||||
@@ -237,33 +227,18 @@ export default function GuidedTour({
|
|||||||
) {
|
) {
|
||||||
return;
|
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
|
// Translation extensions can rewrite nodes inside the popover and detach
|
||||||
// React's delegated handler. Capture the command by its stable data marker.
|
// React's delegated handler. Capture the command by its stable data marker.
|
||||||
document.addEventListener('click', handleNativeClick, true);
|
document.addEventListener('click', handleNativeClick, true);
|
||||||
return () => document.removeEventListener('click', handleNativeClick, true);
|
return () => document.removeEventListener('click', handleNativeClick, true);
|
||||||
}, [handleNext, testId]);
|
}, [finishTour, 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]);
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!enabled ||
|
!enabled ||
|
||||||
@@ -282,7 +257,6 @@ export default function GuidedTour({
|
|||||||
<div
|
<div
|
||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
data-active-step={activeStep.id}
|
data-active-step={activeStep.id}
|
||||||
data-step-complete={String(isComplete)}
|
|
||||||
translate="no"
|
translate="no"
|
||||||
className="notranslate pointer-events-none"
|
className="notranslate pointer-events-none"
|
||||||
>
|
>
|
||||||
@@ -301,19 +275,30 @@ export default function GuidedTour({
|
|||||||
ref={popoverRef}
|
ref={popoverRef}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-labelledby={titleId}
|
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}
|
style={popoverPosition}
|
||||||
>
|
>
|
||||||
<div className="mb-3 flex items-center justify-between gap-3">
|
<div className="mb-3 flex items-center gap-2">
|
||||||
<span className="text-xs font-medium text-blue-600 dark:text-blue-400">
|
<span className="text-xs font-medium text-blue-600 dark:text-blue-400">
|
||||||
{t('guidedTour.label')}
|
{t('guidedTour.label')}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs tabular-nums text-muted-foreground">
|
<span className="ml-auto text-xs tabular-nums text-muted-foreground">
|
||||||
{t('guidedTour.progress', {
|
{t('guidedTour.progress', {
|
||||||
current: activeIndex + 1,
|
current: activeIndex + 1,
|
||||||
total: steps.length,
|
total: steps.length,
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
data-guided-tour-action="skip"
|
||||||
|
data-guided-tour-id={testId}
|
||||||
|
className="-my-2 -mr-2 h-7 gap-1 px-2 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
{t('guidedTour.skip')}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<h2 id={titleId} className="text-base font-semibold">
|
<h2 id={titleId} className="text-base font-semibold">
|
||||||
{activeStep.title}
|
{activeStep.title}
|
||||||
@@ -332,18 +317,11 @@ export default function GuidedTour({
|
|||||||
<ExternalLink className="size-3.5" />
|
<ExternalLink className="size-3.5" />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{!isComplete && activeStep.requirement && (
|
|
||||||
<div className="mt-3 flex items-start gap-2 rounded-md bg-muted px-3 py-2 text-xs leading-5 text-muted-foreground">
|
|
||||||
<LockKeyhole className="mt-0.5 size-3.5 shrink-0" />
|
|
||||||
<span>{activeStep.requirement}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
data-guided-tour-action="next"
|
data-guided-tour-action="next"
|
||||||
data-guided-tour-id={testId}
|
data-guided-tour-id={testId}
|
||||||
className="mt-4 w-full"
|
className="mt-4 w-full"
|
||||||
disabled={!isComplete}
|
|
||||||
>
|
>
|
||||||
{isLastStep ? (
|
{isLastStep ? (
|
||||||
<Check className="size-4" />
|
<Check className="size-4" />
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import {
|
|
||||||
IDynamicFormItemSchema,
|
|
||||||
SYSTEM_FIELD_PREFIX,
|
|
||||||
} from '@/app/infra/entities/form/dynamic';
|
|
||||||
|
|
||||||
function isVisible(
|
|
||||||
item: IDynamicFormItemSchema,
|
|
||||||
values: Record<string, unknown>,
|
|
||||||
externalValues: Record<string, unknown> = {},
|
|
||||||
) {
|
|
||||||
if (!item.show_if || item.show_if.field.startsWith(SYSTEM_FIELD_PREFIX)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const actual =
|
|
||||||
values[item.show_if.field] ?? externalValues[item.show_if.field];
|
|
||||||
if (item.show_if.operator === 'eq') return actual === item.show_if.value;
|
|
||||||
if (item.show_if.operator === 'neq') return actual !== item.show_if.value;
|
|
||||||
return (
|
|
||||||
Array.isArray(item.show_if.value) && item.show_if.value.includes(actual)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasValue(value: unknown) {
|
|
||||||
if (value === null || value === undefined) return false;
|
|
||||||
if (typeof value === 'string') return value.trim().length > 0;
|
|
||||||
if (Array.isArray(value)) return value.length > 0;
|
|
||||||
if (typeof value === 'object' && 'primary' in value) {
|
|
||||||
return String((value as { primary?: unknown }).primary ?? '').trim() !== '';
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function areRequiredDynamicFieldsComplete(
|
|
||||||
items: IDynamicFormItemSchema[],
|
|
||||||
values: Record<string, unknown>,
|
|
||||||
externalValues?: Record<string, unknown>,
|
|
||||||
) {
|
|
||||||
return items
|
|
||||||
.filter(
|
|
||||||
(item) =>
|
|
||||||
item.required &&
|
|
||||||
!item.name.startsWith(SYSTEM_FIELD_PREFIX) &&
|
|
||||||
isVisible(item, values, externalValues),
|
|
||||||
)
|
|
||||||
.every((item) => hasValue(values[item.name]));
|
|
||||||
}
|
|
||||||
@@ -164,7 +164,7 @@ export default function KBDetailContent({ id }: { id: string }) {
|
|||||||
</h1>
|
</h1>
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<Button type="submit" form="kb-form" data-guide="knowledge-submit">
|
<Button type="submit" form="kb-form" data-guide="knowledge-submit">
|
||||||
{t('common.submit')}
|
{t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -215,6 +215,7 @@ export default function KBDetailContent({ id }: { id: string }) {
|
|||||||
form="kb-form"
|
form="kb-form"
|
||||||
disabled={!formDirty}
|
disabled={!formDirty}
|
||||||
className={activeTab !== 'metadata' ? 'invisible' : ''}
|
className={activeTab !== 'metadata' ? 'invisible' : ''}
|
||||||
|
data-guide="knowledge-config-save"
|
||||||
>
|
>
|
||||||
{t('common.save')}
|
{t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -233,16 +234,18 @@ export default function KBDetailContent({ id }: { id: string }) {
|
|||||||
<FileText className="size-3.5" />
|
<FileText className="size-3.5" />
|
||||||
{t('knowledge.metadata')}
|
{t('knowledge.metadata')}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
{hasDocumentCapability() && (
|
{kbInfo.initialized !== false && hasDocumentCapability() && (
|
||||||
<TabsTrigger value="documents" className="gap-1.5">
|
<TabsTrigger value="documents" className="gap-1.5">
|
||||||
<FolderOpen className="size-3.5" />
|
<FolderOpen className="size-3.5" />
|
||||||
{t('knowledge.documents')}
|
{t('knowledge.documents')}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
<TabsTrigger value="retrieve" className="gap-1.5">
|
{kbInfo.initialized !== false && (
|
||||||
<Search className="size-3.5" />
|
<TabsTrigger value="retrieve" className="gap-1.5">
|
||||||
{t('knowledge.retrieve')}
|
<Search className="size-3.5" />
|
||||||
</TabsTrigger>
|
{t('knowledge.retrieve')}
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* Tab: Metadata */}
|
{/* Tab: Metadata */}
|
||||||
@@ -258,6 +261,7 @@ export default function KBDetailContent({ id }: { id: string }) {
|
|||||||
onNewKbCreated={handleNewKbCreated}
|
onNewKbCreated={handleNewKbCreated}
|
||||||
onKbUpdated={handleKbUpdated}
|
onKbUpdated={handleKbUpdated}
|
||||||
onDirtyChange={setFormDirty}
|
onDirtyChange={setFormDirty}
|
||||||
|
guideEnabled={canManage}
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
@@ -299,7 +303,7 @@ export default function KBDetailContent({ id }: { id: string }) {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Tab: Documents */}
|
{/* Tab: Documents */}
|
||||||
{hasDocumentCapability() && (
|
{kbInfo.initialized !== false && hasDocumentCapability() && (
|
||||||
<TabsContent
|
<TabsContent
|
||||||
value="documents"
|
value="documents"
|
||||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
||||||
@@ -315,12 +319,17 @@ export default function KBDetailContent({ id }: { id: string }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tab: Retrieve */}
|
{/* Tab: Retrieve */}
|
||||||
<TabsContent
|
{kbInfo.initialized !== false && (
|
||||||
value="retrieve"
|
<TabsContent
|
||||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
value="retrieve"
|
||||||
>
|
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
||||||
<KBRetrieveGeneric kbId={id} retrieveFunction={retrieveFunction} />
|
>
|
||||||
</TabsContent>
|
<KBRetrieveGeneric
|
||||||
|
kbId={id}
|
||||||
|
retrieveFunction={retrieveFunction}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ import KnowledgeEngineSelect from './KnowledgeEngineSelect';
|
|||||||
import GuidedTour, {
|
import GuidedTour, {
|
||||||
GuidedTourStep,
|
GuidedTourStep,
|
||||||
} from '@/app/home/components/guided-tour/GuidedTour';
|
} from '@/app/home/components/guided-tour/GuidedTour';
|
||||||
import { areRequiredDynamicFieldsComplete } from '@/app/home/components/guided-tour/dynamic-form-progress';
|
|
||||||
|
|
||||||
const KNOWLEDGE_ENGINE_MARKETPLACE_URL =
|
const KNOWLEDGE_ENGINE_MARKETPLACE_URL =
|
||||||
'https://space.langbot.app/market?type=plugin&component=KnowledgeEngine';
|
'https://space.langbot.app/market?type=plugin&component=KnowledgeEngine';
|
||||||
@@ -104,6 +103,7 @@ export default function KBForm({
|
|||||||
Record<string, unknown>
|
Record<string, unknown>
|
||||||
>({});
|
>({});
|
||||||
const [isEditing, setIsEditing] = useState(Boolean(initKbId));
|
const [isEditing, setIsEditing] = useState(Boolean(initKbId));
|
||||||
|
const [engineInitialized, setEngineInitialized] = useState(true);
|
||||||
const [loadFailed, setLoadFailed] = useState(false);
|
const [loadFailed, setLoadFailed] = useState(false);
|
||||||
const [loadAttempt, setLoadAttempt] = useState(0);
|
const [loadAttempt, setLoadAttempt] = useState(0);
|
||||||
const [initialDataLoaded, setInitialDataLoaded] = useState(false);
|
const [initialDataLoaded, setInitialDataLoaded] = useState(false);
|
||||||
@@ -220,11 +220,13 @@ export default function KBForm({
|
|||||||
|
|
||||||
setConfigSettings(kb.creation_settings || {});
|
setConfigSettings(kb.creation_settings || {});
|
||||||
setRetrievalSettings(kb.retrieval_settings || {});
|
setRetrievalSettings(kb.retrieval_settings || {});
|
||||||
|
setEngineInitialized(kb.initialized !== false);
|
||||||
|
|
||||||
// Capture snapshot after a tick so dynamic forms have emitted initial values
|
// Capture snapshot after a tick so dynamic forms have emitted initial values
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
captureSnapshot();
|
captureSnapshot();
|
||||||
isInitializing.current = false;
|
isInitializing.current = false;
|
||||||
|
onDirtyChange?.(kb.initialized === false);
|
||||||
}, 500);
|
}, 500);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
isInitializing.current = false;
|
isInitializing.current = false;
|
||||||
@@ -269,8 +271,8 @@ export default function KBForm({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onSubmit = async (data: z.infer<typeof formSchema>) => {
|
const onSubmit = async (data: z.infer<typeof formSchema>) => {
|
||||||
// Validate dynamic forms before submission
|
// Engine parameters are configured only after the draft has been created.
|
||||||
if (configValidateRef.current) {
|
if (initKbId && configValidateRef.current) {
|
||||||
const configValid = await configValidateRef.current();
|
const configValid = await configValidateRef.current();
|
||||||
if (!configValid) {
|
if (!configValid) {
|
||||||
toast.error(t('knowledge.engineSettingsInvalid'));
|
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();
|
const retrievalValid = await retrievalValidateRef.current();
|
||||||
if (!retrievalValid) {
|
if (!retrievalValid) {
|
||||||
toast.error(t('knowledge.retrievalSettingsInvalid'));
|
toast.error(t('knowledge.retrievalSettingsInvalid'));
|
||||||
@@ -293,12 +295,16 @@ export default function KBForm({
|
|||||||
knowledge_engine_plugin_id: selectedEngineId,
|
knowledge_engine_plugin_id: selectedEngineId,
|
||||||
creation_settings: configSettings,
|
creation_settings: configSettings,
|
||||||
retrieval_settings: retrievalSettings,
|
retrieval_settings: retrievalSettings,
|
||||||
|
...(initKbId
|
||||||
|
? { initialize_engine: !engineInitialized }
|
||||||
|
: { defer_initialization: true }),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (initKbId) {
|
if (initKbId) {
|
||||||
httpClient
|
httpClient
|
||||||
.updateKnowledgeBase(initKbId, kbData)
|
.updateKnowledgeBase(initKbId, kbData)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
|
setEngineInitialized(true);
|
||||||
captureSnapshot();
|
captureSnapshot();
|
||||||
onDirtyChange?.(false);
|
onDirtyChange?.(false);
|
||||||
onKbUpdated(res.uuid);
|
onKbUpdated(res.uuid);
|
||||||
@@ -339,21 +345,11 @@ export default function KBForm({
|
|||||||
|
|
||||||
const guideSteps = useMemo<GuidedTourStep[]>(() => {
|
const guideSteps = useMemo<GuidedTourStep[]>(() => {
|
||||||
const steps: GuidedTourStep[] = [
|
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',
|
id: 'engine',
|
||||||
target: '[data-guide="knowledge-engine"]',
|
target: '[data-guide="knowledge-engine"]',
|
||||||
title: t('guidedTour.knowledge.engine.title'),
|
title: t('guidedTour.knowledge.engine.title'),
|
||||||
description: t('guidedTour.knowledge.engine.description'),
|
description: t('guidedTour.knowledge.engine.description'),
|
||||||
complete: Boolean(selectedEngineId),
|
|
||||||
requirement: t('guidedTour.knowledge.engine.requirement'),
|
|
||||||
action: {
|
action: {
|
||||||
href: KNOWLEDGE_ENGINE_MARKETPLACE_URL,
|
href: KNOWLEDGE_ENGINE_MARKETPLACE_URL,
|
||||||
label: t('guidedTour.knowledge.engine.action'),
|
label: t('guidedTour.knowledge.engine.action'),
|
||||||
@@ -367,12 +363,6 @@ export default function KBForm({
|
|||||||
target: '[data-guide="knowledge-engine-parameters"]',
|
target: '[data-guide="knowledge-engine-parameters"]',
|
||||||
title: t('guidedTour.knowledge.parameters.title'),
|
title: t('guidedTour.knowledge.parameters.title'),
|
||||||
description: t('guidedTour.knowledge.parameters.description'),
|
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"]',
|
target: '[data-guide="knowledge-retrieval"]',
|
||||||
title: t('guidedTour.knowledge.retrieval.title'),
|
title: t('guidedTour.knowledge.retrieval.title'),
|
||||||
description: t('guidedTour.knowledge.retrieval.description'),
|
description: t('guidedTour.knowledge.retrieval.description'),
|
||||||
complete: areRequiredDynamicFieldsComplete(
|
|
||||||
retrievalFormItems,
|
|
||||||
retrievalSettings,
|
|
||||||
configSettings,
|
|
||||||
),
|
|
||||||
requirement: t('guidedTour.knowledge.retrieval.requirement'),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
steps.push({
|
steps.push({
|
||||||
id: 'submit',
|
id: 'save',
|
||||||
target: '[data-guide="knowledge-submit"]',
|
target: '[data-guide="knowledge-config-save"]',
|
||||||
title: t('guidedTour.knowledge.submit.title'),
|
title: t('guidedTour.knowledge.save.title'),
|
||||||
description: t('guidedTour.knowledge.submit.description'),
|
description: t('guidedTour.knowledge.save.description'),
|
||||||
});
|
});
|
||||||
return steps;
|
return steps;
|
||||||
}, [
|
}, [configFormItems, retrievalFormItems, t]);
|
||||||
configFormItems,
|
|
||||||
configSettings,
|
|
||||||
retrievalFormItems,
|
|
||||||
retrievalSettings,
|
|
||||||
selectedEngineId,
|
|
||||||
t,
|
|
||||||
watchedFormValues.name,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (loadFailed)
|
if (loadFailed)
|
||||||
return (
|
return (
|
||||||
@@ -529,7 +505,7 @@ export default function KBForm({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{configFormItems.length > 0 && (
|
{isEditing && configFormItems.length > 0 && (
|
||||||
<div
|
<div
|
||||||
data-guide="knowledge-engine-parameters"
|
data-guide="knowledge-engine-parameters"
|
||||||
className="space-y-6"
|
className="space-y-6"
|
||||||
@@ -541,7 +517,7 @@ export default function KBForm({
|
|||||||
onSubmit={(val) =>
|
onSubmit={(val) =>
|
||||||
setConfigSettings(val as Record<string, unknown>)
|
setConfigSettings(val as Record<string, unknown>)
|
||||||
}
|
}
|
||||||
isEditing={isEditing}
|
isEditing={engineInitialized}
|
||||||
externalDependentValues={retrievalSettings}
|
externalDependentValues={retrievalSettings}
|
||||||
onValidate={(validateFn) =>
|
onValidate={(validateFn) =>
|
||||||
(configValidateRef.current = validateFn)
|
(configValidateRef.current = validateFn)
|
||||||
@@ -553,7 +529,7 @@ export default function KBForm({
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Retrieval Settings (dynamic form from retrieval_schema) */}
|
{/* Retrieval Settings (dynamic form from retrieval_schema) */}
|
||||||
{retrievalFormItems.length > 0 && (
|
{isEditing && retrievalFormItems.length > 0 && (
|
||||||
<Card data-guide="knowledge-retrieval">
|
<Card data-guide="knowledge-retrieval">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('knowledge.retrievalSettings')}</CardTitle>
|
<CardTitle>{t('knowledge.retrievalSettings')}</CardTitle>
|
||||||
@@ -578,10 +554,10 @@ export default function KBForm({
|
|||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
<GuidedTour
|
<GuidedTour
|
||||||
enabled={!isEditing && guideEnabled}
|
enabled={isEditing && guideEnabled}
|
||||||
storageKey="langbot_knowledge_create_guide_v1"
|
storageKey="langbot_knowledge_detail_guide_v1"
|
||||||
steps={guideSteps}
|
steps={guideSteps}
|
||||||
testId="knowledge-create-guide"
|
testId="knowledge-detail-guide"
|
||||||
/>
|
/>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -431,6 +431,9 @@ export interface KnowledgeBase {
|
|||||||
knowledge_engine_plugin_id?: string;
|
knowledge_engine_plugin_id?: string;
|
||||||
creation_settings?: Record<string, unknown>;
|
creation_settings?: Record<string, unknown>;
|
||||||
retrieval_settings?: Record<string, unknown>;
|
retrieval_settings?: Record<string, unknown>;
|
||||||
|
initialized?: boolean;
|
||||||
|
defer_initialization?: boolean;
|
||||||
|
initialize_engine?: boolean;
|
||||||
knowledge_engine?: KnowledgeEngineInfo;
|
knowledge_engine?: KnowledgeEngineInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ const enUS = {
|
|||||||
progress: '{{current}} of {{total}}',
|
progress: '{{current}} of {{total}}',
|
||||||
next: 'Next',
|
next: 'Next',
|
||||||
finish: 'Finish',
|
finish: 'Finish',
|
||||||
|
skip: 'Skip',
|
||||||
bot: {
|
bot: {
|
||||||
connection: {
|
connection: {
|
||||||
title: 'Choose a connection method',
|
title: 'Choose a connection method',
|
||||||
@@ -68,7 +69,7 @@ const enUS = {
|
|||||||
adapter: {
|
adapter: {
|
||||||
title: 'Choose a platform adapter',
|
title: 'Choose a platform adapter',
|
||||||
description:
|
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.',
|
requirement: 'Select an adapter to continue.',
|
||||||
},
|
},
|
||||||
parameters: {
|
parameters: {
|
||||||
@@ -81,12 +82,17 @@ const enUS = {
|
|||||||
routing: {
|
routing: {
|
||||||
title: 'Route incoming events',
|
title: 'Route incoming events',
|
||||||
description:
|
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: {
|
submit: {
|
||||||
title: 'Create the bot',
|
title: 'Create the bot',
|
||||||
description:
|
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: {
|
processorCreate: {
|
||||||
@@ -136,9 +142,9 @@ const enUS = {
|
|||||||
requirement: 'Enter a knowledge base name to continue.',
|
requirement: 'Enter a knowledge base name to continue.',
|
||||||
},
|
},
|
||||||
engine: {
|
engine: {
|
||||||
title: 'Choose or install an engine',
|
title: 'Review the knowledge engine',
|
||||||
description:
|
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.',
|
requirement: 'Select a knowledge engine to continue.',
|
||||||
action: 'Browse Knowledge Engine Marketplace',
|
action: 'Browse Knowledge Engine Marketplace',
|
||||||
},
|
},
|
||||||
@@ -154,6 +160,11 @@ const enUS = {
|
|||||||
'Set how this engine searches and returns relevant content to processors.',
|
'Set how this engine searches and returns relevant content to processors.',
|
||||||
requirement: 'Complete every visible required retrieval parameter.',
|
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: {
|
submit: {
|
||||||
title: 'Create the knowledge base',
|
title: 'Create the knowledge base',
|
||||||
description:
|
description:
|
||||||
@@ -570,7 +581,7 @@ const enUS = {
|
|||||||
getBotConfigError: 'Failed to get bot configuration: ',
|
getBotConfigError: 'Failed to get bot configuration: ',
|
||||||
saveSuccess: 'Saved successfully',
|
saveSuccess: 'Saved successfully',
|
||||||
saveError: 'Save failed: ',
|
saveError: 'Save failed: ',
|
||||||
createSuccess: 'Created successfully. Please configure event routing',
|
createSuccess: 'Created successfully. Continue configuring the bot',
|
||||||
createError: 'Creation failed: ',
|
createError: 'Creation failed: ',
|
||||||
deleteSuccess: 'Deleted successfully',
|
deleteSuccess: 'Deleted successfully',
|
||||||
deleteError: 'Delete failed: ',
|
deleteError: 'Delete failed: ',
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ const zhHans = {
|
|||||||
progress: '第 {{current}} 步,共 {{total}} 步',
|
progress: '第 {{current}} 步,共 {{total}} 步',
|
||||||
next: '下一步',
|
next: '下一步',
|
||||||
finish: '完成引导',
|
finish: '完成引导',
|
||||||
|
skip: '跳过',
|
||||||
bot: {
|
bot: {
|
||||||
connection: {
|
connection: {
|
||||||
title: '选择接入方式',
|
title: '选择接入方式',
|
||||||
@@ -63,7 +64,7 @@ const zhHans = {
|
|||||||
adapter: {
|
adapter: {
|
||||||
title: '选择平台适配器',
|
title: '选择平台适配器',
|
||||||
description:
|
description:
|
||||||
'先选择平台,LangBot 会再判断该适配器支持 Webhook、长连接或两者都支持。',
|
'选择机器人使用的平台适配器;接入方式和平台参数将在创建后配置。',
|
||||||
requirement: '请选择一个适配器。',
|
requirement: '请选择一个适配器。',
|
||||||
},
|
},
|
||||||
parameters: {
|
parameters: {
|
||||||
@@ -75,13 +76,16 @@ const zhHans = {
|
|||||||
},
|
},
|
||||||
routing: {
|
routing: {
|
||||||
title: '设置事件路由',
|
title: '设置事件路由',
|
||||||
description:
|
description: '选择该机器人收到的各类事件交给哪个处理器。',
|
||||||
'选择各类事件交给哪个处理器;机器人创建后仍可继续添加路由。',
|
},
|
||||||
|
save: {
|
||||||
|
title: '保存机器人配置',
|
||||||
|
description: '确认接入参数和事件路由后保存;准备完成后即可启用机器人。',
|
||||||
},
|
},
|
||||||
submit: {
|
submit: {
|
||||||
title: '创建机器人',
|
title: '创建机器人',
|
||||||
description:
|
description:
|
||||||
'创建后连接配置才会生效;LangBot 生成的 Webhook 地址会显示在已保存机器人的配置中。',
|
'先创建未启用的机器人,再到机器人页面配置接入方式和平台参数。',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
processorCreate: {
|
processorCreate: {
|
||||||
@@ -130,9 +134,8 @@ const zhHans = {
|
|||||||
requirement: '请先填写知识库名称。',
|
requirement: '请先填写知识库名称。',
|
||||||
},
|
},
|
||||||
engine: {
|
engine: {
|
||||||
title: '选择或安装知识引擎',
|
title: '确认知识引擎',
|
||||||
description:
|
description: '确认该知识库使用的引擎,并在下方配置引擎参数和检索方式。',
|
||||||
'选择已安装的知识引擎,也可以在此选择器的市场区域直接安装。',
|
|
||||||
requirement: '请选择一个知识引擎。',
|
requirement: '请选择一个知识引擎。',
|
||||||
action: '浏览知识引擎市场',
|
action: '浏览知识引擎市场',
|
||||||
},
|
},
|
||||||
@@ -146,6 +149,10 @@ const zhHans = {
|
|||||||
description: '设置引擎如何搜索内容,以及如何把相关结果返回给处理器。',
|
description: '设置引擎如何搜索内容,以及如何把相关结果返回给处理器。',
|
||||||
requirement: '请填写当前可见的全部必填检索参数。',
|
requirement: '请填写当前可见的全部必填检索参数。',
|
||||||
},
|
},
|
||||||
|
save: {
|
||||||
|
title: '保存知识库配置',
|
||||||
|
description: '确认引擎参数和检索设置后保存配置。',
|
||||||
|
},
|
||||||
submit: {
|
submit: {
|
||||||
title: '创建知识库',
|
title: '创建知识库',
|
||||||
description: '创建后即可添加文档,或连接所选引擎支持的外部知识源。',
|
description: '创建后即可添加文档,或连接所选引擎支持的外部知识源。',
|
||||||
@@ -537,7 +544,7 @@ const zhHans = {
|
|||||||
getBotConfigError: '获取机器人配置失败:',
|
getBotConfigError: '获取机器人配置失败:',
|
||||||
saveSuccess: '保存成功',
|
saveSuccess: '保存成功',
|
||||||
saveError: '保存失败:',
|
saveError: '保存失败:',
|
||||||
createSuccess: '创建成功,请配置事件路由',
|
createSuccess: '创建成功,请继续配置机器人',
|
||||||
createError: '创建失败:',
|
createError: '创建失败:',
|
||||||
deleteSuccess: '删除成功',
|
deleteSuccess: '删除成功',
|
||||||
deleteError: '删除失败:',
|
deleteError: '删除失败:',
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ for (const failure of [
|
|||||||
test(`bot save displays actionable ${failure.code}`, async ({ page }) => {
|
test(`bot save displays actionable ${failure.code}`, async ({ page }) => {
|
||||||
await installLangBotApiMocks(page, { authenticated: true });
|
await installLangBotApiMocks(page, { authenticated: true });
|
||||||
await page.goto('/home/bots?id=new');
|
await page.goto('/home/bots?id=new');
|
||||||
await page.getByRole('combobox').click();
|
await page
|
||||||
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
|
.getByTestId('adapter-gallery')
|
||||||
|
.getByRole('button', { name: /Playwright Adapter/ })
|
||||||
|
.click();
|
||||||
await page.locator('input[name="name"]').fill('Error Test Bot');
|
await page.locator('input[name="name"]').fill('Error Test Bot');
|
||||||
await page.getByRole('button', { name: /^Submit$/ }).click();
|
await page.getByRole('button', { name: /^Submit$/ }).click();
|
||||||
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
|
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();
|
await page.getByRole('button', { name: 'Edit basic information' }).click();
|
||||||
const dialog = page.getByRole('dialog');
|
const editDialog = page.getByRole('dialog');
|
||||||
await dialog.getByLabel('Name', { exact: true }).fill('Edited Bot');
|
await editDialog.getByLabel('Name', { exact: true }).fill('Edited Bot');
|
||||||
await dialog.getByRole('button', { name: /^Save$/ }).click();
|
await editDialog.getByRole('button', { name: /^Save$/ }).click();
|
||||||
if (failure.code === 'internal_error') {
|
if (failure.code === 'internal_error') {
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText('Error reference: bot-save-test-reference'),
|
page.getByText('Error reference: bot-save-test-reference'),
|
||||||
|
|||||||
@@ -2,54 +2,111 @@ import { expect, test } from '@playwright/test';
|
|||||||
|
|
||||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
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,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await installLangBotApiMocks(page, {
|
await installLangBotApiMocks(page, {
|
||||||
authenticated: true,
|
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');
|
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(
|
await expect(
|
||||||
guide.getByRole('heading', { name: 'Describe the knowledge base' }),
|
page.locator('[data-guide="knowledge-engine-parameters"]'),
|
||||||
).toBeVisible();
|
).toHaveCount(0);
|
||||||
await expect(guide.getByRole('button', { name: 'Next' })).toBeDisabled();
|
await expect(page.locator('[data-guide="knowledge-retrieval"]')).toHaveCount(
|
||||||
|
0,
|
||||||
|
);
|
||||||
await page.locator('input[name="name"]').fill('Guided Knowledge');
|
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 expect(guide.getByRole('button', { name: 'Next' })).toBeEnabled();
|
||||||
await guide.getByRole('button', { name: 'Next' }).click();
|
await guide.getByRole('button', { name: 'Next' }).click();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
guide.getByRole('heading', { name: 'Choose or install an engine' }),
|
guide.getByRole('heading', { name: 'Configure engine parameters' }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(
|
await expect(guide.getByRole('button', { name: 'Next' })).toBeEnabled();
|
||||||
guide.getByRole('link', { name: /Marketplace/ }),
|
|
||||||
).toHaveAttribute('target', '_blank');
|
|
||||||
await guide.getByRole('button', { name: 'Next' }).click();
|
await guide.getByRole('button', { name: 'Next' }).click();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
guide.getByRole('heading', { name: 'Create the knowledge base' }),
|
guide.getByRole('heading', {
|
||||||
|
name: 'Save the knowledge base configuration',
|
||||||
|
}),
|
||||||
).toBeVisible();
|
).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 guide.getByRole('button', { name: 'Finish' }).click();
|
||||||
await expect(guide).toHaveCount(0);
|
await expect(guide).toHaveCount(0);
|
||||||
await expect
|
await expect
|
||||||
.poll(() =>
|
.poll(() =>
|
||||||
page.evaluate(() =>
|
page.evaluate(() =>
|
||||||
localStorage.getItem('langbot_knowledge_create_guide_v1'),
|
localStorage.getItem('langbot_knowledge_detail_guide_v1'),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toBe('completed');
|
.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,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await installLangBotApiMocks(page, {
|
await installLangBotApiMocks(page, {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
storage: { langbot_bot_create_guide_v4: 'basic' },
|
storage: { langbot_bot_detail_guide_v1: 'connection' },
|
||||||
});
|
});
|
||||||
await page.route('**/api/v1/platform/adapters', (route) =>
|
await page.route('**/api/v1/platform/adapters', (route) =>
|
||||||
route.fulfill({
|
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');
|
await page.goto('/home/bots?id=new');
|
||||||
|
|
||||||
const guide = page.getByTestId('bot-create-guide');
|
await expect(page.getByTestId('bot-detail-guide')).toHaveCount(0);
|
||||||
await expect(
|
await page
|
||||||
guide.getByRole('heading', { name: 'Name this bot' }),
|
.getByTestId('adapter-gallery')
|
||||||
).toBeVisible();
|
.getByRole('button', { name: /Dual Mode Adapter/ })
|
||||||
await expect(guide.getByRole('button', { name: 'Next' })).toBeDisabled();
|
.click();
|
||||||
|
|
||||||
await page.locator('input[name="name"]').fill('Guided Bot');
|
await page.locator('input[name="name"]').fill('Guided Bot');
|
||||||
await guide.getByRole('button', { name: 'Next' }).click();
|
await page
|
||||||
|
.locator('input[name="description"]')
|
||||||
await expect(
|
.fill('Created before configuration.');
|
||||||
guide.getByRole('heading', { name: 'Choose a platform adapter' }),
|
await page.getByRole('button', { name: /^Submit$/ }).click();
|
||||||
).toBeVisible();
|
|
||||||
await page.getByRole('combobox').click();
|
|
||||||
await page.getByRole('option', { name: 'Dual Mode Adapter' }).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(
|
await expect(
|
||||||
guide.getByRole('heading', { name: 'Choose a connection method' }),
|
guide.getByRole('heading', { name: 'Choose a connection method' }),
|
||||||
).toBeVisible();
|
).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();
|
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(
|
await expect(
|
||||||
guide.getByRole('heading', { name: 'Configure the platform' }),
|
guide.getByRole('heading', { name: 'Configure the platform' }),
|
||||||
).toBeVisible();
|
).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 guide.getByRole('button', { name: 'Next' }).click();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
guide.getByRole('heading', { name: 'Route incoming events' }),
|
guide.getByRole('heading', { name: 'Route incoming events' }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await guide.getByRole('button', { name: 'Next' }).click();
|
await guide.getByRole('button', { name: 'Next' }).click();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
guide.getByRole('heading', { name: 'Create the bot' }),
|
guide.getByRole('heading', { name: 'Save the bot configuration' }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await guide.getByRole('button', { name: 'Finish' }).click();
|
await guide.getByRole('button', { name: 'Finish' }).click();
|
||||||
await expect(guide).toHaveCount(0);
|
await expect(guide).toHaveCount(0);
|
||||||
await expect
|
await expect
|
||||||
.poll(() =>
|
.poll(() =>
|
||||||
page.evaluate(() => localStorage.getItem('langbot_bot_create_guide_v4')),
|
page.evaluate(() => localStorage.getItem('langbot_bot_detail_guide_v1')),
|
||||||
)
|
)
|
||||||
.toBe('completed');
|
.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 page.reload();
|
||||||
await expect(guide).toHaveCount(0);
|
await expect(guide).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('bot setup guide restores missing basic info before a saved adapter step', async ({
|
test('bot creation page never renders a contextual guide', async ({ page }) => {
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await page.setViewportSize({ width: 1645, height: 478 });
|
await page.setViewportSize({ width: 1645, height: 478 });
|
||||||
await installLangBotApiMocks(page, {
|
await installLangBotApiMocks(page, {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
language: 'zh-Hans',
|
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');
|
await page.goto('/home/bots?id=new');
|
||||||
|
|
||||||
const guide = page.getByTestId('bot-create-guide');
|
await expect(page.getByTestId('bot-detail-guide')).toHaveCount(0);
|
||||||
await expect(guide).toHaveAttribute('data-active-step', 'basic');
|
await expect(page.locator('[data-guide="bot-adapter"]')).toBeVisible();
|
||||||
await expect(guide).toHaveAttribute('data-step-complete', 'false');
|
await expect(page.locator('[data-guide="bot-basic"]')).toBeVisible();
|
||||||
await page.locator('input[name="name"]').fill('test');
|
for (const category of ['popular', 'china', 'global', 'protocol', 'legacy']) {
|
||||||
await expect(guide).toHaveAttribute('data-step-complete', 'true');
|
await expect(
|
||||||
|
page.locator(`[data-adapter-category="${category}"]`),
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
await expect(page.getByRole('button', { name: /^提交$/ })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
const nextButton = guide.getByRole('button', { name: '下一步' });
|
test('bot adapter gallery keeps categories and fits desktop and mobile', async ({
|
||||||
await nextButton.evaluate((button) => {
|
page,
|
||||||
button.replaceWith(button.cloneNode(true));
|
}) => {
|
||||||
|
await installLangBotApiMocks(page, {
|
||||||
|
authenticated: true,
|
||||||
});
|
});
|
||||||
const buttonBox = await nextButton.boundingBox();
|
await page.route('**/api/v1/platform/adapters', (route) =>
|
||||||
expect(buttonBox).not.toBeNull();
|
route.fulfill({
|
||||||
await page.mouse.click(
|
json: {
|
||||||
buttonBox!.x + buttonBox!.width / 2,
|
code: 0,
|
||||||
buttonBox!.y + buttonBox!.height / 2,
|
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
|
await expect
|
||||||
.poll(() =>
|
.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 ({
|
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(
|
await expect(
|
||||||
guide.getByRole('link', { name: /Runner Marketplace/ }),
|
guide.getByRole('link', { name: /Runner Marketplace/ }),
|
||||||
).toHaveAttribute('target', '_blank');
|
).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 guide.getByRole('button', { name: 'Next' }).click();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -211,38 +372,48 @@ test('runner guide covers selection, parameters, and event tools', async ({
|
|||||||
await expect(
|
await expect(
|
||||||
guide.getByRole('heading', { name: 'Set events and tools' }),
|
guide.getByRole('heading', { name: 'Set events and tools' }),
|
||||||
).toBeVisible();
|
).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 expect(guide.getByRole('button', { name: 'Finish' })).toBeEnabled();
|
||||||
await guide.getByRole('button', { name: 'Finish' }).click();
|
await guide.getByRole('button', { name: 'Finish' }).click();
|
||||||
await expect(guide).toHaveCount(0);
|
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,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await installLangBotApiMocks(page, {
|
await installLangBotApiMocks(page, {
|
||||||
authenticated: true,
|
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');
|
await page.goto('/home/agents?id=new');
|
||||||
|
|
||||||
const guide = page.getByTestId('processor-create-guide');
|
await expect(page.getByTestId('runner-setup-guide')).toHaveCount(0);
|
||||||
await expect(
|
await expect(page.getByRole('button', { name: /^Submit$/ })).toBeVisible();
|
||||||
guide.getByRole('heading', { name: 'Choose a processor type' }),
|
await expect(page.locator('input[name="name"]')).toBeVisible();
|
||||||
).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();
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,8 +16,19 @@ async function submit(page: Page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function selectPlaywrightAdapter(page: Page) {
|
async function selectPlaywrightAdapter(page: Page) {
|
||||||
await page.getByRole('combobox').click();
|
await page
|
||||||
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
|
.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) {
|
async function confirmDelete(page: Page) {
|
||||||
@@ -107,12 +118,11 @@ test.describe('frontend CRUD smoke flows', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.goto('/home/bots?id=new');
|
await page.goto('/home/bots?id=new');
|
||||||
await selectPlaywrightAdapter(page);
|
await createPlaywrightBot(
|
||||||
await page.locator('input[name="name"]').fill('Viewer Test Bot');
|
page,
|
||||||
await page
|
'Viewer Test Bot',
|
||||||
.locator('input[name="description"]')
|
'Proves monitoring is ordinary resource visibility.',
|
||||||
.fill('Proves monitoring is ordinary resource visibility.');
|
);
|
||||||
await submit(page);
|
|
||||||
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
|
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
|
||||||
|
|
||||||
await page.goto('/home/agents?id=new');
|
await page.goto('/home/agents?id=new');
|
||||||
@@ -152,14 +162,22 @@ test.describe('frontend CRUD smoke flows', () => {
|
|||||||
await installLangBotApiMocks(page, { authenticated: true });
|
await installLangBotApiMocks(page, { authenticated: true });
|
||||||
|
|
||||||
await page.goto('/home/bots?id=new');
|
await page.goto('/home/bots?id=new');
|
||||||
await selectPlaywrightAdapter(page);
|
const createRequest = page.waitForRequest(
|
||||||
|
(request) =>
|
||||||
await expect(page.locator('input[name="name"]')).toBeVisible();
|
request.method() === 'POST' &&
|
||||||
await page.locator('input[name="name"]').fill('Support Bot');
|
request.url().endsWith('/api/v1/platform/bots'),
|
||||||
await page
|
);
|
||||||
.locator('input[name="description"]')
|
await createPlaywrightBot(
|
||||||
.fill('Answers customer support questions.');
|
page,
|
||||||
await submit(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 expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
|
||||||
await page.reload();
|
await page.reload();
|
||||||
@@ -293,7 +311,7 @@ test.describe('frontend CRUD smoke flows', () => {
|
|||||||
await page
|
await page
|
||||||
.locator('input[name="description"]')
|
.locator('input[name="description"]')
|
||||||
.fill('Source material for support answers.');
|
.fill('Source material for support answers.');
|
||||||
await submit(page);
|
await save(page);
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/home\/knowledge\?id=knowledge-1$/);
|
await expect(page).toHaveURL(/\/home\/knowledge\?id=knowledge-1$/);
|
||||||
await page.reload();
|
await page.reload();
|
||||||
@@ -481,9 +499,7 @@ test.describe('bot advanced flows', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
await page.goto('/home/bots?id=new');
|
await page.goto('/home/bots?id=new');
|
||||||
await selectPlaywrightAdapter(page);
|
await createPlaywrightBot(page, 'Route Status Bot');
|
||||||
await page.locator('input[name="name"]').fill('Route Status Bot');
|
|
||||||
await submit(page);
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
|
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
|
||||||
await expect(page.getByText('Supported events')).toBeVisible();
|
await expect(page.getByText('Supported events')).toBeVisible();
|
||||||
@@ -629,14 +645,14 @@ test.describe('bot advanced flows', () => {
|
|||||||
).toBeVisible();
|
).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 });
|
await installLangBotApiMocks(page, { authenticated: true });
|
||||||
|
|
||||||
// Create a bot first
|
// Create a bot first
|
||||||
await page.goto('/home/bots?id=new');
|
await page.goto('/home/bots?id=new');
|
||||||
await selectPlaywrightAdapter(page);
|
await createPlaywrightBot(page, 'Toggle Test Bot');
|
||||||
await page.locator('input[name="name"]').fill('Toggle Test Bot');
|
|
||||||
await submit(page);
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
|
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
|
||||||
|
|
||||||
@@ -645,16 +661,16 @@ test.describe('bot advanced flows', () => {
|
|||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Verify initial state is enabled
|
// Draft bots remain disabled until their adapter configuration is ready.
|
||||||
await expect(page.locator('#bot-enable-switch')).toBeChecked();
|
|
||||||
|
|
||||||
// Toggle to disabled
|
|
||||||
await page.locator('#bot-enable-switch').click();
|
|
||||||
await expect(page.locator('#bot-enable-switch')).not.toBeChecked();
|
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
|
// Reload and verify state persisted
|
||||||
await page.reload();
|
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 }) => {
|
test('switches between bot detail tabs', async ({ page }) => {
|
||||||
@@ -662,9 +678,7 @@ test.describe('bot advanced flows', () => {
|
|||||||
|
|
||||||
// Create a bot
|
// Create a bot
|
||||||
await page.goto('/home/bots?id=new');
|
await page.goto('/home/bots?id=new');
|
||||||
await selectPlaywrightAdapter(page);
|
await createPlaywrightBot(page, 'Tab Test Bot');
|
||||||
await page.locator('input[name="name"]').fill('Tab Test Bot');
|
|
||||||
await submit(page);
|
|
||||||
|
|
||||||
// Verify we're on the Configuration tab
|
// Verify we're on the Configuration tab
|
||||||
await expect(
|
await expect(
|
||||||
@@ -700,9 +714,7 @@ test.describe('bot advanced flows', () => {
|
|||||||
|
|
||||||
// Create a bot
|
// Create a bot
|
||||||
await page.goto('/home/bots?id=new');
|
await page.goto('/home/bots?id=new');
|
||||||
await selectPlaywrightAdapter(page);
|
await createPlaywrightBot(page, 'Clean Form Bot');
|
||||||
await page.locator('input[name="name"]').fill('Clean Form Bot');
|
|
||||||
await submit(page);
|
|
||||||
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
|
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
|
||||||
|
|
||||||
// Reload the persisted record so post-create initialization has completed.
|
// 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');
|
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 selectPlaywrightAdapter(page);
|
||||||
await submit(page);
|
await submit(page);
|
||||||
|
|
||||||
// Should show validation error for name (zod validation)
|
|
||||||
await expect(page.getByText(/cannot be empty/i)).toBeVisible();
|
await expect(page.getByText(/cannot be empty/i)).toBeVisible();
|
||||||
await expect(page).toHaveURL(/\/home\/bots\?id=new$/);
|
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 page.goto('/home/bots?id=new');
|
||||||
await selectPlaywrightAdapter(page);
|
await createPlaywrightBot(page, 'Routing Bot');
|
||||||
await page.locator('input[name="name"]').fill('Routing Bot');
|
|
||||||
await submit(page);
|
|
||||||
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
|
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Add behavior' }).click();
|
await page.getByRole('button', { name: 'Add behavior' }).click();
|
||||||
@@ -1412,9 +1421,7 @@ test.describe('cross-resource flows', () => {
|
|||||||
|
|
||||||
// Create a bot
|
// Create a bot
|
||||||
await page.goto('/home/bots?id=new');
|
await page.goto('/home/bots?id=new');
|
||||||
await selectPlaywrightAdapter(page);
|
await createPlaywrightBot(page, 'Bound Bot');
|
||||||
await page.locator('input[name="name"]').fill('Bound Bot');
|
|
||||||
await submit(page);
|
|
||||||
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
|
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
|
||||||
|
|
||||||
// Wait for form to fully load
|
// Wait for form to fully load
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ interface KnowledgeBaseMock {
|
|||||||
knowledge_engine_plugin_id: string;
|
knowledge_engine_plugin_id: string;
|
||||||
creation_settings: JsonRecord;
|
creation_settings: JsonRecord;
|
||||||
retrieval_settings: JsonRecord;
|
retrieval_settings: JsonRecord;
|
||||||
|
initialized: boolean;
|
||||||
knowledge_engine: {
|
knowledge_engine: {
|
||||||
plugin_id: string;
|
plugin_id: string;
|
||||||
name: {
|
name: {
|
||||||
@@ -465,6 +466,10 @@ function makeKnowledgeBase(
|
|||||||
creation_settings: (data.creation_settings as JsonRecord | undefined) || {},
|
creation_settings: (data.creation_settings as JsonRecord | undefined) || {},
|
||||||
retrieval_settings:
|
retrieval_settings:
|
||||||
(data.retrieval_settings as JsonRecord | undefined) || {},
|
(data.retrieval_settings as JsonRecord | undefined) || {},
|
||||||
|
initialized:
|
||||||
|
data.initialized === false || data.defer_initialization === true
|
||||||
|
? false
|
||||||
|
: true,
|
||||||
knowledge_engine: {
|
knowledge_engine: {
|
||||||
plugin_id: engine.plugin_id,
|
plugin_id: engine.plugin_id,
|
||||||
name: engine.name,
|
name: engine.name,
|
||||||
@@ -896,7 +901,18 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
|||||||
const baseId = decodeURIComponent(knowledgeBaseMatch[1]);
|
const baseId = decodeURIComponent(knowledgeBaseMatch[1]);
|
||||||
|
|
||||||
if (method === 'PUT') {
|
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 = [
|
||||||
...state.knowledgeBases.filter((item) => item.uuid !== baseId),
|
...state.knowledgeBases.filter((item) => item.uuid !== baseId),
|
||||||
base,
|
base,
|
||||||
@@ -1299,10 +1315,9 @@ export async function installLangBotApiMocks(
|
|||||||
localStorage.setItem('langbot_sidebar_guide_v1', 'completed');
|
localStorage.setItem('langbot_sidebar_guide_v1', 'completed');
|
||||||
}
|
}
|
||||||
const contextualGuides = [
|
const contextualGuides = [
|
||||||
'langbot_bot_create_guide_v4',
|
'langbot_bot_detail_guide_v1',
|
||||||
'langbot_processor_create_guide_v1',
|
|
||||||
'langbot_runner_setup_guide_v1',
|
'langbot_runner_setup_guide_v1',
|
||||||
'langbot_knowledge_create_guide_v1',
|
'langbot_knowledge_detail_guide_v1',
|
||||||
];
|
];
|
||||||
for (const guideKey of contextualGuides) {
|
for (const guideKey of contextualGuides) {
|
||||||
if (!Object.hasOwn(storage, guideKey)) {
|
if (!Object.hasOwn(storage, guideKey)) {
|
||||||
|
|||||||
@@ -56,8 +56,10 @@ test('creates a configured processor, persists subscriptions separately and reus
|
|||||||
return route.fulfill({ json: { code: 0, data: { agents: processors } } });
|
return route.fulfill({ json: { code: 0, data: { agents: processors } } });
|
||||||
});
|
});
|
||||||
await page.goto('/home/bots?id=new');
|
await page.goto('/home/bots?id=new');
|
||||||
await page.getByRole('combobox').click();
|
await page
|
||||||
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
|
.getByTestId('adapter-gallery')
|
||||||
|
.getByRole('button', { name: /Playwright Adapter/ })
|
||||||
|
.click();
|
||||||
await page.locator('input[name="name"]').fill('Subscription Bot');
|
await page.locator('input[name="name"]').fill('Subscription Bot');
|
||||||
await page.getByRole('button', { name: /^Submit$/ }).click();
|
await page.getByRole('button', { name: /^Submit$/ }).click();
|
||||||
await expect(page).toHaveURL(/id=bot-1$/);
|
await expect(page).toHaveURL(/id=bot-1$/);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, Page, test } from '@playwright/test';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
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.describe('wizard and QR platform regressions', () => {
|
||||||
test('opens the Page Bot test panel after the first and every later save', async ({
|
test('opens the Page Bot test panel after the first and every later save', async ({
|
||||||
page,
|
page,
|
||||||
@@ -476,8 +486,7 @@ test.describe('wizard and QR platform regressions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.goto('/home/bots?id=new');
|
await page.goto('/home/bots?id=new');
|
||||||
await page.getByRole('combobox').click();
|
await createDraftBot(page, qrPlatform.label);
|
||||||
await page.getByRole('option', { name: qrPlatform.label }).click();
|
|
||||||
await page.getByRole('button', { name: /^Start$/ }).click();
|
await page.getByRole('button', { name: /^Start$/ }).click();
|
||||||
|
|
||||||
const qrImage = page.getByRole('img', { name: 'QR Code' });
|
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.goto('/home/bots?id=new');
|
||||||
await page.getByRole('combobox').click();
|
await createDraftBot(page, qrPlatform.label);
|
||||||
await page.getByRole('option', { name: qrPlatform.label }).click();
|
|
||||||
await page.getByRole('button', { name: /^Start$/ }).click();
|
await page.getByRole('button', { name: /^Start$/ }).click();
|
||||||
|
|
||||||
const dialog = page.getByRole('dialog');
|
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 expect(dialog.getByRole('button', { name: 'Retry' })).toBeEnabled();
|
||||||
await dialog.getByRole('button', { name: 'Cancel' }).click();
|
await dialog.getByRole('button', { name: 'Cancel' }).click();
|
||||||
await expect(dialog).toHaveCount(0);
|
await expect(dialog).toHaveCount(0);
|
||||||
await expect(page.getByRole('button', { name: /^Submit$/ })).toBeVisible();
|
await expect(page.getByRole('button', { name: /^Save$/ })).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user