feat(wizard): streamline custom model onboarding

This commit is contained in:
fdc310
2026-08-25 19:33:36 +08:00
parent 6b60ad1678
commit 7f29d51c69
13 changed files with 838 additions and 43 deletions
+2
View File
@@ -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 `list_*` / `get_*` tools before mutating; identifiers are UUIDs. Reads require
`resource.view`; mutations require `resource.manage`. All service calls inherit `resource.view`; mutations require `resource.manage`. All service calls inherit
the immutable Workspace context authenticated at the MCP transport boundary. 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 ## How to use
@@ -39,7 +39,13 @@ class PipelinesRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_MANAGE, permission=Permission.RESOURCE_MANAGE,
) )
async def _(request_context: RequestContext) -> str: 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}) return self.success(data={'uuid': pipeline_uuid})
@self.route( @self.route(
+10 -1
View File
@@ -147,7 +147,16 @@ class LangBotMCPServer:
) )
async def create_pipeline(pipeline_data: dict) -> str: async def create_pipeline(pipeline_data: dict) -> str:
context = _authorized(Permission.RESOURCE_MANAGE) 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.') @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: async def update_pipeline(pipeline_uuid: str, pipeline_data: dict) -> str:
+16
View File
@@ -254,6 +254,22 @@ class TestPipelinesCRUDEndpoints:
assert data['code'] == 0 assert data['code'] == 0
assert 'uuid' in data['data'] 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 @pytest.mark.asyncio
async def test_update_pipeline_success(self, quart_test_client): async def test_update_pipeline_success(self, quart_test_client):
"""PUT /api/v1/pipelines/{uuid} updates pipeline.""" """PUT /api/v1/pipelines/{uuid} updates pipeline."""
@@ -33,7 +33,7 @@ const getFormSchema = (t: (key: string) => string) =>
interface ProviderFormProps { interface ProviderFormProps {
providerId?: string; providerId?: string;
onFormSubmit: () => void; onFormSubmit: (providerUuid: string) => void | Promise<void>;
onFormCancel: () => void; onFormCancel: () => void;
} }
@@ -171,14 +171,16 @@ export default function ProviderForm({
}; };
try { try {
let savedProviderUuid = providerId;
if (providerId) { if (providerId) {
await httpClient.updateModelProvider(providerId, data); await httpClient.updateModelProvider(providerId, data);
toast.success(t('models.providerSaved')); toast.success(t('models.providerSaved'));
} else { } else {
await httpClient.createModelProvider(data); const response = await httpClient.createModelProvider(data);
savedProviderUuid = response.uuid;
toast.success(t('models.providerCreated')); toast.success(t('models.providerCreated'));
} }
onFormSubmit(); await onFormSubmit(savedProviderUuid as string);
} catch (err) { } catch (err) {
toast.error(t('models.providerSaveError') + (err as CustomApiError).msg); toast.error(t('models.providerSaveError') + (err as CustomApiError).msg);
} }
+3 -1
View File
@@ -150,7 +150,9 @@ export class BackendClient extends BaseHttpClient {
return this.get(`/api/v1/provider/models/llm/${uuid}`); return this.get(`/api/v1/provider/models/llm/${uuid}`);
} }
public createProviderLLMModel(model: LLMModel): Promise<object> { public createProviderLLMModel(
model: Omit<LLMModel, 'uuid'>,
): Promise<{ uuid: string }> {
return this.post('/api/v1/provider/models/llm', model); return this.post('/api/v1/provider/models/llm', model);
} }
@@ -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<string | null>(null);
const [showProviderForm, setShowProviderForm] = useState(true);
const [mode, setMode] = useState<ModelSetupMode>('scan');
const [models, setModels] = useState<ScannedProviderModel[]>([]);
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
const [isScanning, setIsScanning] = useState(false);
const [scanFallbackReason, setScanFallbackReason] =
useState<ScanFallbackReason>(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 (
<div className="mx-auto w-full max-w-3xl space-y-5">
<div>
<Button variant="ghost" size="sm" onClick={handleBack}>
<ArrowLeft className="mr-1.5 size-4" />
{t('wizard.aiEngine.backToChoices')}
</Button>
</div>
<div className="text-center">
<h2 className="text-xl font-semibold">
{t('wizard.aiEngine.ownModelSetupTitle')}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
{t('wizard.aiEngine.ownModelSetupDescription')}
</p>
</div>
{showProviderForm ? (
<Card>
<CardHeader>
<CardTitle className="text-base">
{t('wizard.aiEngine.addProviderTitle')}
</CardTitle>
<CardDescription>
{t('wizard.aiEngine.addProviderDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<ProviderForm
providerId={providerUuid ?? undefined}
onFormSubmit={handleProviderSaved}
onFormCancel={() =>
providerUuid ? setShowProviderForm(false) : handleBack()
}
/>
</CardContent>
</Card>
) : (
<div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3 border-b pb-3">
<div>
<h3 className="text-base font-semibold">
{t('wizard.aiEngine.selectModelTitle')}
</h3>
<p className="text-sm text-muted-foreground">
{t('wizard.aiEngine.selectScannedModelDescription')}
</p>
</div>
<Button
variant="outline"
size="icon"
title={t('wizard.aiEngine.editProvider')}
onClick={handleEditProvider}
>
<Pencil className="size-4" />
</Button>
</div>
<Tabs value={mode} onValueChange={handleModeChange}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="scan">
{t('wizard.aiEngine.scanModelMode')}
</TabsTrigger>
<TabsTrigger value="manual">
{t('wizard.aiEngine.manualModelMode')}
</TabsTrigger>
</TabsList>
<TabsContent value="scan" className="mt-4">
{isScanning ? (
<div className="flex min-h-48 items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
{t('wizard.aiEngine.scanningModels')}
</div>
) : models.length > 0 ? (
<div className="space-y-3">
<div className="grid gap-2 sm:grid-cols-2">
{models.map((model) => {
const selected = selectedModelId === model.id;
return (
<button
key={model.id}
type="button"
className={cn(
'flex min-h-20 items-center gap-3 rounded-md border p-3 text-left transition-colors hover:border-primary/60 hover:bg-accent/40',
selected &&
'border-primary bg-accent ring-1 ring-primary',
)}
onClick={() => handleSelectModel(model)}
>
<span
className={cn(
'flex size-5 shrink-0 items-center justify-center rounded-full border',
selected &&
'border-primary bg-primary text-primary-foreground',
)}
>
{selected && <Check className="size-3" />}
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-medium">
{model.display_name || model.name}
</span>
<span className="block truncate text-xs text-muted-foreground">
{model.name}
</span>
</span>
</button>
);
})}
</div>
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
disabled={isScanning || !providerUuid}
onClick={() => providerUuid && scanModels(providerUuid)}
>
<RefreshCw className="mr-1.5 size-4" />
{t('wizard.aiEngine.rescanModels')}
</Button>
</div>
</div>
) : (
<div className="flex min-h-48 flex-col items-center justify-center gap-3 border border-dashed p-6 text-center">
<p className="text-sm text-muted-foreground">
{t(
scanFallbackReason === 'failed'
? 'wizard.aiEngine.scanModelsFailed'
: 'wizard.aiEngine.noScannedModels',
)}
</p>
<Button
variant="outline"
size="sm"
disabled={!providerUuid}
onClick={() => providerUuid && scanModels(providerUuid)}
>
<RefreshCw className="mr-1.5 size-4" />
{t('wizard.aiEngine.rescanModels')}
</Button>
</div>
)}
</TabsContent>
<TabsContent value="manual" className="mt-4 space-y-5">
{scanFallbackReason && (
<div className="border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-200">
{t(
scanFallbackReason === 'failed'
? 'wizard.aiEngine.manualFallbackFailed'
: 'wizard.aiEngine.manualFallbackEmpty',
)}
</div>
)}
<div className="space-y-2">
<Label htmlFor="wizard-manual-model-name">
{t('wizard.aiEngine.manualModelId')}
<span className="text-red-500">*</span>
</Label>
<Input
id="wizard-manual-model-name"
value={manualModelName}
onChange={(event) => setManualModelName(event.target.value)}
placeholder={t('wizard.aiEngine.manualModelIdPlaceholder')}
/>
<p className="text-xs text-muted-foreground">
{t('wizard.aiEngine.manualModelIdDescription')}
</p>
</div>
<div className="space-y-3 border-t pt-4">
<p className="text-sm font-medium">
{t('wizard.aiEngine.manualModelOptions')}
</p>
<div className="space-y-2">
<Label htmlFor="wizard-manual-context-length">
{t('models.contextLength')}
</Label>
<Input
id="wizard-manual-context-length"
type="number"
min={1}
step={1}
value={manualContextLength}
onChange={(event) =>
setManualContextLength(event.target.value)
}
placeholder={t('models.contextLengthPlaceholder')}
/>
{parsedManualContextLength === undefined && (
<p className="text-xs text-destructive">
{t('models.contextLengthInvalid')}
</p>
)}
</div>
<div className="flex flex-wrap gap-5">
<div className="flex items-center gap-2">
<Checkbox
id="wizard-manual-vision"
checked={manualVision}
onCheckedChange={(checked) =>
setManualVision(checked === true)
}
/>
<Label
htmlFor="wizard-manual-vision"
className="flex items-center gap-1.5"
>
<Eye className="size-4" />
{t('models.visionAbility')}
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="wizard-manual-function-call"
checked={manualFunctionCall}
onCheckedChange={(checked) =>
setManualFunctionCall(checked === true)
}
/>
<Label
htmlFor="wizard-manual-function-call"
className="flex items-center gap-1.5"
>
<Wrench className="size-4" />
{t('models.functionCallAbility')}
</Label>
</div>
</div>
</div>
</TabsContent>
</Tabs>
</div>
)}
</div>
);
}
+171 -30
View File
@@ -40,6 +40,9 @@ import {
} from '@/app/home/components/dynamic-form/DynamicFormItemConfig'; } from '@/app/home/components/dynamic-form/DynamicFormItemConfig';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent'; import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import { BotLogListComponent } from '@/app/home/bots/components/bot-log/view/BotLogListComponent'; 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 { extractI18nObject } from '@/i18n/I18nProvider';
import { import {
groupByCategory, groupByCategory,
@@ -49,8 +52,11 @@ import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
import i18n from 'i18next'; import i18n from 'i18next';
import { import {
configureLocalAgentPrimaryModel,
ensureHttpBotSigningSecret, ensureHttpBotSigningSecret,
findDefaultPipeline,
getErrorMessage, getErrorMessage,
isWebhookModeEnabled,
} from '@/app/wizard/utils'; } from '@/app/wizard/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -120,6 +126,8 @@ export default function WizardPage() {
const [aiChoice, setAiChoice] = useState< const [aiChoice, setAiChoice] = useState<
'external' | 'own-model' | 'more-features' | null 'external' | 'own-model' | 'more-features' | null
>(null); >(null);
const [ownModelSelection, setOwnModelSelection] =
useState<OwnModelSelection | null>(null);
// ---- Helper: persist wizard progress to backend (fire-and-forget) ---- // ---- Helper: persist wizard progress to backend (fire-and-forget) ----
const saveProgress = useCallback( const saveProgress = useCallback(
@@ -357,6 +365,9 @@ export default function WizardPage() {
const goPrev = useCallback(() => { const goPrev = useCallback(() => {
if (currentStep > 0) { if (currentStep > 0) {
const prevStep = currentStep - 1; const prevStep = currentStep - 1;
if (currentStep === 2) {
setOwnModelSelection(null);
}
setCurrentStep(prevStep); setCurrentStep(prevStep);
saveProgress({ step: prevStep }); saveProgress({ step: prevStep });
} }
@@ -434,7 +445,7 @@ export default function WizardPage() {
}, [selectedAdapter, adapters, t, saveProgress]); }, [selectedAdapter, adapters, t, saveProgress]);
// ---- Save Bot Config & Enable (Step 1) ---- // ---- 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 () => { const handleSaveBot = useCallback(async () => {
if (!createdBotUuid || !selectedAdapter) return; if (!createdBotUuid || !selectedAdapter) return;
@@ -442,42 +453,74 @@ export default function WizardPage() {
let createdPipelineThisAttempt: string | null = null; let createdPipelineThisAttempt: string | null = null;
try { 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) { if (!pipelineUuid) {
const recommendedModel = await httpClient.getWizardRecommendedModel();
const pipelineResp = await httpClient.createPipeline({ const pipelineResp = await httpClient.createPipeline({
name: `${botName} Agent`, name: `${botName} Agent`,
description: botDescription || '', description: botDescription || '',
config: {}, config: {},
is_default: true,
}); });
pipelineUuid = pipelineResp.uuid; pipelineUuid = pipelineResp.uuid;
createdPipelineThisAttempt = pipelineUuid; createdPipelineThisAttempt = pipelineUuid;
const createdPipeline = await httpClient.getPipeline(pipelineUuid); createdDefaultPipeline = true;
const aiConfig = createdPipeline.pipeline.config.ai as Record< }
string,
unknown const pipelineData = await httpClient.getPipeline(pipelineUuid);
>; const fullConfig = pipelineData.pipeline.config as unknown as Record<
const localAgentConfig = (aiConfig['local-agent'] ?? {}) as Record< string,
string, unknown
unknown >;
>; const aiConfig = (fullConfig.ai ?? {}) as Record<string, unknown>;
const runnerConfig = (aiConfig.runner ?? {}) as Record<string, unknown>;
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, { await httpClient.updatePipeline(pipelineUuid, {
name: `${botName} Agent`, name: pipelineData.pipeline.name,
description: botDescription || '', description: pipelineData.pipeline.description || '',
config: { config: {
...createdPipeline.pipeline.config, ...fullConfig,
ai: { ai: {
...aiConfig, ...aiConfig,
runner: { runner: 'local-agent' }, runner: createdDefaultPipeline
? { ...runnerConfig, runner: 'local-agent' }
: runnerConfig,
'local-agent': { 'local-agent': {
...localAgentConfig, ...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( const configToSave = ensureHttpBotSigningSecret(
selectedAdapter, selectedAdapter,
@@ -537,7 +580,6 @@ export default function WizardPage() {
botName, botName,
botDescription, botDescription,
adapterConfig, adapterConfig,
createdPipelineUuid,
t, t,
saveProgress, saveProgress,
]); ]);
@@ -559,9 +601,14 @@ export default function WizardPage() {
const handleFinish = useCallback(async () => { const handleFinish = useCallback(async () => {
if (!aiChoice || !createdBotUuid || !createdPipelineUuid) return; if (!aiChoice || !createdBotUuid || !createdPipelineUuid) return;
if (aiChoice === 'external' && !selectedRunner) return; if (aiChoice === 'external' && !selectedRunner) return;
if (aiChoice === 'own-model' && !ownModelSelection) return;
setIsSubmitting(true); setIsSubmitting(true);
let externalPipelineUuid: string | null = null; let externalPipelineUuid: string | null = null;
let externalPipelineBound = false; let externalPipelineBound = false;
let createdOwnModelUuid: string | null = null;
let ownModelPipelineUuid: string | null = null;
let ownModelPipelineBound = false;
let originalOwnModelBot: Bot | null = null;
try { try {
if (aiChoice === 'external' && selectedRunner) { if (aiChoice === 'external' && selectedRunner) {
@@ -599,18 +646,91 @@ export default function WizardPage() {
externalPipelineBound = true; externalPipelineBound = true;
} }
await completeWizard(); if (aiChoice === 'own-model' && ownModelSelection) {
if (aiChoice === 'own-model') { const modelResponse = await httpClient.createProviderLLMModel({
navigate(`/home/pipelines?id=${createdPipelineUuid}`, { name: ownModelSelection.model.name,
replace: true, provider_uuid: ownModelSelection.providerUuid,
abilities: ownModelSelection.model.abilities ?? [],
reasoning_config: { level: 'provider_default' },
context_length: ownModelSelection.model.context_length ?? null,
extra_args: {},
}); });
} else { createdOwnModelUuid = modelResponse.uuid;
navigate('/home', { replace: true });
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) { } catch (err) {
if (externalPipelineUuid && !externalPipelineBound) { if (externalPipelineUuid && !externalPipelineBound) {
await httpClient.deletePipeline(externalPipelineUuid).catch(() => {}); 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 }; const apiErr = err as { msg?: string };
toast.error( toast.error(
t('wizard.createError') + (apiErr?.msg ? `: ${apiErr.msg}` : ''), t('wizard.createError') + (apiErr?.msg ? `: ${apiErr.msg}` : ''),
@@ -626,6 +746,7 @@ export default function WizardPage() {
botName, botName,
botDescription, botDescription,
runnerConfig, runnerConfig,
ownModelSelection,
completeWizard, completeWizard,
navigate, navigate,
t, t,
@@ -813,6 +934,7 @@ export default function WizardPage() {
runnerConfigItems={selectedRunnerConfigItems} runnerConfigItems={selectedRunnerConfigItems}
runnerConfigValues={runnerConfig} runnerConfigValues={runnerConfig}
onRunnerConfigChange={setRunnerConfig} onRunnerConfigChange={setRunnerConfig}
onOwnModelSelectionChange={setOwnModelSelection}
/> />
)} )}
</div> </div>
@@ -851,7 +973,8 @@ export default function WizardPage() {
disabled={ disabled={
!canProceed() || !canProceed() ||
isSubmitting || isSubmitting ||
(aiChoice === 'external' && !selectedRunner) (aiChoice === 'external' && !selectedRunner) ||
(aiChoice === 'own-model' && !ownModelSelection)
} }
> >
{isSubmitting && ( {isSubmitting && (
@@ -860,7 +983,7 @@ export default function WizardPage() {
{aiChoice === 'external' {aiChoice === 'external'
? t('wizard.aiEngine.createExternal') ? t('wizard.aiEngine.createExternal')
: aiChoice === 'own-model' : aiChoice === 'own-model'
? t('wizard.aiEngine.configurePipeline') ? t('wizard.aiEngine.finishWithModel')
: t('wizard.aiEngine.openWorkbench')} : t('wizard.aiEngine.openWorkbench')}
</Button> </Button>
)} )}
@@ -1071,6 +1194,13 @@ function StepBotConfig({
return a ? extractI18nObject(a.label) : (selectedAdapterName ?? ''); return a ? extractI18nObject(a.label) : (selectedAdapterName ?? '');
}, [adapters, selectedAdapterName]); }, [adapters, selectedAdapterName]);
const webhookModeEnabled = useMemo(
() =>
isWebhookModeEnabled(adapterConfigItems, adapterConfigValues) &&
Boolean(webhookUrl),
[adapterConfigItems, adapterConfigValues, webhookUrl],
);
// Stable callback ref // Stable callback ref
const onAdapterConfigRef = useRef(onAdapterConfigChange); const onAdapterConfigRef = useRef(onAdapterConfigChange);
onAdapterConfigRef.current = onAdapterConfigChange; onAdapterConfigRef.current = onAdapterConfigChange;
@@ -1144,7 +1274,7 @@ function StepBotConfig({
<MessageSquare className="size-3 text-white" /> <MessageSquare className="size-3 text-white" />
) : selectedAdapterName === 'http_bot' ? ( ) : selectedAdapterName === 'http_bot' ? (
<Send className="size-3 text-white" /> <Send className="size-3 text-white" />
) : webhookUrl ? ( ) : webhookModeEnabled ? (
<Webhook className="size-3 text-white" /> <Webhook className="size-3 text-white" />
) : ( ) : (
<Loader2 className="size-3 animate-spin text-white" /> <Loader2 className="size-3 animate-spin text-white" />
@@ -1165,12 +1295,12 @@ function StepBotConfig({
? t('wizard.botConfig.pageBotTestPrompt') ? t('wizard.botConfig.pageBotTestPrompt')
: selectedAdapterName === 'http_bot' : selectedAdapterName === 'http_bot'
? t('wizard.botConfig.httpTestPrompt') ? t('wizard.botConfig.httpTestPrompt')
: webhookUrl : webhookModeEnabled
? t('wizard.botConfig.webhookTestPrompt') ? t('wizard.botConfig.webhookTestPrompt')
: t('wizard.botConfig.waitingForMessage')} : t('wizard.botConfig.waitingForMessage')}
</p> </p>
{!messageReceived && webhookUrl && ( {!messageReceived && webhookModeEnabled && (
<div className="mt-3 space-y-3"> <div className="mt-3 space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<code className="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap border bg-background px-2.5 py-2 text-xs"> <code className="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap border bg-background px-2.5 py-2 text-xs">
@@ -1324,6 +1454,7 @@ function StepAIEngine({
runnerConfigItems, runnerConfigItems,
runnerConfigValues, runnerConfigValues,
onRunnerConfigChange, onRunnerConfigChange,
onOwnModelSelectionChange,
}: { }: {
runnerOptions: { name: string; label: { en_US: string; zh_Hans: string } }[]; runnerOptions: { name: string; label: { en_US: string; zh_Hans: string } }[];
choice: 'external' | 'own-model' | 'more-features' | null; choice: 'external' | 'own-model' | 'more-features' | null;
@@ -1337,6 +1468,7 @@ function StepAIEngine({
runnerConfigItems: IDynamicFormItemSchema[]; runnerConfigItems: IDynamicFormItemSchema[];
runnerConfigValues: Record<string, unknown>; runnerConfigValues: Record<string, unknown>;
onRunnerConfigChange: (v: Record<string, unknown>) => void; onRunnerConfigChange: (v: Record<string, unknown>) => void;
onOwnModelSelectionChange: (selection: OwnModelSelection | null) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -1374,6 +1506,15 @@ function StepAIEngine({
}, },
]; ];
if (choice === 'own-model') {
return (
<OwnModelSetup
onBack={() => onChoiceChange(null)}
onSelectionChange={onOwnModelSelectionChange}
/>
);
}
if (choice !== 'external') { if (choice !== 'external') {
return ( return (
<div className="space-y-6 max-w-4xl mx-auto"> <div className="space-y-6 max-w-4xl mx-auto">
+68
View File
@@ -34,3 +34,71 @@ export function ensureHttpBotSigningSecret(
inbound_secret: createSigningSecret(), 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<string, unknown>,
): 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<string, unknown>,
modelUuid: string,
): Record<string, unknown> {
const aiConfig = (config.ai ?? {}) as Record<string, unknown>;
const runnerConfig = (aiConfig.runner ?? {}) as Record<string, unknown>;
const localAgentConfig = (aiConfig['local-agent'] ?? {}) as Record<
string,
unknown
>;
const modelConfig = (localAgentConfig.model ?? {}) as Record<string, unknown>;
return {
...config,
ai: {
...aiConfig,
runner: { ...runnerConfig, runner: 'local-agent' },
'local-agent': {
...localAgentConfig,
model: {
...modelConfig,
primary: modelUuid,
fallbacks: Array.isArray(modelConfig.fallbacks)
? modelConfig.fallbacks
: [],
},
},
},
};
}
+30 -2
View File
@@ -1859,7 +1859,35 @@ const enUS = {
'Connect Dify, n8n, Coze, or another platform and replace the bot pipeline.', 'Connect Dify, n8n, Coze, or another platform and replace the bot pipeline.',
ownModelTitle: 'Use My Own Model', ownModelTitle: 'Use My Own Model',
ownModelDescription: 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', moreFeaturesTitle: 'Add More Agent Features',
moreFeaturesDescription: moreFeaturesDescription:
'Open the workbench to add tools, knowledge, and other capabilities.', '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.', 'Select a runner for the external agent and configure its connection.',
backToChoices: 'Back to options', backToChoices: 'Back to options',
createExternal: 'Create and Bind', createExternal: 'Create and Bind',
configurePipeline: 'Configure Pipeline', finishWithModel: 'Use Selected Model & Finish',
openWorkbench: 'Open Workbench', openWorkbench: 'Open Workbench',
}, },
spaceBanner: { spaceBanner: {
+30 -2
View File
@@ -1776,14 +1776,42 @@ const jaJP = {
'Dify、n8n、Coze などを接続し、ボットのパイプラインを置き換えます。', 'Dify、n8n、Coze などを接続し、ボットのパイプラインを置き換えます。',
ownModelTitle: '自分のモデルを使用', ownModelTitle: '自分のモデルを使用',
ownModelDescription: 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 に機能を追加', moreFeaturesTitle: 'Agent に機能を追加',
moreFeaturesDescription: moreFeaturesDescription:
'ワークベンチを開き、ツールやナレッジなどの機能を追加します。', 'ワークベンチを開き、ツールやナレッジなどの機能を追加します。',
runnerDescription: '外部 Agent の Runner を選択し、接続を設定します。', runnerDescription: '外部 Agent の Runner を選択し、接続を設定します。',
backToChoices: '選択肢に戻る', backToChoices: '選択肢に戻る',
createExternal: '作成して関連付ける', createExternal: '作成して関連付ける',
configurePipeline: 'パイプラインを設定', finishWithModel: '選択したモデルを使用して完了',
openWorkbench: 'ワークベンチを開く', openWorkbench: 'ワークベンチを開く',
}, },
spaceBanner: { spaceBanner: {
+26 -2
View File
@@ -1772,13 +1772,37 @@ const zhHans = {
externalDescription: externalDescription:
'接入 Dify、n8n、Coze 等平台,并替换当前机器人的流水线。', '接入 Dify、n8n、Coze 等平台,并替换当前机器人的流水线。',
ownModelTitle: '改成使用自己的模型', 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 配置更多功能', moreFeaturesTitle: '给现在的 Agent 配置更多功能',
moreFeaturesDescription: '进入工作台,为 Agent 添加工具、知识库等能力。', moreFeaturesDescription: '进入工作台,为 Agent 添加工具、知识库等能力。',
runnerDescription: '选择外部 Agent 的 Runner 并完成连接配置。', runnerDescription: '选择外部 Agent 的 Runner 并完成连接配置。',
backToChoices: '返回选项', backToChoices: '返回选项',
createExternal: '创建并绑定', createExternal: '创建并绑定',
configurePipeline: '配置流水线', finishWithModel: '使用所选模型并完成',
openWorkbench: '进入工作台', openWorkbench: '进入工作台',
}, },
spaceBanner: { spaceBanner: {
+61 -1
View File
@@ -27,7 +27,13 @@ function loadWizardUtils() {
return loadedModule.exports; 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', () => { test('generates an HTTP Bot signing secret when signatures are enabled', () => {
const config = ensureHttpBotSigningSecret('http_bot', { 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'); 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);
});