mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-28 12:26:46 +08:00
feat(web): add contextual setup guides
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '保存に成功しました',
|
||||
|
||||
@@ -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: '保存成功',
|
||||
|
||||
@@ -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: '儲存成功',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user