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 { + public createProviderLLMModel( + model: Omit, + ): Promise<{ uuid: string }> { return this.post('/api/v1/provider/models/llm', model); } diff --git a/web/src/app/wizard/components/OwnModelSetup.tsx b/web/src/app/wizard/components/OwnModelSetup.tsx new file mode 100644 index 000000000..39dfb0cff --- /dev/null +++ b/web/src/app/wizard/components/OwnModelSetup.tsx @@ -0,0 +1,409 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + ArrowLeft, + Check, + Eye, + Loader2, + Pencil, + RefreshCw, + Wrench, +} from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import ProviderForm from '@/app/home/components/models-dialog/component/provider-form/ProviderForm'; +import type { ScannedProviderModel } from '@/app/infra/entities/api'; +import { httpClient } from '@/app/infra/http/HttpClient'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { cn } from '@/lib/utils'; + +type ModelSetupMode = 'scan' | 'manual'; +type ScanFallbackReason = 'failed' | 'empty' | null; + +export interface OwnModelSelection { + source: ModelSetupMode; + providerUuid: string; + model: ScannedProviderModel; +} + +interface OwnModelSetupProps { + onBack: () => void; + onSelectionChange: (selection: OwnModelSelection | null) => void; +} + +export default function OwnModelSetup({ + onBack, + onSelectionChange, +}: OwnModelSetupProps) { + const { t } = useTranslation(); + const [providerUuid, setProviderUuid] = useState(null); + const [showProviderForm, setShowProviderForm] = useState(true); + const [mode, setMode] = useState('scan'); + const [models, setModels] = useState([]); + const [selectedModelId, setSelectedModelId] = useState(null); + const [isScanning, setIsScanning] = useState(false); + const [scanFallbackReason, setScanFallbackReason] = + useState(null); + const [manualModelName, setManualModelName] = useState(''); + const [manualContextLength, setManualContextLength] = useState(''); + const [manualVision, setManualVision] = useState(false); + const [manualFunctionCall, setManualFunctionCall] = useState(false); + + const parsedManualContextLength = useMemo(() => { + if (!manualContextLength.trim()) return null; + const value = Number(manualContextLength); + return Number.isInteger(value) && value > 0 ? value : undefined; + }, [manualContextLength]); + + useEffect(() => { + if (mode !== 'manual' || !providerUuid) return; + if (!manualModelName.trim() || parsedManualContextLength === undefined) { + onSelectionChange(null); + return; + } + + const abilities = [ + ...(manualVision ? ['vision'] : []), + ...(manualFunctionCall ? ['func_call'] : []), + ]; + const modelName = manualModelName.trim(); + onSelectionChange({ + source: 'manual', + providerUuid, + model: { + id: modelName, + name: modelName, + type: 'llm', + abilities, + context_length: parsedManualContextLength, + already_added: false, + }, + }); + }, [ + manualFunctionCall, + manualModelName, + manualVision, + mode, + onSelectionChange, + parsedManualContextLength, + providerUuid, + ]); + + const scanModels = useCallback( + async (uuid: string) => { + setMode('scan'); + setIsScanning(true); + setScanFallbackReason(null); + setModels([]); + setSelectedModelId(null); + onSelectionChange(null); + + try { + const response = await httpClient.scanProviderModels(uuid, 'llm'); + const availableModels = response.models.filter( + (model) => model.type === 'llm' && !model.already_added, + ); + setModels(availableModels); + if (availableModels.length === 0) { + setScanFallbackReason('empty'); + setMode('manual'); + } + } catch { + setScanFallbackReason('failed'); + setMode('manual'); + } finally { + setIsScanning(false); + } + }, + [onSelectionChange], + ); + + const handleProviderSaved = useCallback( + async (uuid: string) => { + setProviderUuid(uuid); + setShowProviderForm(false); + await scanModels(uuid); + }, + [scanModels], + ); + + const handleSelectModel = useCallback( + (model: ScannedProviderModel) => { + if (!providerUuid) return; + setSelectedModelId(model.id); + onSelectionChange({ source: 'scan', providerUuid, model }); + }, + [onSelectionChange, providerUuid], + ); + + const handleModeChange = useCallback( + (value: string) => { + setMode(value as ModelSetupMode); + setSelectedModelId(null); + onSelectionChange(null); + }, + [onSelectionChange], + ); + + const handleBack = useCallback(() => { + onSelectionChange(null); + onBack(); + }, [onBack, onSelectionChange]); + + const handleEditProvider = useCallback(() => { + setSelectedModelId(null); + onSelectionChange(null); + setShowProviderForm(true); + }, [onSelectionChange]); + + return ( +
+
+ +
+ +
+

+ {t('wizard.aiEngine.ownModelSetupTitle')} +

+

+ {t('wizard.aiEngine.ownModelSetupDescription')} +

+
+ + {showProviderForm ? ( + + + + {t('wizard.aiEngine.addProviderTitle')} + + + {t('wizard.aiEngine.addProviderDescription')} + + + + + providerUuid ? setShowProviderForm(false) : handleBack() + } + /> + + + ) : ( +
+
+
+

+ {t('wizard.aiEngine.selectModelTitle')} +

+

+ {t('wizard.aiEngine.selectScannedModelDescription')} +

+
+ +
+ + + + + {t('wizard.aiEngine.scanModelMode')} + + + {t('wizard.aiEngine.manualModelMode')} + + + + + {isScanning ? ( +
+ + {t('wizard.aiEngine.scanningModels')} +
+ ) : models.length > 0 ? ( +
+
+ {models.map((model) => { + const selected = selectedModelId === model.id; + return ( + + ); + })} +
+
+ +
+
+ ) : ( +
+

+ {t( + scanFallbackReason === 'failed' + ? 'wizard.aiEngine.scanModelsFailed' + : 'wizard.aiEngine.noScannedModels', + )} +

+ +
+ )} +
+ + + {scanFallbackReason && ( +
+ {t( + scanFallbackReason === 'failed' + ? 'wizard.aiEngine.manualFallbackFailed' + : 'wizard.aiEngine.manualFallbackEmpty', + )} +
+ )} + +
+ + setManualModelName(event.target.value)} + placeholder={t('wizard.aiEngine.manualModelIdPlaceholder')} + /> +

+ {t('wizard.aiEngine.manualModelIdDescription')} +

+
+ +
+

+ {t('wizard.aiEngine.manualModelOptions')} +

+
+ + + setManualContextLength(event.target.value) + } + placeholder={t('models.contextLengthPlaceholder')} + /> + {parsedManualContextLength === undefined && ( +

+ {t('models.contextLengthInvalid')} +

+ )} +
+ +
+
+ + setManualVision(checked === true) + } + /> + +
+
+ + setManualFunctionCall(checked === true) + } + /> + +
+
+
+
+
+
+ )} +
+ ); +} diff --git a/web/src/app/wizard/page.tsx b/web/src/app/wizard/page.tsx index 9dc1d0140..d81da665c 100644 --- a/web/src/app/wizard/page.tsx +++ b/web/src/app/wizard/page.tsx @@ -40,6 +40,9 @@ import { } from '@/app/home/components/dynamic-form/DynamicFormItemConfig'; import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent'; import { BotLogListComponent } from '@/app/home/bots/components/bot-log/view/BotLogListComponent'; +import OwnModelSetup, { + OwnModelSelection, +} from '@/app/wizard/components/OwnModelSetup'; import { extractI18nObject } from '@/i18n/I18nProvider'; import { groupByCategory, @@ -49,8 +52,11 @@ import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs'; import i18n from 'i18next'; import { + configureLocalAgentPrimaryModel, ensureHttpBotSigningSecret, + findDefaultPipeline, getErrorMessage, + isWebhookModeEnabled, } from '@/app/wizard/utils'; import { Button } from '@/components/ui/button'; @@ -120,6 +126,8 @@ export default function WizardPage() { const [aiChoice, setAiChoice] = useState< 'external' | 'own-model' | 'more-features' | null >(null); + const [ownModelSelection, setOwnModelSelection] = + useState(null); // ---- Helper: persist wizard progress to backend (fire-and-forget) ---- const saveProgress = useCallback( @@ -357,6 +365,9 @@ export default function WizardPage() { const goPrev = useCallback(() => { if (currentStep > 0) { const prevStep = currentStep - 1; + if (currentStep === 2) { + setOwnModelSelection(null); + } setCurrentStep(prevStep); saveProgress({ step: prevStep }); } @@ -434,7 +445,7 @@ export default function WizardPage() { }, [selectedAdapter, adapters, t, saveProgress]); // ---- Save Bot Config & Enable (Step 1) ---- - // Creates a recommended Local Agent pipeline, binds it, and enables the bot. + // Binds the bot to the Workspace default pipeline and enables it. const handleSaveBot = useCallback(async () => { if (!createdBotUuid || !selectedAdapter) return; @@ -442,42 +453,74 @@ export default function WizardPage() { let createdPipelineThisAttempt: string | null = null; try { - let pipelineUuid = createdPipelineUuid; + const pipelinesResponse = await httpClient.getPipelines( + 'updated_at', + 'DESC', + ); + const defaultPipeline = findDefaultPipeline(pipelinesResponse.pipelines); + let pipelineUuid = defaultPipeline?.uuid ?? null; + let createdDefaultPipeline = false; + if (!pipelineUuid) { - const recommendedModel = await httpClient.getWizardRecommendedModel(); const pipelineResp = await httpClient.createPipeline({ name: `${botName} Agent`, description: botDescription || '', config: {}, + is_default: true, }); pipelineUuid = pipelineResp.uuid; createdPipelineThisAttempt = pipelineUuid; - const createdPipeline = await httpClient.getPipeline(pipelineUuid); - const aiConfig = createdPipeline.pipeline.config.ai as Record< - string, - unknown - >; - const localAgentConfig = (aiConfig['local-agent'] ?? {}) as Record< - string, - unknown - >; + createdDefaultPipeline = true; + } + + const pipelineData = await httpClient.getPipeline(pipelineUuid); + const fullConfig = pipelineData.pipeline.config as unknown as Record< + string, + unknown + >; + const aiConfig = (fullConfig.ai ?? {}) as Record; + const runnerConfig = (aiConfig.runner ?? {}) as Record; + const localAgentConfig = (aiConfig['local-agent'] ?? {}) as Record< + string, + unknown + >; + const modelConfig = (localAgentConfig.model ?? {}) as Record< + string, + unknown + >; + const usesLocalAgent = + createdDefaultPipeline || runnerConfig.runner === 'local-agent'; + const needsPrimaryModel = + usesLocalAgent && + (typeof modelConfig.primary !== 'string' || !modelConfig.primary); + + if (createdDefaultPipeline || needsPrimaryModel) { + const recommendedModel = await httpClient.getWizardRecommendedModel(); await httpClient.updatePipeline(pipelineUuid, { - name: `${botName} Agent`, - description: botDescription || '', + name: pipelineData.pipeline.name, + description: pipelineData.pipeline.description || '', config: { - ...createdPipeline.pipeline.config, + ...fullConfig, ai: { ...aiConfig, - runner: { runner: 'local-agent' }, + runner: createdDefaultPipeline + ? { ...runnerConfig, runner: 'local-agent' } + : runnerConfig, 'local-agent': { ...localAgentConfig, - model: { primary: recommendedModel.uuid, fallbacks: [] }, + model: { + ...modelConfig, + primary: recommendedModel.uuid, + fallbacks: Array.isArray(modelConfig.fallbacks) + ? modelConfig.fallbacks + : [], + }, }, }, }, }); - setCreatedPipelineUuid(pipelineUuid); } + setCreatedPipelineUuid(pipelineUuid); const configToSave = ensureHttpBotSigningSecret( selectedAdapter, @@ -537,7 +580,6 @@ export default function WizardPage() { botName, botDescription, adapterConfig, - createdPipelineUuid, t, saveProgress, ]); @@ -559,9 +601,14 @@ export default function WizardPage() { const handleFinish = useCallback(async () => { if (!aiChoice || !createdBotUuid || !createdPipelineUuid) return; if (aiChoice === 'external' && !selectedRunner) return; + if (aiChoice === 'own-model' && !ownModelSelection) return; setIsSubmitting(true); let externalPipelineUuid: string | null = null; let externalPipelineBound = false; + let createdOwnModelUuid: string | null = null; + let ownModelPipelineUuid: string | null = null; + let ownModelPipelineBound = false; + let originalOwnModelBot: Bot | null = null; try { if (aiChoice === 'external' && selectedRunner) { @@ -599,18 +646,91 @@ export default function WizardPage() { externalPipelineBound = true; } - await completeWizard(); - if (aiChoice === 'own-model') { - navigate(`/home/pipelines?id=${createdPipelineUuid}`, { - replace: true, + if (aiChoice === 'own-model' && ownModelSelection) { + const modelResponse = await httpClient.createProviderLLMModel({ + name: ownModelSelection.model.name, + provider_uuid: ownModelSelection.providerUuid, + abilities: ownModelSelection.model.abilities ?? [], + reasoning_config: { level: 'provider_default' }, + context_length: ownModelSelection.model.context_length ?? null, + extra_args: {}, }); - } else { - navigate('/home', { replace: true }); + createdOwnModelUuid = modelResponse.uuid; + + const pipelineResponse = await httpClient.createPipeline({ + name: `${botName} Custom Agent`, + description: botDescription || '', + config: {}, + }); + ownModelPipelineUuid = pipelineResponse.uuid; + const createdPipeline = + await httpClient.getPipeline(ownModelPipelineUuid); + const fullConfig = createdPipeline.pipeline.config as unknown as Record< + string, + unknown + >; + await httpClient.updatePipeline(ownModelPipelineUuid, { + name: `${botName} Custom Agent`, + description: botDescription || '', + config: configureLocalAgentPrimaryModel( + fullConfig, + createdOwnModelUuid, + ), + }); + + originalOwnModelBot = (await httpClient.getBot(createdBotUuid)).bot; + await httpClient.updateBot(createdBotUuid, { + name: originalOwnModelBot.name, + description: originalOwnModelBot.description, + adapter: originalOwnModelBot.adapter, + adapter_config: originalOwnModelBot.adapter_config, + enable: originalOwnModelBot.enable, + use_pipeline_uuid: ownModelPipelineUuid, + }); + ownModelPipelineBound = true; } + + await completeWizard(); + navigate('/home', { replace: true }); } catch (err) { if (externalPipelineUuid && !externalPipelineBound) { await httpClient.deletePipeline(externalPipelineUuid).catch(() => {}); } + if (createdOwnModelUuid) { + let canCleanUpOwnModelResources = !ownModelPipelineBound; + if (ownModelPipelineBound && originalOwnModelBot) { + try { + await httpClient.updateBot(createdBotUuid, { + name: originalOwnModelBot.name, + description: originalOwnModelBot.description, + adapter: originalOwnModelBot.adapter, + adapter_config: originalOwnModelBot.adapter_config, + enable: originalOwnModelBot.enable, + use_pipeline_uuid: originalOwnModelBot.use_pipeline_uuid, + }); + canCleanUpOwnModelResources = true; + } catch { + canCleanUpOwnModelResources = false; + } + } + + if (canCleanUpOwnModelResources) { + let pipelineDeleted = ownModelPipelineUuid === null; + if (ownModelPipelineUuid) { + try { + await httpClient.deletePipeline(ownModelPipelineUuid); + pipelineDeleted = true; + } catch { + pipelineDeleted = false; + } + } + if (pipelineDeleted) { + await httpClient + .deleteProviderLLMModel(createdOwnModelUuid) + .catch(() => {}); + } + } + } const apiErr = err as { msg?: string }; toast.error( t('wizard.createError') + (apiErr?.msg ? `: ${apiErr.msg}` : ''), @@ -626,6 +746,7 @@ export default function WizardPage() { botName, botDescription, runnerConfig, + ownModelSelection, completeWizard, navigate, t, @@ -813,6 +934,7 @@ export default function WizardPage() { runnerConfigItems={selectedRunnerConfigItems} runnerConfigValues={runnerConfig} onRunnerConfigChange={setRunnerConfig} + onOwnModelSelectionChange={setOwnModelSelection} /> )} @@ -851,7 +973,8 @@ export default function WizardPage() { disabled={ !canProceed() || isSubmitting || - (aiChoice === 'external' && !selectedRunner) + (aiChoice === 'external' && !selectedRunner) || + (aiChoice === 'own-model' && !ownModelSelection) } > {isSubmitting && ( @@ -860,7 +983,7 @@ export default function WizardPage() { {aiChoice === 'external' ? t('wizard.aiEngine.createExternal') : aiChoice === 'own-model' - ? t('wizard.aiEngine.configurePipeline') + ? t('wizard.aiEngine.finishWithModel') : t('wizard.aiEngine.openWorkbench')} )} @@ -1071,6 +1194,13 @@ function StepBotConfig({ return a ? extractI18nObject(a.label) : (selectedAdapterName ?? ''); }, [adapters, selectedAdapterName]); + const webhookModeEnabled = useMemo( + () => + isWebhookModeEnabled(adapterConfigItems, adapterConfigValues) && + Boolean(webhookUrl), + [adapterConfigItems, adapterConfigValues, webhookUrl], + ); + // Stable callback ref const onAdapterConfigRef = useRef(onAdapterConfigChange); onAdapterConfigRef.current = onAdapterConfigChange; @@ -1144,7 +1274,7 @@ function StepBotConfig({ ) : selectedAdapterName === 'http_bot' ? ( - ) : webhookUrl ? ( + ) : webhookModeEnabled ? ( ) : ( @@ -1165,12 +1295,12 @@ function StepBotConfig({ ? t('wizard.botConfig.pageBotTestPrompt') : selectedAdapterName === 'http_bot' ? t('wizard.botConfig.httpTestPrompt') - : webhookUrl + : webhookModeEnabled ? t('wizard.botConfig.webhookTestPrompt') : t('wizard.botConfig.waitingForMessage')}

- {!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); +});