feat(wizard): rework agent onboarding flow (#2471)

* feat(wizard): rework agent onboarding flow

* fix(web): support LAN development access

* fix(wizard): parse ranked model selection entries

* feat(wizard): add inbound bot verification

* feat(wizard): add floating page bot verification

* fix(wizard): repair HTTP bot inbound test setup

* feat(wizard): streamline custom model onboarding

* feat(wizard): label page bot test preview

* style(space): apply ruff formatting

* fix(wizard): polish AI engine onboarding

* fix(wizard): clarify local account message test

* feat(wizard): animate AI engine transitions

* fix(wizard): align AI engine setup headers

---------

Co-authored-by: langbot-dev <langbot@users.noreply.github.com>
Co-authored-by: RockChinQ <rockchinq@gmail.com>
This commit is contained in:
Dongchuan Fu
2026-08-28 01:30:30 +08:00
committed by GitHub
parent 855ae2bdba
commit be3734ffda
33 changed files with 2319 additions and 271 deletions
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import ts from 'typescript';
import { fileURLToPath } from 'node:url';
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
const sourcePath = path.resolve(
currentDirectory,
'../../src/app/infra/entities/adapter-categories.ts',
);
function loadCategoryHelpers(language = 'zh-Hans') {
const source = fs.readFileSync(sourcePath, 'utf8');
const compiled = ts.transpileModule(source, {
compilerOptions: {
esModuleInterop: true,
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
},
}).outputText;
const loadedModule = { exports: {} };
new Function('require', 'module', 'exports', compiled)(
(name) => {
if (name === 'i18next') return { language };
throw new Error(`Unexpected runtime import: ${name}`);
},
loadedModule,
loadedModule.exports,
);
return loadedModule.exports;
}
test('places an adapter only once when metadata repeats a category', () => {
const { groupByCategory } = loadCategoryHelpers();
const adapter = { name: 'http_bot', categories: ['popular', 'popular'] };
assert.deepEqual(groupByCategory([adapter]), [
{ categoryId: 'popular', items: [adapter] },
]);
});
@@ -24,11 +24,11 @@ function loadNormalizer() {
loadedModule,
loadedModule.exports,
);
return loadedModule.exports.normalizeDynamicFormValuesForSave;
return loadedModule.exports;
}
test('normalizes only single-line text fields in a dynamic form save snapshot', () => {
const normalizeDynamicFormValuesForSave = loadNormalizer();
const { normalizeDynamicFormValuesForSave } = loadNormalizer();
const specs = [
{ name: 'single-line', type: 'string', default: '' },
{ name: 'multiline', type: 'text', default: '' },
@@ -70,3 +70,29 @@ test('normalizes only single-line text fields in a dynamic form save snapshot',
},
});
});
test('normalizes missing dynamic form defaults into controlled values', () => {
const { normalizeDynamicFormFieldValue } = loadNormalizer();
assert.equal(
normalizeDynamicFormFieldValue(
{ name: 'api-key', type: 'string', default: undefined },
undefined,
),
'',
);
assert.equal(
normalizeDynamicFormFieldValue(
{ name: 'enabled', type: 'boolean', default: undefined },
undefined,
),
false,
);
assert.deepEqual(
normalizeDynamicFormFieldValue(
{ name: 'items', type: 'array[string]', default: undefined },
undefined,
),
[],
);
});
+145
View File
@@ -0,0 +1,145 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import ts from 'typescript';
import { fileURLToPath } from 'node:url';
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
const sourcePath = path.resolve(
currentDirectory,
'../../src/app/wizard/utils.ts',
);
function loadWizardUtils() {
const source = fs.readFileSync(sourcePath, 'utf8');
const compiled = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS },
}).outputText;
const loadedModule = { exports: {} };
new Function('require', 'module', 'exports', compiled)(
() => {
throw new Error('Wizard utils must not have runtime imports');
},
loadedModule,
loadedModule.exports,
);
return loadedModule.exports;
}
const {
configureLocalAgentPrimaryModel,
ensureHttpBotSigningSecret,
findDefaultPipeline,
getErrorMessage,
isRequiredRunnerConfigComplete,
isWebhookModeEnabled,
} = loadWizardUtils();
test('generates an HTTP Bot signing secret when signatures are enabled', () => {
const config = ensureHttpBotSigningSecret('http_bot', {
signature_required: true,
inbound_secret: '',
});
assert.match(config.inbound_secret, /^[a-f0-9]{64}$/);
});
test('preserves existing or intentionally disabled HTTP Bot signing config', () => {
const existing = { signature_required: true, inbound_secret: 'keep-me' };
const disabled = { signature_required: false, inbound_secret: '' };
assert.equal(ensureHttpBotSigningSecret('http_bot', existing), existing);
assert.equal(ensureHttpBotSigningSecret('http_bot', disabled), disabled);
});
test('does not add signing config to other adapters', () => {
const config = {};
assert.equal(ensureHttpBotSigningSecret('web_page_bot', config), config);
});
test('extracts the backend message from structured API errors', () => {
assert.equal(
getErrorMessage({ code: 400, msg: 'Signing secret is required' }),
'Signing secret is required',
);
assert.equal(getErrorMessage(new Error('Network failed')), 'Network failed');
});
test('selects only a usable Workspace default pipeline', () => {
const pipelines = [
{ uuid: 'recent-pipeline', is_default: false },
{ uuid: '', is_default: true },
{ uuid: 'default-pipeline', is_default: true },
];
assert.equal(findDefaultPipeline(pipelines)?.uuid, 'default-pipeline');
});
test('configures the selected model as the Local Agent primary model', () => {
const config = {
trigger: { prefix: '!' },
ai: {
runner: { runner: 'plugin:external', timeout: 30 },
'local-agent': {
model: { primary: 'old-model', fallbacks: ['fallback-model'] },
tools: { enabled: true },
},
},
};
const updated = configureLocalAgentPrimaryModel(config, 'selected-model');
assert.equal(updated.ai.runner.runner, 'local-agent');
assert.equal(updated.ai.runner.timeout, 30);
assert.equal(updated.ai['local-agent'].model.primary, 'selected-model');
assert.deepEqual(updated.ai['local-agent'].model.fallbacks, [
'fallback-model',
]);
assert.deepEqual(updated.ai['local-agent'].tools, { enabled: true });
assert.deepEqual(updated.trigger, { prefix: '!' });
});
test('shows webhook guidance only when the adapter webhook mode is active', () => {
const dualModeFields = [
{
name: 'webhook_url',
show_if: { field: 'enable-webhook', operator: 'eq', value: true },
},
];
assert.equal(
isWebhookModeEnabled(dualModeFields, { 'enable-webhook': false }),
false,
);
assert.equal(
isWebhookModeEnabled(dualModeFields, { 'enable-webhook': true }),
true,
);
assert.equal(isWebhookModeEnabled([{ name: 'webhook_url' }], {}), true);
assert.equal(isWebhookModeEnabled([], {}), false);
});
test('requires real values for required external runner configuration', () => {
const fields = [
{ name: 'base-url', required: true, default: 'https://api.dify.ai/v1' },
{ name: 'api-key', required: true, default: 'your-api-key' },
{ name: 'optional', required: false, default: '' },
];
assert.equal(
isRequiredRunnerConfigComplete(fields, {
'base-url': 'https://api.dify.ai/v1',
'api-key': 'your-api-key',
}),
false,
);
assert.equal(
isRequiredRunnerConfigComplete(fields, {
'base-url': 'https://api.dify.ai/v1',
'api-key': 'app-real-key',
}),
true,
);
});
+128
View File
@@ -0,0 +1,128 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
const wizardSource = fs.readFileSync(
path.resolve(currentDirectory, '../../src/app/wizard/page.tsx'),
'utf8',
);
const ownModelSetupSource = fs.readFileSync(
path.resolve(
currentDirectory,
'../../src/app/wizard/components/OwnModelSetup.tsx',
),
'utf8',
);
const widgetSource = fs.readFileSync(
path.resolve(
currentDirectory,
'../../../src/langbot/templates/embed/widget.js',
),
'utf8',
);
test('shows the test-only notice only when the wizard opts in', () => {
assert.match(
wizardSource,
/widget\.js\?preview=wizard&v=\$\{Date\.now\(\)\}/,
);
assert.match(wizardSource, /script\.dataset\.testNotice = testNotice/);
assert.match(
wizardSource,
/testNotice=\{t\('wizard\.botConfig\.pageBotTestNotice'\)\}/,
);
assert.match(widgetSource, /getAttribute\("data-test-notice"\)/);
assert.match(widgetSource, /if \(scriptTestNotice\)/);
assert.match(widgetSource, /testNotice\.textContent = scriptTestNotice/);
});
test('defaults the AI engine step to the workbench option and lists it first', () => {
assert.match(
wizardSource,
/const \[aiChoice, setAiChoice\] = useState<[\s\S]*?>\('more-features'\);/,
);
const choicesStart = wizardSource.indexOf('const choices = [');
const moreFeaturesChoice = wizardSource.indexOf(
"id: 'more-features' as const",
choicesStart,
);
const externalChoice = wizardSource.indexOf(
"id: 'external' as const",
choicesStart,
);
const ownModelChoice = wizardSource.indexOf(
"id: 'own-model' as const",
choicesStart,
);
assert.ok(choicesStart >= 0);
assert.ok(moreFeaturesChoice > choicesStart);
assert.ok(moreFeaturesChoice < externalChoice);
assert.ok(moreFeaturesChoice < ownModelChoice);
});
test('uses the external-runner layout only while that configuration is open', () => {
assert.match(
wizardSource,
/currentStep === 2 && aiChoice === 'external' && selectedRunner/,
);
});
test('restores the default workbench choice when leaving a nested AI setup', () => {
assert.equal(
wizardSource.match(/onChoiceChange\('more-features'\)/g)?.length,
3,
);
});
test('warns local-account users after the bot receives an IM message', () => {
assert.match(
wizardSource,
/messageReceived && userInfo\?\.account_type !== 'space'/,
);
assert.match(
wizardSource,
/wizard\.botConfig\.messageReceivedLocalAccountWarning/,
);
assert.match(wizardSource, /<AlertTriangle className="size-3 text-white"/);
});
test('animates AI engine sub-pages and the return to choices', () => {
assert.match(
wizardSource,
/key="ai-engine-own-model"[\s\S]*?slide-in-from-right-4/,
);
assert.match(
wizardSource,
/key="ai-engine-external-picker"[\s\S]*?slide-in-from-right-4/,
);
assert.match(
wizardSource,
/key="ai-engine-choices"[\s\S]*?slide-in-from-left-4/,
);
assert.match(wizardSource, /motion-reduce:animate-none/);
});
test('aligns the own-model title and back button with external Agent setup', () => {
const ownModelTitle = ownModelSetupSource.indexOf(
"t('wizard.aiEngine.ownModelSetupTitle')",
);
const ownModelBack = ownModelSetupSource.indexOf(
"t('wizard.aiEngine.backToChoices')",
);
assert.ok(ownModelTitle >= 0);
assert.ok(ownModelBack > ownModelTitle);
assert.match(ownModelSetupSource, /mx-auto w-full max-w-4xl space-y-6/);
});
test('labels both external Agent setup states with their specific title', () => {
assert.equal(
wizardSource.match(/t\('wizard\.aiEngine\.externalTitle'\)/g)?.length,
3,
);
});