feat(web): add contextual setup guides

This commit is contained in:
fdc310
2026-09-18 19:08:30 +08:00
parent c4e14516ed
commit 960e322c79
15 changed files with 1832 additions and 207 deletions
@@ -115,6 +115,7 @@ export default function AgentDetailContent({ id }: { id: string }) {
if (isCreateMode) {
return (
<AgentCreateContent
guideEnabled={canManage}
onCreated={(newAgentId) => {
refreshPipelines();
navigate(`/home/agents?id=${encodeURIComponent(newAgentId)}`);
@@ -248,6 +249,7 @@ export default function AgentDetailContent({ id }: { id: string }) {
onRunnerStatusChange={setRunnerStatus}
onSupportedEventPatternsChange={setSupportedEventPatterns}
onPlatformToolsChange={setPlatformTools}
guideEnabled={canManage}
/>
</fieldset>
}
@@ -27,11 +27,16 @@ import {
import { Input } from '@/components/ui/input';
import EmojiPicker from '@/components/ui/emoji-picker';
import ProcessorTypeDiagram from './ProcessorTypeDiagram';
import GuidedTour, {
GuidedTourStep,
} from '@/app/home/components/guided-tour/GuidedTour';
export default function AgentCreateContent({
onCreated,
guideEnabled = true,
}: {
onCreated: (agentId: string) => void;
guideEnabled?: boolean;
}) {
const { t } = useTranslation();
const [kind, setKind] = useState<AgentKind>('agent');
@@ -109,6 +114,28 @@ export default function AgentCreateContent({
description: t('agents.eventProcessor.description'),
},
];
const guideSteps: GuidedTourStep[] = [
{
id: 'type',
target: '[data-guide="processor-type"]',
title: t('guidedTour.processorCreate.type.title'),
description: t('guidedTour.processorCreate.type.description'),
},
{
id: 'basic',
target: '[data-guide="processor-basic"]',
title: t('guidedTour.processorCreate.basic.title'),
description: t('guidedTour.processorCreate.basic.description'),
complete: Boolean(form.watch('name')?.trim()),
requirement: t('guidedTour.processorCreate.basic.requirement'),
},
{
id: 'submit',
target: '[data-guide="processor-submit"]',
title: t('guidedTour.processorCreate.submit.title'),
description: t('guidedTour.processorCreate.submit.description'),
},
];
return (
<div className="flex h-full flex-col">
@@ -120,6 +147,7 @@ export default function AgentCreateContent({
type="submit"
form="agent-create-form"
disabled={form.formState.isSubmitting}
data-guide="processor-submit"
>
{t('common.submit')}
</Button>
@@ -132,6 +160,7 @@ export default function AgentCreateContent({
<section
aria-labelledby="processor-kind-heading"
className="space-y-3"
data-guide="processor-type"
>
<div>
<h2
@@ -182,7 +211,7 @@ export default function AgentCreateContent({
</ToggleGroup>
</section>
<Card>
<Card data-guide="processor-basic">
<CardHeader>
<CardTitle>{t('agents.basicInfo')}</CardTitle>
<CardDescription>
@@ -258,6 +287,12 @@ export default function AgentCreateContent({
</div>
</div>
</div>
<GuidedTour
enabled={guideEnabled}
storageKey="langbot_processor_create_guide_v1"
steps={guideSteps}
testId="processor-create-guide"
/>
</div>
);
}
@@ -45,6 +45,10 @@ import {
import { Form, FormField, FormItem, FormMessage } from '@/components/ui/form';
import AgentEventPatternPicker from './AgentEventPatternPicker';
import RunnerSelect from './RunnerSelect';
import GuidedTour, {
GuidedTourStep,
} from '@/app/home/components/guided-tour/GuidedTour';
import { areRequiredDynamicFieldsComplete } from '@/app/home/components/guided-tour/dynamic-form-progress';
import AgentApiToolPicker from './AgentApiToolPicker';
const OTHER_TOOL_SCOPES = [
@@ -70,6 +74,7 @@ interface AgentFormComponentProps {
onRunnerStatusChange?: (status: RunnerStatus) => void;
onSupportedEventPatternsChange?: (patterns: string[]) => void;
onPlatformToolsChange?: (tools: AgentPlatformTool[]) => void;
guideEnabled?: boolean;
}
export type AgentConfigSection = 'runner' | 'events_and_tools';
@@ -124,6 +129,7 @@ function AgentFormComponent(
onRunnerStatusChange,
onSupportedEventPatternsChange,
onPlatformToolsChange,
guideEnabled = true,
}: AgentFormComponentProps,
ref: ForwardedRef<AgentFormHandle>,
) {
@@ -469,6 +475,54 @@ function AgentFormComponent(
t,
]);
const runnerGuideSteps = useMemo<GuidedTourStep[]>(() => {
const steps: GuidedTourStep[] = [
{
id: 'runner',
target: '[data-guide="runner-selector"]',
title: t('guidedTour.runner.select.title'),
description: t('guidedTour.runner.select.description'),
complete: Boolean(currentRunner && selectedRunnerOption),
requirement: t('guidedTour.runner.select.requirement'),
action: {
href: 'https://space.langbot.app/market?type=plugin&component=Runner&runner_usage=agent',
label: t('guidedTour.runner.select.action'),
},
},
];
if (activeRunnerStage) {
steps.push({
id: 'parameters',
target: '[data-guide="runner-parameters"]',
title: t('guidedTour.runner.parameters.title'),
description: t('guidedTour.runner.parameters.description'),
complete: areRequiredDynamicFieldsComplete(
activeRunnerStage.config,
activeRunnerValues,
),
requirement: t('guidedTour.runner.parameters.requirement'),
});
}
steps.push({
id: 'events',
target: '[data-guide="agent-sections"]',
title: t('guidedTour.runner.events.title'),
description: t('guidedTour.runner.events.description'),
complete: activeSection === 'events_and_tools',
requirement: t('guidedTour.runner.events.requirement'),
});
return steps;
}, [
activeRunnerStage,
activeRunnerValues,
activeSection,
currentRunner,
selectedRunnerOption,
t,
]);
useEffect(() => {
onRunnerStatusChange?.(runnerStatus);
}, [onRunnerStatusChange, runnerStatus]);
@@ -517,7 +571,10 @@ function AgentFormComponent(
] || {};
return (
<Card key={stage.name}>
<Card
key={stage.name}
data-guide={isRunnerSelector ? 'runner-selector' : 'runner-parameters'}
>
<CardHeader>
<CardTitle>{extractI18nObject(stage.label)}</CardTitle>
{stage.description && (
@@ -660,7 +717,10 @@ function AgentFormComponent(
onSubmit={form.handleSubmit(handleSubmit)}
className="mb-2 flex h-full min-h-0 min-w-0 flex-1 flex-col"
>
<nav className="mb-4 shrink-0 space-y-2 border-b pb-4">
<nav
className="mb-4 shrink-0 space-y-2 border-b pb-4"
data-guide="agent-sections"
>
<Tabs
value={activeSection}
onValueChange={(value) =>
@@ -806,6 +866,12 @@ function AgentFormComponent(
)}
</div>
</div>
<GuidedTour
enabled={guideEnabled}
storageKey="langbot_runner_setup_guide_v1"
steps={runnerGuideSteps}
testId="runner-setup-guide"
/>
</form>
</Form>
</div>
+2 -1
View File
@@ -163,7 +163,7 @@ export default function BotDetailContent({ id }: { id: string }) {
<div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold">{t('bots.createBot')}</h1>
{canManage && (
<Button type="submit" form="bot-form">
<Button type="submit" form="bot-form" data-guide="bot-submit">
{t('common.submit')}
</Button>
)}
@@ -177,6 +177,7 @@ export default function BotDetailContent({ id }: { id: string }) {
initBotId={undefined}
onFormSubmit={handleFormSubmit}
onNewBotCreated={handleNewBotCreated}
guideEnabled={canManage}
/>
</fieldset>
</div>
@@ -15,14 +15,23 @@ import {
getDefaultValues,
parseDynamicFormItemType,
} from '@/app/home/components/dynamic-form/DynamicFormItemConfig';
import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
import {
DynamicFormItemType,
IDynamicFormItemSchema,
} from '@/app/infra/entities/form/dynamic';
import { UUID } from 'uuidjs';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import { httpClient } from '@/app/infra/http/HttpClient';
import { systemInfo } from '@/app/infra/http';
import { Agent, Bot } from '@/app/infra/entities/api';
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
import { ExternalLink, ChevronDown, ChevronRight } from 'lucide-react';
import {
Cable,
ExternalLink,
ChevronDown,
ChevronRight,
Webhook,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import EventBindingsEditor from './EventBindingsEditor';
import PluginProcessorBindings from './PluginProcessorBindings';
@@ -65,6 +74,89 @@ import {
groupByCategory,
getCategoryLabel,
} from '@/app/infra/entities/adapter-categories';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import GuidedTour, {
GuidedTourStep,
} from '@/app/home/components/guided-tour/GuidedTour';
import { areRequiredDynamicFieldsComplete } from '@/app/home/components/guided-tour/dynamic-form-progress';
type ConnectionMode = 'webhook' | 'persistent';
function supportsConnectionMode(
config: IDynamicFormItemSchema[],
mode: ConnectionMode,
) {
const webhookField = config.find(
(item) =>
item.name === 'webhook_url' ||
item.name === '__system.webhook_url' ||
item.type === DynamicFormItemType.WEBHOOK_URL,
);
if (!webhookField) return mode === 'persistent';
if (webhookField.show_if) return true;
return mode === 'webhook';
}
function getSupportedConnectionModes(config: IDynamicFormItemSchema[]) {
return (['webhook', 'persistent'] as const).filter((mode) =>
supportsConnectionMode(config, mode),
);
}
function conditionMatches(
condition: NonNullable<IDynamicFormItemSchema['show_if']>,
value: unknown,
) {
if (condition.operator === 'eq') return value === condition.value;
if (condition.operator === 'neq') return value !== condition.value;
return Array.isArray(condition.value) && condition.value.includes(value);
}
function applyConnectionMode(
config: IDynamicFormItemSchema[],
values: Record<string, unknown>,
mode: ConnectionMode,
) {
const webhookField = config.find(
(item) => item.type === DynamicFormItemType.WEBHOOK_URL,
);
const condition = webhookField?.show_if;
if (!condition) return values;
const controller = config.find((item) => item.name === condition.field);
const candidates: unknown[] = [
controller?.default,
...(controller?.options?.map((option) => option.name) ?? []),
true,
false,
'',
];
const shouldMatch = mode === 'webhook';
const nextValue = candidates.find(
(candidate) =>
candidate !== undefined &&
conditionMatches(condition, candidate) === shouldMatch,
);
if (nextValue === undefined) return values;
return { ...values, [condition.field]: nextValue };
}
function detectConnectionMode(
config: IDynamicFormItemSchema[],
values: Record<string, unknown>,
): ConnectionMode {
const webhookField = config.find(
(item) => item.type === DynamicFormItemType.WEBHOOK_URL,
);
if (!webhookField) return 'persistent';
if (!webhookField.show_if) return 'webhook';
return conditionMatches(
webhookField.show_if,
values[webhookField.show_if.field],
)
? 'webhook'
: 'persistent';
}
const getFormSchema = (t: (key: string) => string) =>
z.object({
@@ -108,6 +200,7 @@ interface BotFormProps {
onNewBotCreated: (botId: string) => void;
onDirtyChange?: (dirty: boolean) => void;
onAdapterLabelChange?: (label: string) => void;
guideEnabled?: boolean;
}
const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
@@ -117,6 +210,7 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
onNewBotCreated,
onDirtyChange,
onAdapterLabelChange,
guideEnabled = true,
},
ref,
) {
@@ -167,8 +261,12 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
const [isLoading, setIsLoading] = useState<boolean>(false);
const [webhookUrl, setWebhookUrl] = useState<string>('');
const [extraWebhookUrl, setExtraWebhookUrl] = useState<string>('');
const [connectionMode, setConnectionMode] = useState<ConnectionMode | null>(
null,
);
// Watch adapter and adapter_config for filtering
const currentBotName = form.watch('name');
const currentAdapter = form.watch('adapter');
const adapterLabel =
adapterNameList.find((adapter) => adapter.value === currentAdapter)
@@ -393,23 +491,143 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
function handleAdapterSelect(adapterName: string) {
if (adapterName) {
const dynamicFormConfigList =
adapterNameToDynamicConfigMap.get(adapterName);
if (dynamicFormConfigList) {
setDynamicFormConfigList(dynamicFormConfigList);
const adapterConfig = adapterNameToDynamicConfigMap.get(adapterName);
if (adapterConfig) {
setDynamicFormConfigList(adapterConfig);
if (!initBotId) {
const defaultValues = getDefaultValues(adapterConfig);
const supportedModes = getSupportedConnectionModes(adapterConfig);
const nextMode =
supportedModes.length === 1 ? supportedModes[0] : null;
setConnectionMode(nextMode);
form.setValue(
'adapter_config',
getDefaultValues(dynamicFormConfigList),
nextMode
? applyConnectionMode(adapterConfig, defaultValues, nextMode)
: defaultValues,
);
} else {
setConnectionMode(
detectConnectionMode(
adapterConfig,
form.getValues('adapter_config') || {},
),
);
}
}
setShowDynamicForm(true);
} else {
setConnectionMode(null);
setShowDynamicForm(false);
}
}
function handleConnectionModeChange(mode: ConnectionMode) {
if (!currentAdapter) return;
const adapterConfig =
adapterNameToDynamicConfigMap.get(currentAdapter) ?? [];
if (!supportsConnectionMode(adapterConfig, mode)) return;
setConnectionMode(mode);
form.setValue(
'adapter_config',
applyConnectionMode(adapterConfig, currentAdapterConfig, mode),
{ shouldDirty: true },
);
}
const supportedConnectionModes = useMemo(
() =>
currentAdapter
? getSupportedConnectionModes(
adapterNameToDynamicConfigMap.get(currentAdapter) ?? [],
)
: [],
[adapterNameToDynamicConfigMap, currentAdapter],
);
const botGuideSteps = useMemo<GuidedTourStep[]>(() => {
const steps: GuidedTourStep[] = [
{
id: 'basic',
target: '[data-guide="bot-basic"]',
title: t('guidedTour.bot.basic.title'),
description: t('guidedTour.bot.basic.description'),
complete: Boolean(currentBotName?.trim()),
requirement: t('guidedTour.bot.basic.requirement'),
},
{
id: 'adapter',
target: '[data-guide="bot-adapter"]',
title: t('guidedTour.bot.adapter.title'),
description: t('guidedTour.bot.adapter.description'),
complete: Boolean(currentAdapter),
advanceOnComplete: true,
requirement: t('guidedTour.bot.adapter.requirement'),
},
];
if (currentAdapter) {
steps.push({
id: 'connection',
target: '[data-guide="bot-connection-mode"]',
title: t('guidedTour.bot.connection.title'),
description: t('guidedTour.bot.connection.description'),
complete: connectionMode !== null,
advanceOnComplete: true,
requirement: t('guidedTour.bot.connection.requirement'),
});
}
if (currentAdapter && dynamicFormConfigList.length > 0) {
const docsUrl = getAdapterDocUrl(
adapterHelpLinks[currentAdapter],
i18n.language,
);
steps.push({
id: 'parameters',
target: '[data-guide="bot-adapter-parameters"]',
title: t('guidedTour.bot.parameters.title'),
description: t('guidedTour.bot.parameters.description'),
complete: areRequiredDynamicFieldsComplete(
dynamicFormConfigList,
currentAdapterConfig,
),
requirement: t('guidedTour.bot.parameters.requirement'),
action: docsUrl
? {
href: docsUrl,
label: t('guidedTour.bot.parameters.action'),
}
: undefined,
});
}
if (currentAdapter) {
steps.push({
id: 'routing',
target: '[data-guide="bot-routing"]',
title: t('guidedTour.bot.routing.title'),
description: t('guidedTour.bot.routing.description'),
});
}
steps.push({
id: 'submit',
target: '[data-guide="bot-submit"]',
title: t('guidedTour.bot.submit.title'),
description: t('guidedTour.bot.submit.description'),
});
return steps;
}, [
adapterHelpLinks,
connectionMode,
currentAdapter,
currentAdapterConfig,
currentBotName,
dynamicFormConfigList,
t,
]);
function onDynamicFormSubmit() {
setIsLoading(true);
if (initBotId) {
@@ -494,7 +712,7 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
disabled={isLoading}
>
{!initBotId && (
<Card>
<Card data-guide="bot-basic">
<CardHeader>
<CardTitle>{t('bots.basicInfo')}</CardTitle>
<CardDescription>
@@ -554,201 +772,268 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
initBotId && 'lg:min-h-0 lg:flex-1 lg:overflow-y-auto',
)}
>
<FormField
control={form.control}
name="adapter"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('bots.platformAdapter')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<div className="flex flex-wrap items-center gap-2">
<Select
onValueChange={(value) => {
field.onChange(value);
handleAdapterSelect(value);
}}
value={field.value}
>
<SelectTrigger className="w-full min-w-0 overflow-hidden sm:w-[240px]">
{field.value ? (
<div className="flex min-w-0 items-center gap-2">
<img
src={httpClient.getAdapterIconURL(
field.value,
)}
alt=""
className="h-5 w-5 shrink-0 rounded"
/>
{(() => {
const selectedAdapter = adapterNameList.find(
(a) => a.value === field.value,
);
<div data-guide="bot-adapter">
<FormField
control={form.control}
name="adapter"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('bots.platformAdapter')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<div className="flex flex-wrap items-center gap-2">
<Select
onValueChange={(value) => {
field.onChange(value);
handleAdapterSelect(value);
}}
value={field.value}
>
<SelectTrigger className="w-full min-w-0 overflow-hidden sm:w-[240px]">
{field.value ? (
<div className="flex min-w-0 items-center gap-2">
<img
src={httpClient.getAdapterIconURL(
field.value,
)}
alt=""
className="h-5 w-5 shrink-0 rounded"
/>
{(() => {
const selectedAdapter =
adapterNameList.find(
(a) => a.value === field.value,
);
return (
<>
<span className="min-w-0 truncate">
{selectedAdapter?.label ?? field.value}
</span>
{selectedAdapter?.legacy && (
<span className="shrink-0 rounded border border-amber-200 bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-300">
{t('bots.legacyAdapterBadge')}
return (
<>
<span className="min-w-0 truncate">
{selectedAdapter?.label ??
field.value}
</span>
)}
</>
);
})()}
</div>
) : (
<SelectValue
placeholder={t('bots.selectAdapter')}
/>
)}
</SelectTrigger>
<SelectContent>
{groupedAdapters.map((group) => (
<SelectGroup
key={group.categoryId ?? 'uncategorized'}
>
{group.categoryId && (
<SelectLabel>
{getCategoryLabel(t, group.categoryId)}
</SelectLabel>
)}
{group.items.map((item) => (
<SelectItem
key={`${group.categoryId ?? 'uncategorized'}:${item.value}`}
value={item.value}
>
<div className="flex min-w-0 w-full items-center gap-2">
<img
src={httpClient.getAdapterIconURL(
item.value,
{selectedAdapter?.legacy && (
<span className="shrink-0 rounded border border-amber-200 bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-300">
{t('bots.legacyAdapterBadge')}
</span>
)}
alt=""
className="h-5 w-5 shrink-0 rounded"
/>
<span className="min-w-0 truncate">
{item.label}
</span>
</div>
</SelectItem>
))}
</SelectGroup>
))}
{legacyAdapters.length > 0 && (
<>
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowLegacyAdapters((v) => !v);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setShowLegacyAdapters((v) => !v);
}
}}
className="flex cursor-pointer items-center gap-1 px-2 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground border-t mt-1 pt-2"
>
{showLegacyAdapters ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
{t('bots.legacyAdapters')}
<span className="ml-1 rounded bg-muted px-1.5 py-0.5 text-[10px]">
{legacyAdapters.length}
</span>
</>
);
})()}
</div>
{showLegacyAdapters && (
<>
<p className="whitespace-pre-line px-2 pb-1 text-[11px] leading-snug text-muted-foreground">
{t('bots.legacyAdaptersHint')}
</p>
<SelectGroup>
{legacyAdapters.map((item) => (
<SelectItem
key={`legacy:${item.value}`}
value={item.value}
>
<div className="flex min-w-0 w-full items-center gap-2 opacity-70">
<img
src={httpClient.getAdapterIconURL(
item.value,
)}
alt=""
className="h-5 w-5 shrink-0 rounded grayscale"
/>
<span className="min-w-0 truncate">
{item.label}
</span>
<span className="ml-auto shrink-0 rounded border border-amber-200 bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-300">
{t('bots.legacyAdapterBadge')}
</span>
</div>
</SelectItem>
))}
</SelectGroup>
</>
)}
</>
)}
</SelectContent>
</Select>
{currentAdapter &&
(() => {
const docUrl = getAdapterDocUrl(
adapterHelpLinks[currentAdapter],
i18n.language,
);
return docUrl ? (
<a
href={docUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex shrink-0 items-center gap-1 text-xs text-primary hover:underline"
>
{t('bots.viewAdapterDocs')}
<ExternalLink className="h-3 w-3" />
</a>
) : null;
})()}
</div>
</FormControl>
{currentAdapter &&
adapterDescriptionList[currentAdapter] && (
<FormDescription>
{adapterDescriptionList[currentAdapter]}
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
) : (
<SelectValue
placeholder={t('bots.selectAdapter')}
/>
)}
</SelectTrigger>
<SelectContent className="z-[70]">
{groupedAdapters.map((group) => (
<SelectGroup
key={group.categoryId ?? 'uncategorized'}
>
{group.categoryId && (
<SelectLabel>
{getCategoryLabel(t, group.categoryId)}
</SelectLabel>
)}
{group.items.map((item) => (
<SelectItem
key={`${group.categoryId ?? 'uncategorized'}:${item.value}`}
value={item.value}
>
<div className="flex min-w-0 w-full items-center gap-2">
<img
src={httpClient.getAdapterIconURL(
item.value,
)}
alt=""
className="h-5 w-5 shrink-0 rounded"
/>
<span className="min-w-0 truncate">
{item.label}
</span>
</div>
</SelectItem>
))}
</SelectGroup>
))}
{legacyAdapters.length > 0 && (
<>
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowLegacyAdapters((v) => !v);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setShowLegacyAdapters((v) => !v);
}
}}
className="flex cursor-pointer items-center gap-1 px-2 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground border-t mt-1 pt-2"
>
{showLegacyAdapters ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
{t('bots.legacyAdapters')}
<span className="ml-1 rounded bg-muted px-1.5 py-0.5 text-[10px]">
{legacyAdapters.length}
</span>
</div>
{showLegacyAdapters && (
<>
<p className="whitespace-pre-line px-2 pb-1 text-[11px] leading-snug text-muted-foreground">
{t('bots.legacyAdaptersHint')}
</p>
<SelectGroup>
{legacyAdapters.map((item) => (
<SelectItem
key={`legacy:${item.value}`}
value={item.value}
>
<div className="flex min-w-0 w-full items-center gap-2 opacity-70">
<img
src={httpClient.getAdapterIconURL(
item.value,
)}
alt=""
className="h-5 w-5 shrink-0 rounded grayscale"
/>
<span className="min-w-0 truncate">
{item.label}
</span>
<span className="ml-auto shrink-0 rounded border border-amber-200 bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-300">
{t('bots.legacyAdapterBadge')}
</span>
</div>
</SelectItem>
))}
</SelectGroup>
</>
)}
</>
)}
</SelectContent>
</Select>
{currentAdapter &&
(() => {
const docUrl = getAdapterDocUrl(
adapterHelpLinks[currentAdapter],
i18n.language,
);
return docUrl ? (
<a
href={docUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex shrink-0 items-center gap-1 text-xs text-primary hover:underline"
>
{t('bots.viewAdapterDocs')}
<ExternalLink className="h-3 w-3" />
</a>
) : null;
})()}
</div>
</FormControl>
{currentAdapter &&
adapterDescriptionList[currentAdapter] && (
<FormDescription>
{adapterDescriptionList[currentAdapter]}
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
</div>
{!initBotId && currentAdapter && (
<div
data-guide="bot-connection-mode"
className="space-y-3 rounded-md border bg-muted/20 p-4"
>
<div>
<h3 className="text-sm font-medium">
{t('bots.connectionMode')}
</h3>
<p className="mt-1 text-xs text-muted-foreground">
{t('bots.connectionModeDescription')}
</p>
</div>
<ToggleGroup
type="single"
value={connectionMode ?? ''}
onValueChange={(value) => {
if (value) {
handleConnectionModeChange(value as ConnectionMode);
}
}}
variant="outline"
className="grid w-full grid-cols-1 gap-3 sm:grid-cols-2"
spacing={3}
>
<ToggleGroupItem
value="webhook"
disabled={!supportedConnectionModes.includes('webhook')}
className="h-auto min-h-20 justify-start gap-3 whitespace-normal px-4 py-3 text-left data-[state=on]:border-blue-500/50 data-[state=on]:bg-blue-50 dark:data-[state=on]:bg-blue-500/10"
>
<Webhook className="size-5 shrink-0 text-primary" />
<span>
<span className="block font-medium">
{t('bots.connectionWebhook')}
</span>
<span className="mt-1 block text-xs font-normal text-muted-foreground">
{t('bots.connectionWebhookDescription')}
</span>
</span>
</ToggleGroupItem>
<ToggleGroupItem
value="persistent"
disabled={
!supportedConnectionModes.includes('persistent')
}
className="h-auto min-h-20 justify-start gap-3 whitespace-normal px-4 py-3 text-left data-[state=on]:border-blue-500/50 data-[state=on]:bg-blue-50 dark:data-[state=on]:bg-blue-500/10"
>
<Cable className="size-5 shrink-0 text-primary" />
<span>
<span className="block font-medium">
{t('bots.connectionPersistent')}
</span>
<span className="mt-1 block text-xs font-normal text-muted-foreground">
{t('bots.connectionPersistentDescription')}
</span>
</span>
</ToggleGroupItem>
</ToggleGroup>
</div>
)}
{showDynamicForm && dynamicFormConfigList.length > 0 && (
<DynamicFormComponent
itemConfigList={dynamicFormConfigList}
initialValues={currentAdapterConfig}
onSubmit={(values) => {
form.setValue('adapter_config', values, {
shouldDirty: !isInitializing.current,
});
}}
systemContext={{
webhook_url: webhookUrl,
extra_webhook_url: extraWebhookUrl,
bot_uuid: initBotId || '',
adapter_config: form.getValues('adapter_config') || {},
outbound_ips: systemInfo.outbound_ips,
}}
/>
<div data-guide="bot-adapter-parameters">
<DynamicFormComponent
itemConfigList={dynamicFormConfigList}
initialValues={currentAdapterConfig}
onSubmit={(values) => {
form.setValue('adapter_config', values, {
shouldDirty: !isInitializing.current,
});
}}
systemContext={{
webhook_url: webhookUrl,
extra_webhook_url: extraWebhookUrl,
bot_uuid: initBotId || '',
adapter_config: form.getValues('adapter_config') || {},
outbound_ips: systemInfo.outbound_ips,
}}
/>
</div>
)}
</CardContent>
</Card>
@@ -756,6 +1041,7 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
{/* Card 3: Event Routing */}
{currentAdapter && (
<Card
data-guide="bot-routing"
className={cn(
'min-w-0',
initBotId && 'lg:min-h-0 lg:overflow-hidden',
@@ -799,6 +1085,12 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
)}
</fieldset>
</form>
<GuidedTour
enabled={!initBotId && guideEnabled}
storageKey="langbot_bot_create_guide_v4"
steps={botGuideSteps}
testId="bot-create-guide"
/>
</Form>
);
});
@@ -0,0 +1,359 @@
import { createPortal } from 'react-dom';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronRight, ExternalLink, LockKeyhole } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
const VIEWPORT_GAP = 12;
const MIN_POPOVER_WIDTH = 220;
const MAX_POPOVER_WIDTH = 340;
type TargetRect = Pick<
DOMRect,
'top' | 'right' | 'bottom' | 'left' | 'width' | 'height'
>;
type PopoverPosition = {
left: number;
top: number;
width: number;
};
export interface GuidedTourStep {
id: string;
target: string;
title: string;
description: string;
complete?: boolean;
advanceOnComplete?: boolean;
requirement?: string;
action?: {
href: string;
label: string;
};
}
interface GuidedTourProps {
enabled?: boolean;
storageKey: string;
steps: GuidedTourStep[];
testId?: string;
}
function readProgress(storageKey: string): string | null {
if (typeof window === 'undefined') return null;
try {
return localStorage.getItem(storageKey);
} catch {
return null;
}
}
function storeProgress(storageKey: string, value: string) {
try {
localStorage.setItem(storageKey, value);
} catch {
// Keep the tour usable when browser storage is unavailable.
}
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), Math.max(min, max));
}
export default function GuidedTour({
enabled = true,
storageKey,
steps,
testId = 'guided-tour',
}: GuidedTourProps) {
const { t } = useTranslation();
const initialProgress = useMemo(() => readProgress(storageKey), [storageKey]);
const [finished, setFinished] = useState(initialProgress === 'completed');
const [activeStepId, setActiveStepId] = useState<string | null>(() =>
initialProgress === 'completed' ? null : initialProgress,
);
const [targetRect, setTargetRect] = useState<TargetRect | null>(null);
const [popoverPosition, setPopoverPosition] =
useState<PopoverPosition | null>(null);
const popoverRef = useRef<HTMLDivElement | null>(null);
const previousStorageKeyRef = useRef(storageKey);
const previousCompletionRef = useRef<{
stepId: string;
complete: boolean;
} | null>(null);
const activeIndex = steps.findIndex((step) => step.id === activeStepId);
const activeStep = activeIndex >= 0 ? steps[activeIndex] : undefined;
const isComplete = activeStep?.complete !== false;
const isPopoverPositioned = popoverPosition !== null;
useEffect(() => {
if (previousStorageKeyRef.current === storageKey) return;
previousStorageKeyRef.current = storageKey;
const progress = readProgress(storageKey);
setFinished(progress === 'completed');
setActiveStepId(progress === 'completed' ? null : progress);
setTargetRect(null);
setPopoverPosition(null);
}, [storageKey]);
useEffect(() => {
if (!enabled || steps.length === 0 || finished) {
setActiveStepId(null);
return;
}
const currentIndex = steps.findIndex((step) => step.id === activeStepId);
const firstIncompleteIndex = steps.findIndex(
(step) => step.complete === false,
);
const nextIndex =
currentIndex < 0
? 0
: firstIncompleteIndex >= 0 && firstIncompleteIndex < currentIndex
? firstIncompleteIndex
: currentIndex;
if (nextIndex !== currentIndex) {
const nextStep = steps[nextIndex];
setActiveStepId(nextStep.id);
storeProgress(storageKey, nextStep.id);
}
}, [activeStepId, enabled, finished, steps, storageKey]);
const measure = useCallback(() => {
if (!activeStep) return;
const target = document.querySelector<HTMLElement>(activeStep.target);
if (!target) {
setTargetRect(null);
setPopoverPosition(null);
return;
}
const rect = target.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const nextRect: TargetRect = {
top: rect.top,
right: rect.right,
bottom: rect.bottom,
left: rect.left,
width: rect.width,
height: rect.height,
};
setTargetRect(nextRect);
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const popoverHeight = popoverRef.current?.offsetHeight ?? 250;
const rightSpace = viewportWidth - rect.right - VIEWPORT_GAP * 2;
const leftSpace = rect.left - VIEWPORT_GAP * 2;
let width = Math.min(MAX_POPOVER_WIDTH, viewportWidth - VIEWPORT_GAP * 2);
let left = VIEWPORT_GAP;
let top = rect.bottom + VIEWPORT_GAP;
if (rightSpace >= MIN_POPOVER_WIDTH) {
width = Math.min(MAX_POPOVER_WIDTH, rightSpace);
left = rect.right + VIEWPORT_GAP;
top = clamp(
rect.top,
VIEWPORT_GAP,
viewportHeight - popoverHeight - VIEWPORT_GAP,
);
} else if (leftSpace >= MIN_POPOVER_WIDTH) {
width = Math.min(MAX_POPOVER_WIDTH, leftSpace);
left = rect.left - VIEWPORT_GAP - width;
top = clamp(
rect.top,
VIEWPORT_GAP,
viewportHeight - popoverHeight - VIEWPORT_GAP,
);
} else {
top =
rect.bottom + VIEWPORT_GAP + popoverHeight <= viewportHeight
? rect.bottom + VIEWPORT_GAP
: rect.top - popoverHeight - VIEWPORT_GAP;
top = clamp(
top,
VIEWPORT_GAP,
viewportHeight - popoverHeight - VIEWPORT_GAP,
);
}
setPopoverPosition({ left, top, width });
}, [activeStep]);
useEffect(() => {
if (!enabled || !activeStep) return;
const target = document.querySelector<HTMLElement>(activeStep.target);
target?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
const frame = window.requestAnimationFrame(measure);
const delayedMeasure = window.setTimeout(measure, 240);
const observer = new MutationObserver(measure);
observer.observe(document.body, { childList: true, subtree: true });
window.addEventListener('resize', measure);
window.addEventListener('scroll', measure, true);
return () => {
window.cancelAnimationFrame(frame);
window.clearTimeout(delayedMeasure);
observer.disconnect();
window.removeEventListener('resize', measure);
window.removeEventListener('scroll', measure, true);
};
}, [activeStep, enabled, measure]);
useEffect(() => {
if (isPopoverPositioned) measure();
}, [activeStep?.id, isPopoverPositioned, measure]);
const handleNext = useCallback(() => {
if (!activeStep || !isComplete) return;
const nextStep = steps[activeIndex + 1];
setTargetRect(null);
setPopoverPosition(null);
if (!nextStep) {
storeProgress(storageKey, 'completed');
setFinished(true);
setActiveStepId(null);
return;
}
storeProgress(storageKey, nextStep.id);
setActiveStepId(nextStep.id);
}, [activeIndex, activeStep, isComplete, steps, storageKey]);
useEffect(() => {
const handleNativeClick = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof Element)) return;
const button = target.closest<HTMLButtonElement>(
'[data-guided-tour-action="next"]',
);
if (
!button ||
button.dataset.guidedTourId !== testId ||
button.disabled
) {
return;
}
handleNext();
};
// Translation extensions can rewrite nodes inside the popover and detach
// React's delegated handler. Capture the command by its stable data marker.
document.addEventListener('click', handleNativeClick, true);
return () => document.removeEventListener('click', handleNativeClick, true);
}, [handleNext, testId]);
useEffect(() => {
if (!activeStep) return;
const previous = previousCompletionRef.current;
previousCompletionRef.current = {
stepId: activeStep.id,
complete: isComplete,
};
if (
!activeStep.advanceOnComplete ||
previous?.stepId !== activeStep.id ||
previous.complete ||
!isComplete
) {
return;
}
window.setTimeout(handleNext, 0);
}, [activeStep, handleNext, isComplete]);
if (
!enabled ||
!activeStep ||
!targetRect ||
!popoverPosition ||
typeof document === 'undefined'
) {
return null;
}
const isLastStep = activeIndex === steps.length - 1;
const titleId = `${testId}-${activeStep.id}-title`;
return createPortal(
<div
data-testid={testId}
data-active-step={activeStep.id}
data-step-complete={String(isComplete)}
translate="no"
className="notranslate pointer-events-none"
>
<div
aria-hidden="true"
className="fixed z-[61] rounded-md ring-2 ring-blue-500 ring-offset-2 ring-offset-background transition-[top,left,width,height] duration-200"
style={{
top: targetRect.top,
left: targetRect.left,
width: targetRect.width,
height: targetRect.height,
boxShadow: '0 0 0 9999px rgb(15 23 42 / 0.32)',
}}
/>
<div
ref={popoverRef}
role="dialog"
aria-labelledby={titleId}
className="pointer-events-auto fixed z-[62] rounded-lg border bg-popover p-4 text-popover-foreground shadow-xl"
style={popoverPosition}
>
<div className="mb-3 flex items-center justify-between gap-3">
<span className="text-xs font-medium text-blue-600 dark:text-blue-400">
{t('guidedTour.label')}
</span>
<span className="text-xs tabular-nums text-muted-foreground">
{t('guidedTour.progress', {
current: activeIndex + 1,
total: steps.length,
})}
</span>
</div>
<h2 id={titleId} className="text-base font-semibold">
{activeStep.title}
</h2>
<p className="mt-1.5 text-sm leading-6 text-muted-foreground">
{activeStep.description}
</p>
{activeStep.action && (
<a
href={activeStep.action.href}
target="_blank"
rel="noreferrer"
className="mt-3 inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
>
{activeStep.action.label}
<ExternalLink className="size-3.5" />
</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
type="button"
data-guided-tour-action="next"
data-guided-tour-id={testId}
className="mt-4 w-full"
disabled={!isComplete}
>
{isLastStep ? (
<Check className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
{isLastStep ? t('guidedTour.finish') : t('guidedTour.next')}
</Button>
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,46 @@
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]));
}
@@ -163,7 +163,7 @@ export default function KBDetailContent({ id }: { id: string }) {
{t('knowledge.createKnowledgeBase')}
</h1>
{canManage && (
<Button type="submit" form="kb-form">
<Button type="submit" form="kb-form" data-guide="knowledge-submit">
{t('common.submit')}
</Button>
)}
@@ -176,6 +176,7 @@ export default function KBDetailContent({ id }: { id: string }) {
initKbId={undefined}
onNewKbCreated={handleNewKbCreated}
onKbUpdated={handleKbUpdated}
guideEnabled={canManage}
/>
</fieldset>
</div>
@@ -37,6 +37,13 @@ import {
} from '@/app/home/components/dynamic-form/DynamicFormItemConfig';
import { UUID } from 'uuidjs';
import KnowledgeEngineSelect from './KnowledgeEngineSelect';
import GuidedTour, {
GuidedTourStep,
} from '@/app/home/components/guided-tour/GuidedTour';
import { areRequiredDynamicFieldsComplete } from '@/app/home/components/guided-tour/dynamic-form-progress';
const KNOWLEDGE_ENGINE_MARKETPLACE_URL =
'https://space.langbot.app/market?type=plugin&component=KnowledgeEngine';
const getFormSchema = (t: (key: string) => string) =>
z.object({
@@ -79,11 +86,13 @@ export default function KBForm({
onNewKbCreated,
onKbUpdated,
onDirtyChange,
guideEnabled = true,
}: {
initKbId?: string;
onNewKbCreated: (kbId: string) => void;
onKbUpdated: (kbId: string) => void;
onDirtyChange?: (dirty: boolean) => void;
guideEnabled?: boolean;
}) {
const { t } = useTranslation();
const [ragEngines, setRagEngines] = useState<KnowledgeEngine[]>([]);
@@ -328,6 +337,77 @@ export default function KBForm({
[selectedEngine?.retrieval_schema],
);
const guideSteps = useMemo<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',
target: '[data-guide="knowledge-engine"]',
title: t('guidedTour.knowledge.engine.title'),
description: t('guidedTour.knowledge.engine.description'),
complete: Boolean(selectedEngineId),
requirement: t('guidedTour.knowledge.engine.requirement'),
action: {
href: KNOWLEDGE_ENGINE_MARKETPLACE_URL,
label: t('guidedTour.knowledge.engine.action'),
},
},
];
if (configFormItems.length > 0) {
steps.push({
id: 'engine-parameters',
target: '[data-guide="knowledge-engine-parameters"]',
title: t('guidedTour.knowledge.parameters.title'),
description: t('guidedTour.knowledge.parameters.description'),
complete: areRequiredDynamicFieldsComplete(
configFormItems,
configSettings,
retrievalSettings,
),
requirement: t('guidedTour.knowledge.parameters.requirement'),
});
}
if (retrievalFormItems.length > 0) {
steps.push({
id: 'retrieval',
target: '[data-guide="knowledge-retrieval"]',
title: t('guidedTour.knowledge.retrieval.title'),
description: t('guidedTour.knowledge.retrieval.description'),
complete: areRequiredDynamicFieldsComplete(
retrievalFormItems,
retrievalSettings,
configSettings,
),
requirement: t('guidedTour.knowledge.retrieval.requirement'),
});
}
steps.push({
id: 'submit',
target: '[data-guide="knowledge-submit"]',
title: t('guidedTour.knowledge.submit.title'),
description: t('guidedTour.knowledge.submit.description'),
});
return steps;
}, [
configFormItems,
configSettings,
retrievalFormItems,
retrievalSettings,
selectedEngineId,
t,
watchedFormValues.name,
]);
if (loadFailed)
return (
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
@@ -343,7 +423,7 @@ export default function KBForm({
>
{/* Basic information is entered here only during creation. */}
{!isEditing && (
<Card>
<Card data-guide="knowledge-basic">
<CardHeader>
<CardTitle>{t('knowledge.basicInfo')}</CardTitle>
<CardDescription>
@@ -406,7 +486,7 @@ export default function KBForm({
)}
{/* Knowledge engine selection and settings stay together. */}
<Card>
<Card data-guide="knowledge-engine">
<CardHeader>
<CardTitle>{t('knowledge.engineSettings')}</CardTitle>
<CardDescription>
@@ -450,7 +530,10 @@ export default function KBForm({
/>
{configFormItems.length > 0 && (
<>
<div
data-guide="knowledge-engine-parameters"
className="space-y-6"
>
<Separator />
<DynamicFormComponent
itemConfigList={configFormItems}
@@ -464,14 +547,14 @@ export default function KBForm({
(configValidateRef.current = validateFn)
}
/>
</>
</div>
)}
</CardContent>
</Card>
{/* Retrieval Settings (dynamic form from retrieval_schema) */}
{retrievalFormItems.length > 0 && (
<Card>
<Card data-guide="knowledge-retrieval">
<CardHeader>
<CardTitle>{t('knowledge.retrievalSettings')}</CardTitle>
<CardDescription>
@@ -494,6 +577,12 @@ export default function KBForm({
</Card>
)}
</form>
<GuidedTour
enabled={!isEditing && guideEnabled}
storageKey="langbot_knowledge_create_guide_v1"
steps={guideSteps}
testId="knowledge-create-guide"
/>
</Form>
);
}
+125
View File
@@ -47,6 +47,120 @@ const enUS = {
},
},
},
guidedTour: {
label: 'Setup guide',
progress: '{{current}} of {{total}}',
next: 'Next',
finish: 'Finish',
bot: {
connection: {
title: 'Choose a connection method',
description:
'Choose one of the connection methods supported by this adapter. The corresponding adapter parameters update automatically.',
requirement: 'Select Webhook or persistent connection to continue.',
},
basic: {
title: 'Name this bot',
description:
'Use a recognizable name so this connection is easy to find in routing and logs.',
requirement: 'Enter a bot name to continue.',
},
adapter: {
title: 'Choose a platform adapter',
description:
'Choose the platform first. LangBot will then show whether this adapter supports Webhook, persistent connection, or both.',
requirement: 'Select an adapter to continue.',
},
parameters: {
title: 'Configure the platform',
description:
'Fill in the credentials and platform parameters. Use the adapter documentation for settings created on the platform side.',
requirement: 'Complete every visible required parameter to continue.',
action: 'Open adapter documentation',
},
routing: {
title: 'Route incoming events',
description:
'Choose which processor handles each event. You can add more routes after the bot is created.',
},
submit: {
title: 'Create the bot',
description:
'Create the bot to apply the connection. Webhook URLs generated by LangBot are shown in the saved bot configuration.',
},
},
processorCreate: {
type: {
title: 'Choose a processor type',
description:
'Pipelines follow a defined message flow, Agents decide with models and tools, and plugin processors handle declared platform events.',
},
basic: {
title: 'Name the processor',
description:
'Give the processor a recognizable name. Runner and event settings are configured after creation.',
requirement: 'Enter a processor name to continue.',
},
submit: {
title: 'Create and continue setup',
description:
'After creation, continue with the Runner, parameters, events, and tools required by this processor type.',
},
},
runner: {
select: {
title: 'Choose or install a Runner',
description:
'Select an installed Runner, or install one directly from the Marketplace list in this selector.',
requirement: 'Select an available Runner to continue.',
action: 'Browse Runner Marketplace',
},
parameters: {
title: 'Configure Runner parameters',
description:
'Provide the model, credentials, service URL, and other settings declared by the selected Runner.',
requirement: 'Complete every visible required parameter to continue.',
},
events: {
title: 'Set events and tools',
description:
'Open Events & tools to choose what this Agent can receive and which platform or host tools it may use.',
requirement: 'Open the Events & tools tab to finish the guide.',
},
},
knowledge: {
basic: {
title: 'Describe the knowledge base',
description:
'Set a recognizable name and optional description before choosing its storage and retrieval engine.',
requirement: 'Enter a knowledge base name to continue.',
},
engine: {
title: 'Choose or install an engine',
description:
'Select an installed knowledge engine, or install one from the Marketplace section in this selector.',
requirement: 'Select a knowledge engine to continue.',
action: 'Browse Knowledge Engine Marketplace',
},
parameters: {
title: 'Configure engine parameters',
description:
'Fill in the storage, model, credentials, or external-service settings requested by this engine.',
requirement: 'Complete every visible required parameter to continue.',
},
retrieval: {
title: 'Configure retrieval',
description:
'Set how this engine searches and returns relevant content to processors.',
requirement: 'Complete every visible required retrieval parameter.',
},
submit: {
title: 'Create the knowledge base',
description:
'Create the knowledge base, then add documents or connect sources supported by the selected engine.',
},
},
},
sidebar: {
home: 'Home',
extensions: 'Extensions',
@@ -441,6 +555,17 @@ const enUS = {
botNameRequired: 'Bot name cannot be empty',
botDescriptionRequired: 'Bot description cannot be empty',
adapterRequired: 'Adapter cannot be empty',
connectionMode: 'Connection method',
connectionModeDescription:
'Choose one of the connection methods supported by this adapter.',
connectionWebhook: 'Webhook',
connectionWebhookDescription:
'The platform sends events to a URL generated by LangBot.',
connectionPersistent: 'Persistent connection',
connectionPersistentDescription:
'LangBot keeps an outbound socket or streaming connection open.',
noAdaptersForConnectionMode:
'No installed adapters support this connection method.',
defaultDescription: 'A bot',
getBotConfigError: 'Failed to get bot configuration: ',
saveSuccess: 'Saved successfully',
+124
View File
@@ -47,6 +47,119 @@ const jaJP = {
},
},
},
guidedTour: {
label: 'セットアップガイド',
progress: '{{current}} / {{total}}',
next: '次へ',
finish: '完了',
bot: {
connection: {
title: '接続方式を選択',
description:
'このアダプターが対応する接続方式から選択すると、関連パラメーターが自動的に更新されます。',
requirement: 'Webhook または常時接続を選択してください。',
},
basic: {
title: 'ボット情報を入力',
description: 'ルーティングやログで見つけやすい名前を設定します。',
requirement: 'ボット名を入力してください。',
},
adapter: {
title: 'プラットフォームアダプターを選択',
description:
'先にプラットフォームを選択すると、Webhook、常時接続、または両方への対応状況が表示されます。',
requirement: 'アダプターを選択してください。',
},
parameters: {
title: 'プラットフォームを設定',
description:
'認証情報とプラットフォームのパラメーターを入力します。外部側の設定はドキュメントを参照してください。',
requirement: '表示されている必須項目をすべて入力してください。',
action: 'アダプターのドキュメントを開く',
},
routing: {
title: 'イベントルートを設定',
description:
'各イベントを処理するプロセッサーを選びます。作成後にもルートを追加できます。',
},
submit: {
title: 'ボットを作成',
description:
'作成すると接続設定が適用されます。Webhook URL は保存後のボット設定に表示されます。',
},
},
processorCreate: {
type: {
title: 'プロセッサーの種類を選択',
description:
'Pipeline は固定フロー、Agent はモデルとツールによる判断、プラグインプロセッサーは宣言済みイベントを処理します。',
},
basic: {
title: 'プロセッサー情報を入力',
description:
'識別しやすい名前を設定し、作成後に Runner、イベント、ツールを設定します。',
requirement: 'プロセッサー名を入力してください。',
},
submit: {
title: '作成して設定を続行',
description:
'作成後、必要な Runner、パラメーター、イベント、ツールを設定します。',
},
},
runner: {
select: {
title: 'Runner を選択またはインストール',
description:
'インストール済み Runner を選択するか、この選択欄のマーケット一覧から直接インストールします。',
requirement: '利用可能な Runner を選択してください。',
action: 'Runner マーケットを見る',
},
parameters: {
title: 'Runner のパラメーターを設定',
description:
'選択した Runner が要求するモデル、認証情報、サービス URL などを入力します。',
requirement: '表示されている必須項目をすべて入力してください。',
},
events: {
title: 'イベントとツールを設定',
description:
'「イベントとツール」を開き、Agent が受け取るイベントと使用可能なツールを選びます。',
requirement: '「イベントとツール」タブを開いてください。',
},
},
knowledge: {
basic: {
title: 'ナレッジベース情報を入力',
description:
'名前と任意の説明を設定してから、保存・検索エンジンを選択します。',
requirement: 'ナレッジベース名を入力してください。',
},
engine: {
title: 'エンジンを選択またはインストール',
description:
'インストール済みエンジンを選択するか、この選択欄のマーケットから直接インストールします。',
requirement: 'ナレッジエンジンを選択してください。',
action: 'ナレッジエンジンのマーケットを見る',
},
parameters: {
title: 'エンジンのパラメーターを設定',
description:
'エンジンが要求するストレージ、モデル、認証情報、外部サービス設定を入力します。',
requirement: '表示されている必須項目をすべて入力してください。',
},
retrieval: {
title: '検索方法を設定',
description:
'関連コンテンツの検索方法とプロセッサーへの返却方法を設定します。',
requirement: '表示されている必須検索項目を入力してください。',
},
submit: {
title: 'ナレッジベースを作成',
description:
'作成後、ドキュメントの追加や対応する外部ナレッジソースへの接続ができます。',
},
},
},
sidebar: {
home: 'ホーム',
extensions: '拡張機能',
@@ -445,6 +558,17 @@ const jaJP = {
botNameRequired: 'ボット名は必須です',
botDescriptionRequired: 'ボットの説明は必須です',
adapterRequired: 'アダプターは必須です',
connectionMode: '接続方式',
connectionModeDescription:
'このアダプターが対応する接続方式から選択します。',
connectionWebhook: 'Webhook',
connectionWebhookDescription:
'プラットフォームから LangBot が生成した URL へイベントを送信します。',
connectionPersistent: '常時接続',
connectionPersistentDescription:
'LangBot が Socket またはストリーミング接続を維持します。',
noAdaptersForConnectionMode:
'この接続方式に対応するアダプターがインストールされていません。',
defaultDescription: 'ボット',
getBotConfigError: 'ボット設定の取得に失敗しました:',
saveSuccess: '保存に成功しました',
+116
View File
@@ -43,6 +43,115 @@ const zhHans = {
},
},
},
guidedTour: {
label: '配置引导',
progress: '第 {{current}} 步,共 {{total}} 步',
next: '下一步',
finish: '完成引导',
bot: {
connection: {
title: '选择接入方式',
description:
'从该适配器支持的方式中选择一种,相关适配器参数会自动同步。',
requirement: '请选择 Webhook 或长连接后继续。',
},
basic: {
title: '填写机器人信息',
description: '使用容易识别的名称,便于之后在事件路由和日志中快速找到。',
requirement: '请先填写机器人名称。',
},
adapter: {
title: '选择平台适配器',
description:
'先选择平台,LangBot 会再判断该适配器支持 Webhook、长连接或两者都支持。',
requirement: '请选择一个适配器。',
},
parameters: {
title: '配置平台参数',
description:
'填写凭据和平台参数;需要在平台侧创建的配置可按适配器文档操作。',
requirement: '请填写当前可见的全部必填参数。',
action: '打开适配器文档',
},
routing: {
title: '设置事件路由',
description:
'选择各类事件交给哪个处理器;机器人创建后仍可继续添加路由。',
},
submit: {
title: '创建机器人',
description:
'创建后连接配置才会生效;LangBot 生成的 Webhook 地址会显示在已保存机器人的配置中。',
},
},
processorCreate: {
type: {
title: '选择处理器类型',
description:
'流水线按固定流程处理消息,Agent 使用模型和工具自主决策,插件处理器负责声明的平台事件。',
},
basic: {
title: '填写处理器信息',
description: '先设置容易识别的名称,创建后再配置 Runner、事件和工具。',
requirement: '请先填写处理器名称。',
},
submit: {
title: '创建并继续配置',
description:
'创建后继续完成该类型处理器所需的 Runner、参数、事件和工具设置。',
},
},
runner: {
select: {
title: '选择或安装 Runner',
description:
'选择已安装的 Runner,也可以直接在此选择器的市场列表中安装。',
requirement: '请选择一个可用的 Runner。',
action: '浏览 Runner 市场',
},
parameters: {
title: '配置 Runner 参数',
description:
'按照所选 Runner 的定义填写模型、凭据、服务地址和其他参数。',
requirement: '请填写当前可见的全部必填参数。',
},
events: {
title: '设置事件和工具',
description:
'打开“事件和工具”,选择 Agent 可接收的事件以及允许使用的平台或主机工具。',
requirement: '请打开“事件和工具”页签以完成引导。',
},
},
knowledge: {
basic: {
title: '填写知识库信息',
description:
'设置容易识别的名称和可选描述,然后选择知识的存储与检索引擎。',
requirement: '请先填写知识库名称。',
},
engine: {
title: '选择或安装知识引擎',
description:
'选择已安装的知识引擎,也可以在此选择器的市场区域直接安装。',
requirement: '请选择一个知识引擎。',
action: '浏览知识引擎市场',
},
parameters: {
title: '配置引擎参数',
description: '填写该引擎要求的存储、模型、凭据或外部服务设置。',
requirement: '请填写当前可见的全部必填参数。',
},
retrieval: {
title: '配置检索方式',
description: '设置引擎如何搜索内容,以及如何把相关结果返回给处理器。',
requirement: '请填写当前可见的全部必填检索参数。',
},
submit: {
title: '创建知识库',
description: '创建后即可添加文档,或连接所选引擎支持的外部知识源。',
},
},
},
sidebar: {
home: '首页',
extensions: '扩展',
@@ -417,6 +526,13 @@ const zhHans = {
botNameRequired: '机器人名称不能为空',
botDescriptionRequired: '机器人描述不能为空',
adapterRequired: '适配器不能为空',
connectionMode: '接入方式',
connectionModeDescription: '从当前适配器支持的接入方式中选择一种。',
connectionWebhook: 'Webhook',
connectionWebhookDescription: '平台向 LangBot 生成的地址推送事件。',
connectionPersistent: '长连接',
connectionPersistentDescription: 'LangBot 主动保持 Socket 或流式连接。',
noAdaptersForConnectionMode: '尚未安装支持这种接入方式的适配器。',
defaultDescription: '一个机器人',
getBotConfigError: '获取机器人配置失败:',
saveSuccess: '保存成功',
+110
View File
@@ -43,6 +43,109 @@ const zhHant = {
},
},
},
guidedTour: {
label: '設定引導',
progress: '第 {{current}} 步,共 {{total}} 步',
next: '下一步',
finish: '完成引導',
bot: {
connection: {
title: '選擇接入方式',
description:
'從此適配器支援的方式中選擇一種,相關適配器參數會自動同步。',
requirement: '請選擇 Webhook 或長連線後繼續。',
},
basic: {
title: '填寫機器人資訊',
description: '使用容易辨識的名稱,方便之後在事件路由和日誌中找到。',
requirement: '請先填寫機器人名稱。',
},
adapter: {
title: '選擇平台適配器',
description:
'先選擇平台,LangBot 會再判斷此適配器支援 Webhook、長連線或兩者皆支援。',
requirement: '請選擇一個適配器。',
},
parameters: {
title: '設定平台參數',
description: '填寫憑證和平台參數;平台端的設定請依適配器文件操作。',
requirement: '請填寫目前可見的所有必填參數。',
action: '開啟適配器文件',
},
routing: {
title: '設定事件路由',
description: '選擇各類事件交給哪個處理器;建立後仍可繼續新增路由。',
},
submit: {
title: '建立機器人',
description:
'建立後連線設定才會生效;Webhook 位址會顯示在已儲存機器人的設定中。',
},
},
processorCreate: {
type: {
title: '選擇處理器類型',
description:
'流水線按固定流程處理訊息,Agent 使用模型和工具決策,外掛處理器負責宣告的平台事件。',
},
basic: {
title: '填寫處理器資訊',
description: '先設定容易辨識的名稱,建立後再設定 Runner、事件和工具。',
requirement: '請先填寫處理器名稱。',
},
submit: {
title: '建立並繼續設定',
description: '建立後繼續完成此處理器所需的 Runner、參數、事件和工具。',
},
},
runner: {
select: {
title: '選擇或安裝 Runner',
description:
'選擇已安裝的 Runner,也可以直接從選擇器中的市集清單安裝。',
requirement: '請選擇一個可用的 Runner。',
action: '瀏覽 Runner 市集',
},
parameters: {
title: '設定 Runner 參數',
description: '依所選 Runner 的定義填寫模型、憑證、服務位址和其他參數。',
requirement: '請填寫目前可見的所有必填參數。',
},
events: {
title: '設定事件和工具',
description:
'開啟「事件和工具」,選擇 Agent 可接收的事件與可使用的工具。',
requirement: '請開啟「事件和工具」頁籤以完成引導。',
},
},
knowledge: {
basic: {
title: '填寫知識庫資訊',
description: '設定容易辨識的名稱和選填說明,再選擇儲存與檢索引擎。',
requirement: '請先填寫知識庫名稱。',
},
engine: {
title: '選擇或安裝知識引擎',
description: '選擇已安裝的知識引擎,也可以從選擇器的市集區域直接安裝。',
requirement: '請選擇一個知識引擎。',
action: '瀏覽知識引擎市集',
},
parameters: {
title: '設定引擎參數',
description: '填寫此引擎要求的儲存、模型、憑證或外部服務設定。',
requirement: '請填寫目前可見的所有必填參數。',
},
retrieval: {
title: '設定檢索方式',
description: '設定引擎如何搜尋內容,以及如何將相關結果傳回處理器。',
requirement: '請填寫目前可見的所有必填檢索參數。',
},
submit: {
title: '建立知識庫',
description: '建立後即可加入文件,或連接所選引擎支援的外部知識來源。',
},
},
},
sidebar: {
home: '首頁',
extensions: '擴展',
@@ -433,6 +536,13 @@ const zhHant = {
botNameRequired: '機器人名稱不能為空',
botDescriptionRequired: '機器人描述不能為空',
adapterRequired: '適配器不能為空',
connectionMode: '接入方式',
connectionModeDescription: '從目前適配器支援的接入方式中選擇一種。',
connectionWebhook: 'Webhook',
connectionWebhookDescription: '平台向 LangBot 產生的位址推送事件。',
connectionPersistent: '長連線',
connectionPersistentDescription: 'LangBot 主動維持 Socket 或串流連線。',
noAdaptersForConnectionMode: '尚未安裝支援此接入方式的適配器。',
defaultDescription: '一個機器人',
getBotConfigError: '取得機器人設定失敗:',
saveSuccess: '儲存成功',
+248
View File
@@ -0,0 +1,248 @@
import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
test('knowledge setup guide advances only after required state is complete', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
storage: { langbot_knowledge_create_guide_v1: 'basic' },
});
await page.goto('/home/knowledge?id=new');
const guide = page.getByTestId('knowledge-create-guide');
await expect(
guide.getByRole('heading', { name: 'Describe the knowledge base' }),
).toBeVisible();
await expect(guide.getByRole('button', { name: 'Next' })).toBeDisabled();
await page.locator('input[name="name"]').fill('Guided Knowledge');
await expect(guide.getByRole('button', { name: 'Next' })).toBeEnabled();
await guide.getByRole('button', { name: 'Next' }).click();
await expect(
guide.getByRole('heading', { name: 'Choose or install an engine' }),
).toBeVisible();
await expect(
guide.getByRole('link', { name: /Marketplace/ }),
).toHaveAttribute('target', '_blank');
await guide.getByRole('button', { name: 'Next' }).click();
await expect(
guide.getByRole('heading', { name: 'Create the knowledge base' }),
).toBeVisible();
await guide.getByRole('button', { name: 'Finish' }).click();
await expect(guide).toHaveCount(0);
await expect
.poll(() =>
page.evaluate(() =>
localStorage.getItem('langbot_knowledge_create_guide_v1'),
),
)
.toBe('completed');
});
test('bot setup guide chooses the adapter before its connection method', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
storage: { langbot_bot_create_guide_v4: 'basic' },
});
await page.route('**/api/v1/platform/adapters', (route) =>
route.fulfill({
json: {
code: 0,
data: {
adapters: [
{
name: 'dual-mode-adapter',
label: { en_US: 'Dual Mode Adapter' },
description: { en_US: 'Supports Webhook and socket modes.' },
spec: {
categories: ['testing'],
config: [
{
id: 'enable-webhook',
name: 'enable-webhook',
label: { en_US: 'Enable Webhook Mode' },
type: 'boolean',
required: true,
default: false,
},
{
id: 'webhook-url',
name: 'webhook_url',
label: { en_US: 'Webhook URL' },
type: 'webhook-url',
required: false,
default: '',
show_if: {
field: 'enable-webhook',
operator: 'eq',
value: true,
},
},
],
},
},
],
},
},
}),
);
await page.goto('/home/bots?id=new');
const guide = page.getByTestId('bot-create-guide');
await expect(
guide.getByRole('heading', { name: 'Name this bot' }),
).toBeVisible();
await expect(guide.getByRole('button', { name: 'Next' })).toBeDisabled();
await page.locator('input[name="name"]').fill('Guided Bot');
await guide.getByRole('button', { name: 'Next' }).click();
await expect(
guide.getByRole('heading', { name: 'Choose a platform adapter' }),
).toBeVisible();
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Dual Mode Adapter' }).click();
await expect(
guide.getByRole('heading', { name: 'Choose a connection method' }),
).toBeVisible();
await expect(page.getByRole('radio', { name: /^Webhook/ })).not.toBeChecked();
await page.getByRole('radio', { name: /^Webhook/ }).click();
await expect(
guide.getByRole('heading', { name: 'Configure the platform' }),
).toBeVisible();
await expect(page.getByRole('switch')).toBeChecked();
await page.getByRole('radio', { name: /^Persistent connection/ }).click();
await expect(page.getByRole('switch')).not.toBeChecked();
await page.getByRole('radio', { name: /^Webhook/ }).click();
await guide.getByRole('button', { name: 'Next' }).click();
await expect(
guide.getByRole('heading', { name: 'Route incoming events' }),
).toBeVisible();
await guide.getByRole('button', { name: 'Next' }).click();
await expect(
guide.getByRole('heading', { name: 'Create the bot' }),
).toBeVisible();
await guide.getByRole('button', { name: 'Finish' }).click();
await expect(guide).toHaveCount(0);
await expect
.poll(() =>
page.evaluate(() => localStorage.getItem('langbot_bot_create_guide_v4')),
)
.toBe('completed');
await page.waitForTimeout(500);
await expect(guide).toHaveCount(0);
await expect(page.locator('input[name="name"]')).toHaveValue('Guided Bot');
await page.reload();
await expect(guide).toHaveCount(0);
});
test('bot setup guide restores missing basic info before a saved adapter step', async ({
page,
}) => {
await page.setViewportSize({ width: 1645, height: 478 });
await installLangBotApiMocks(page, {
authenticated: true,
language: 'zh-Hans',
storage: { langbot_bot_create_guide_v4: 'adapter' },
});
await page.goto('/home/bots?id=new');
const guide = page.getByTestId('bot-create-guide');
await expect(guide).toHaveAttribute('data-active-step', 'basic');
await expect(guide).toHaveAttribute('data-step-complete', 'false');
await page.locator('input[name="name"]').fill('test');
await expect(guide).toHaveAttribute('data-step-complete', 'true');
const nextButton = guide.getByRole('button', { name: '下一步' });
await nextButton.evaluate((button) => {
button.replaceWith(button.cloneNode(true));
});
const buttonBox = await nextButton.boundingBox();
expect(buttonBox).not.toBeNull();
await page.mouse.click(
buttonBox!.x + buttonBox!.width / 2,
buttonBox!.y + buttonBox!.height / 2,
);
await expect(guide).toHaveAttribute('data-active-step', 'adapter');
await expect
.poll(() =>
page.evaluate(() => localStorage.getItem('langbot_bot_create_guide_v4')),
)
.toBe('adapter');
});
test('runner guide covers selection, parameters, and event tools', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
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('heading', { name: 'Choose or install a Runner' }),
).toBeVisible();
await expect(
guide.getByRole('link', { name: /Runner Marketplace/ }),
).toHaveAttribute('target', '_blank');
await guide.getByRole('button', { name: 'Next' }).click();
await expect(
guide.getByRole('heading', { name: 'Configure Runner parameters' }),
).toBeVisible();
await guide.getByRole('button', { name: 'Next' }).click();
await expect(
guide.getByRole('heading', { name: 'Set events and tools' }),
).toBeVisible();
await expect(guide.getByRole('button', { name: 'Finish' })).toBeDisabled();
await page.getByRole('tab', { name: 'Events & tools' }).click();
await expect(guide.getByRole('button', { name: 'Finish' })).toBeEnabled();
await guide.getByRole('button', { name: 'Finish' }).click();
await expect(guide).toHaveCount(0);
});
test('processor creation guide explains the type before required details', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
storage: { langbot_processor_create_guide_v1: 'type' },
});
await page.goto('/home/agents?id=new');
const guide = page.getByTestId('processor-create-guide');
await expect(
guide.getByRole('heading', { name: 'Choose a processor type' }),
).toBeVisible();
await guide.getByRole('button', { name: 'Next' }).click();
await expect(
guide.getByRole('heading', { name: 'Name the processor' }),
).toBeVisible();
await expect(guide.getByRole('button', { name: 'Next' })).toBeDisabled();
await page.locator('input[name="name"]').fill('Guided Processor');
await expect(guide.getByRole('button', { name: 'Next' })).toBeEnabled();
await guide.getByRole('button', { name: 'Next' }).click();
await expect(
guide.getByRole('heading', { name: 'Create and continue setup' }),
).toBeVisible();
});
+11
View File
@@ -1298,6 +1298,17 @@ export async function installLangBotApiMocks(
if (!Object.hasOwn(storage, 'langbot_sidebar_guide_v1')) {
localStorage.setItem('langbot_sidebar_guide_v1', 'completed');
}
const contextualGuides = [
'langbot_bot_create_guide_v4',
'langbot_processor_create_guide_v1',
'langbot_runner_setup_guide_v1',
'langbot_knowledge_create_guide_v1',
];
for (const guideKey of contextualGuides) {
if (!Object.hasOwn(storage, guideKey)) {
localStorage.setItem(guideKey, 'completed');
}
}
if (authenticated) {
localStorage.setItem('token', 'playwright-token');