feat: simplify resource setup and detail guides

This commit is contained in:
fdc310
2026-09-19 02:39:35 +08:00
parent 960e322c79
commit cc8a0ec847
27 changed files with 1237 additions and 840 deletions
+1 -1
View File
@@ -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",
+64 -19
View File
@@ -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')
+20 -13
View File
@@ -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()
+20
View File
@@ -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()
Generated
+4 -4
View File
@@ -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">
+5 -2
View File
@@ -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]));
}
+22 -13
View File
@@ -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>
); );
+3
View File
@@ -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;
} }
+17 -6
View File
@@ -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: ',
+15 -8
View File
@@ -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: '删除失败:',
+7 -5
View File
@@ -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'),
+249 -78
View File
@@ -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();
}); });
+51 -44
View File
@@ -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
+19 -4
View File
@@ -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)) {
+4 -2
View File
@@ -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();
}); });
}); });