diff --git a/skills/skills/langbot-mcp-ops/SKILL.md b/skills/skills/langbot-mcp-ops/SKILL.md
index fb1152fa4..7480f2b1a 100644
--- a/skills/skills/langbot-mcp-ops/SKILL.md
+++ b/skills/skills/langbot-mcp-ops/SKILL.md
@@ -75,6 +75,8 @@ shape as the corresponding HTTP API request body. Discover resources with the
`list_*` / `get_*` tools before mutating; identifiers are UUIDs. Reads require
`resource.view`; mutations require `resource.manage`. All service calls inherit
the immutable Workspace context authenticated at the MCP transport boundary.
+Pass `is_default: true` to `create_pipeline` only when the Workspace does not
+already have a default pipeline.
## How to use
diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py b/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py
index 69189d2ee..136eac39e 100644
--- a/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py
+++ b/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py
@@ -39,7 +39,13 @@ class PipelinesRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_MANAGE,
)
async def _(request_context: RequestContext) -> str:
- pipeline_uuid = await self.ap.pipeline_service.create_pipeline(request_context, await quart.request.json)
+ pipeline_data = await quart.request.json
+ create_as_default = pipeline_data.get('is_default') is True
+ pipeline_uuid = await self.ap.pipeline_service.create_pipeline(
+ request_context,
+ pipeline_data,
+ default=create_as_default,
+ )
return self.success(data={'uuid': pipeline_uuid})
@self.route(
diff --git a/src/langbot/pkg/api/mcp/server.py b/src/langbot/pkg/api/mcp/server.py
index 4cf4e33ad..9091178e1 100644
--- a/src/langbot/pkg/api/mcp/server.py
+++ b/src/langbot/pkg/api/mcp/server.py
@@ -147,7 +147,16 @@ class LangBotMCPServer:
)
async def create_pipeline(pipeline_data: dict) -> str:
context = _authorized(Permission.RESOURCE_MANAGE)
- return _dump({'uuid': await ap.pipeline_service.create_pipeline(context, pipeline_data)})
+ create_as_default = pipeline_data.get('is_default') is True
+ return _dump(
+ {
+ 'uuid': await ap.pipeline_service.create_pipeline(
+ context,
+ pipeline_data,
+ default=create_as_default,
+ )
+ }
+ )
@mcp.tool(description='Update a pipeline by UUID. `pipeline_data` matches the PUT body.')
async def update_pipeline(pipeline_uuid: str, pipeline_data: dict) -> str:
diff --git a/tests/integration/api/test_pipelines.py b/tests/integration/api/test_pipelines.py
index 80fce9747..9f84aafb9 100644
--- a/tests/integration/api/test_pipelines.py
+++ b/tests/integration/api/test_pipelines.py
@@ -254,6 +254,22 @@ class TestPipelinesCRUDEndpoints:
assert data['code'] == 0
assert 'uuid' in data['data']
+ @pytest.mark.asyncio
+ async def test_create_default_pipeline_forwards_default_flag(self, quart_test_client, fake_pipeline_app):
+ """POST /api/v1/pipelines explicitly creates a default pipeline."""
+ fake_pipeline_app.pipeline_service.create_pipeline.reset_mock()
+
+ response = await quart_test_client.post(
+ '/api/v1/pipelines',
+ headers={'Authorization': 'Bearer test_token'},
+ json={'name': 'Default Pipeline', 'config': {}, 'is_default': True},
+ )
+
+ assert response.status_code == 200
+ call = fake_pipeline_app.pipeline_service.create_pipeline.await_args
+ assert call.kwargs == {'default': True}
+ assert call.args[1]['is_default'] is True
+
@pytest.mark.asyncio
async def test_update_pipeline_success(self, quart_test_client):
"""PUT /api/v1/pipelines/{uuid} updates pipeline."""
diff --git a/web/src/app/home/components/models-dialog/component/provider-form/ProviderForm.tsx b/web/src/app/home/components/models-dialog/component/provider-form/ProviderForm.tsx
index ae20bb254..96816db83 100644
--- a/web/src/app/home/components/models-dialog/component/provider-form/ProviderForm.tsx
+++ b/web/src/app/home/components/models-dialog/component/provider-form/ProviderForm.tsx
@@ -33,7 +33,7 @@ const getFormSchema = (t: (key: string) => string) =>
interface ProviderFormProps {
providerId?: string;
- onFormSubmit: () => void;
+ onFormSubmit: (providerUuid: string) => void | Promise;
onFormCancel: () => void;
}
@@ -171,14 +171,16 @@ export default function ProviderForm({
};
try {
+ let savedProviderUuid = providerId;
if (providerId) {
await httpClient.updateModelProvider(providerId, data);
toast.success(t('models.providerSaved'));
} else {
- await httpClient.createModelProvider(data);
+ const response = await httpClient.createModelProvider(data);
+ savedProviderUuid = response.uuid;
toast.success(t('models.providerCreated'));
}
- onFormSubmit();
+ await onFormSubmit(savedProviderUuid as string);
} catch (err) {
toast.error(t('models.providerSaveError') + (err as CustomApiError).msg);
}
diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts
index 3c5db7ae4..8dec1248b 100644
--- a/web/src/app/infra/http/BackendClient.ts
+++ b/web/src/app/infra/http/BackendClient.ts
@@ -150,7 +150,9 @@ export class BackendClient extends BaseHttpClient {
return this.get(`/api/v1/provider/models/llm/${uuid}`);
}
- public createProviderLLMModel(model: LLMModel): Promise
- {!messageReceived && webhookUrl && (
+ {!messageReceived && webhookModeEnabled && (
@@ -1324,6 +1454,7 @@ function StepAIEngine({
runnerConfigItems,
runnerConfigValues,
onRunnerConfigChange,
+ onOwnModelSelectionChange,
}: {
runnerOptions: { name: string; label: { en_US: string; zh_Hans: string } }[];
choice: 'external' | 'own-model' | 'more-features' | null;
@@ -1337,6 +1468,7 @@ function StepAIEngine({
runnerConfigItems: IDynamicFormItemSchema[];
runnerConfigValues: Record;
onRunnerConfigChange: (v: Record) => void;
+ onOwnModelSelectionChange: (selection: OwnModelSelection | null) => void;
}) {
const { t } = useTranslation();
@@ -1374,6 +1506,15 @@ function StepAIEngine({
},
];
+ if (choice === 'own-model') {
+ return (
+ onChoiceChange(null)}
+ onSelectionChange={onOwnModelSelectionChange}
+ />
+ );
+ }
+
if (choice !== 'external') {
return (
diff --git a/web/src/app/wizard/utils.ts b/web/src/app/wizard/utils.ts
index 1841d50b6..ee41d6f9f 100644
--- a/web/src/app/wizard/utils.ts
+++ b/web/src/app/wizard/utils.ts
@@ -34,3 +34,71 @@ export function ensureHttpBotSigningSecret(
inbound_secret: createSigningSecret(),
};
}
+
+export function findDefaultPipeline<
+ T extends { uuid?: string; is_default?: boolean },
+>(pipelines: T[]): T | undefined {
+ return pipelines.find(
+ (pipeline) =>
+ pipeline.is_default === true &&
+ typeof pipeline.uuid === 'string' &&
+ pipeline.uuid.length > 0,
+ );
+}
+
+interface WebhookConfigItem {
+ name: string;
+ show_if?: {
+ field: string;
+ operator: 'eq' | 'neq' | 'in';
+ value: unknown;
+ };
+}
+
+export function isWebhookModeEnabled(
+ configItems: WebhookConfigItem[],
+ configValues: Record,
+): boolean {
+ const webhookField = configItems.find((item) => item.name === 'webhook_url');
+ if (!webhookField) return false;
+ if (!webhookField.show_if) return true;
+
+ const condition = webhookField.show_if;
+ const actualValue = configValues[condition.field];
+ if (condition.operator === 'eq') return actualValue === condition.value;
+ if (condition.operator === 'neq') return actualValue !== condition.value;
+ return (
+ Array.isArray(condition.value) && condition.value.includes(actualValue)
+ );
+}
+
+export function configureLocalAgentPrimaryModel(
+ config: Record,
+ modelUuid: string,
+): Record {
+ const aiConfig = (config.ai ?? {}) as Record;
+ const runnerConfig = (aiConfig.runner ?? {}) as Record;
+ const localAgentConfig = (aiConfig['local-agent'] ?? {}) as Record<
+ string,
+ unknown
+ >;
+ const modelConfig = (localAgentConfig.model ?? {}) as Record;
+
+ return {
+ ...config,
+ ai: {
+ ...aiConfig,
+ runner: { ...runnerConfig, runner: 'local-agent' },
+ 'local-agent': {
+ ...localAgentConfig,
+ model: {
+ ...modelConfig,
+ primary: modelUuid,
+ fallbacks: Array.isArray(modelConfig.fallbacks)
+ ? modelConfig.fallbacks
+ : [],
+ },
+ },
+ },
+ };
+}
diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts
index a5ceb54d5..b0130d041 100644
--- a/web/src/i18n/locales/en-US.ts
+++ b/web/src/i18n/locales/en-US.ts
@@ -1859,7 +1859,35 @@ const enUS = {
'Connect Dify, n8n, Coze, or another platform and replace the bot pipeline.',
ownModelTitle: 'Use My Own Model',
ownModelDescription:
- 'Open the current Local Agent pipeline and configure your own model.',
+ 'Add a provider, then scan or manually enter a model to finish setup.',
+ ownModelSetupTitle: 'Add Your Own Model',
+ ownModelSetupDescription:
+ 'Add a model provider. Chat models are scanned automatically, or you can enter a model ID manually.',
+ addProviderTitle: 'Add Provider',
+ addProviderDescription:
+ 'Enter the provider details and API key used to connect and scan models.',
+ selectModelTitle: 'Choose a Model',
+ selectScannedModelTitle: 'Choose a Model',
+ selectScannedModelDescription:
+ 'The selected model will be the primary model of a new pipeline, and the bot will switch to it.',
+ scanModelMode: 'Scan Models',
+ manualModelMode: 'Add Manually',
+ scanningModels: 'Scanning available models…',
+ noScannedModels:
+ 'No available chat models were found. Check the provider configuration.',
+ scanModelsFailed:
+ 'Model scanning failed. Check the URL and API key, then try again.',
+ manualFallbackFailed:
+ 'Automatic scanning failed. Enter a model ID supported by the provider.',
+ manualFallbackEmpty:
+ 'No models were found. Enter a model ID supported by the provider.',
+ manualModelId: 'Model ID',
+ manualModelIdPlaceholder: 'For example: gpt-4o',
+ manualModelIdDescription:
+ 'Enter the model parameter used in model requests.',
+ manualModelOptions: 'Optional Model Capabilities',
+ editProvider: 'Edit provider',
+ rescanModels: 'Scan models again',
moreFeaturesTitle: 'Add More Agent Features',
moreFeaturesDescription:
'Open the workbench to add tools, knowledge, and other capabilities.',
@@ -1867,7 +1895,7 @@ const enUS = {
'Select a runner for the external agent and configure its connection.',
backToChoices: 'Back to options',
createExternal: 'Create and Bind',
- configurePipeline: 'Configure Pipeline',
+ finishWithModel: 'Use Selected Model & Finish',
openWorkbench: 'Open Workbench',
},
spaceBanner: {
diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts
index e73e5b998..f2f7ecbaf 100644
--- a/web/src/i18n/locales/ja-JP.ts
+++ b/web/src/i18n/locales/ja-JP.ts
@@ -1776,14 +1776,42 @@ const jaJP = {
'Dify、n8n、Coze などを接続し、ボットのパイプラインを置き換えます。',
ownModelTitle: '自分のモデルを使用',
ownModelDescription:
- '現在の Local Agent パイプラインを開き、自分のモデルを設定します。',
+ 'プロバイダーを追加し、モデルをスキャンまたは手動入力して設定を完了します。',
+ ownModelSetupTitle: '自分のモデルを追加',
+ ownModelSetupDescription:
+ 'モデルプロバイダーを追加すると自動スキャンされます。モデル ID の手動入力も可能です。',
+ addProviderTitle: 'プロバイダーを追加',
+ addProviderDescription:
+ '接続とモデルスキャンに使用するプロバイダー情報と API キーを入力します。',
+ selectModelTitle: 'モデルを選択',
+ selectScannedModelTitle: 'モデルを選択',
+ selectScannedModelDescription:
+ '選択したモデルを新しいパイプラインのメインモデルに設定し、ボットをそのパイプラインへ切り替えます。',
+ scanModelMode: 'モデルをスキャン',
+ manualModelMode: '手動で追加',
+ scanningModels: '利用可能なモデルをスキャン中…',
+ noScannedModels:
+ '利用可能なチャットモデルが見つかりません。プロバイダー設定を確認してください。',
+ scanModelsFailed:
+ 'モデルのスキャンに失敗しました。URL と API キーを確認して再試行してください。',
+ manualFallbackFailed:
+ '自動スキャンに失敗しました。プロバイダーが対応するモデル ID を直接入力できます。',
+ manualFallbackEmpty:
+ 'モデルが見つかりませんでした。プロバイダーが対応するモデル ID を直接入力できます。',
+ manualModelId: 'モデル ID',
+ manualModelIdPlaceholder: '例:gpt-4o',
+ manualModelIdDescription:
+ 'モデルリクエストで実際に使用する model パラメーターを入力します。',
+ manualModelOptions: '任意のモデル機能',
+ editProvider: 'プロバイダーを編集',
+ rescanModels: 'モデルを再スキャン',
moreFeaturesTitle: 'Agent に機能を追加',
moreFeaturesDescription:
'ワークベンチを開き、ツールやナレッジなどの機能を追加します。',
runnerDescription: '外部 Agent の Runner を選択し、接続を設定します。',
backToChoices: '選択肢に戻る',
createExternal: '作成して関連付ける',
- configurePipeline: 'パイプラインを設定',
+ finishWithModel: '選択したモデルを使用して完了',
openWorkbench: 'ワークベンチを開く',
},
spaceBanner: {
diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts
index d03e6de36..c3bbcab74 100644
--- a/web/src/i18n/locales/zh-Hans.ts
+++ b/web/src/i18n/locales/zh-Hans.ts
@@ -1772,13 +1772,37 @@ const zhHans = {
externalDescription:
'接入 Dify、n8n、Coze 等平台,并替换当前机器人的流水线。',
ownModelTitle: '改成使用自己的模型',
- ownModelDescription: '进入当前 Local Agent 流水线,配置你自己的模型。',
+ ownModelDescription:
+ '添加模型供应商,自动扫描或手动填写模型以快速完成引导。',
+ ownModelSetupTitle: '添加你自己的模型',
+ ownModelSetupDescription:
+ '先添加模型供应商,保存后会自动扫描,也可以手动填写模型 ID。',
+ addProviderTitle: '添加供应商',
+ addProviderDescription: '填写供应商和 API Key,用于连接并扫描模型。',
+ selectModelTitle: '选择模型',
+ selectScannedModelTitle: '选择一个模型',
+ selectScannedModelDescription:
+ '选中的模型将作为新流水线的主模型,机器人会切换到这条流水线。',
+ scanModelMode: '扫描模型',
+ manualModelMode: '手动添加',
+ scanningModels: '正在扫描可用模型…',
+ noScannedModels: '没有扫描到可用的对话模型,请检查供应商配置。',
+ scanModelsFailed: '模型扫描失败,请检查地址和 API Key 后重试。',
+ manualFallbackFailed: '自动扫描失败,你可以直接填写中转站支持的模型 ID。',
+ manualFallbackEmpty:
+ '没有扫描到可用模型,你可以直接填写中转站支持的模型 ID。',
+ manualModelId: '模型 ID',
+ manualModelIdPlaceholder: '例如:gpt-4o',
+ manualModelIdDescription: '填写模型请求中实际使用的 model 参数。',
+ manualModelOptions: '可选模型能力',
+ editProvider: '修改供应商',
+ rescanModels: '重新扫描模型',
moreFeaturesTitle: '给现在的 Agent 配置更多功能',
moreFeaturesDescription: '进入工作台,为 Agent 添加工具、知识库等能力。',
runnerDescription: '选择外部 Agent 的 Runner 并完成连接配置。',
backToChoices: '返回选项',
createExternal: '创建并绑定',
- configurePipeline: '配置流水线',
+ finishWithModel: '使用所选模型并完成',
openWorkbench: '进入工作台',
},
spaceBanner: {
diff --git a/web/tests/unit/wizard-http-bot.test.mjs b/web/tests/unit/wizard-http-bot.test.mjs
index 4a6edf936..1558f9f89 100644
--- a/web/tests/unit/wizard-http-bot.test.mjs
+++ b/web/tests/unit/wizard-http-bot.test.mjs
@@ -27,7 +27,13 @@ function loadWizardUtils() {
return loadedModule.exports;
}
-const { ensureHttpBotSigningSecret, getErrorMessage } = loadWizardUtils();
+const {
+ configureLocalAgentPrimaryModel,
+ ensureHttpBotSigningSecret,
+ findDefaultPipeline,
+ getErrorMessage,
+ isWebhookModeEnabled,
+} = loadWizardUtils();
test('generates an HTTP Bot signing secret when signatures are enabled', () => {
const config = ensureHttpBotSigningSecret('http_bot', {
@@ -59,3 +65,57 @@ test('extracts the backend message from structured API errors', () => {
);
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);
+});