mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-25 11:37:13 +00:00
feat(wizard): streamline custom model onboarding
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user