Merge remote-tracking branch 'origin/master' into dev/4.11.x

# Conflicts:
#	src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py
#	src/langbot/pkg/api/http/service/bot.py
#	src/langbot/pkg/provider/runners/localagent.py
#	src/langbot/templates/metadata/pipeline/ai.yaml
#	tests/unit_tests/api/service/test_bot_service.py
#	tests/unit_tests/provider/runners/test_difysvapi_runner.py
#	tests/unit_tests/utils/test_safe_regex.py
#	web/src/app/infra/entities/adapter-categories.ts
#	web/src/app/wizard/page.tsx
#	web/src/i18n/locales/en-US.ts
#	web/src/i18n/locales/ja-JP.ts
#	web/src/i18n/locales/zh-Hans.ts
#	web/tests/e2e/plugin-page-auth.spec.ts
This commit is contained in:
Hyu
2026-08-31 17:17:47 +08:00
67 changed files with 3604 additions and 255 deletions
@@ -20,6 +20,7 @@ export function BotLogListComponent({
autoExpandImages = false,
hideDetailedLogsLink = false,
hideToolbar = false,
onMessageReceived,
}: {
botId: string;
/** When true, log entries with images are rendered expanded by default */
@@ -28,6 +29,8 @@ export function BotLogListComponent({
hideDetailedLogsLink?: boolean;
/** When true, hides the entire toolbar (auto-refresh, level filter, detailed logs link) */
hideToolbar?: boolean;
/** Called after an inbound person/group message appears in the bot log. */
onMessageReceived?: () => void;
}) {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -41,6 +44,8 @@ export function BotLogListComponent({
]);
const listContainerRef = useRef<HTMLDivElement>(null);
const botLogListRef = useRef<BotLog[]>(botLogList);
const onMessageReceivedRef = useRef(onMessageReceived);
onMessageReceivedRef.current = onMessageReceived;
const logLevels = [
{ value: 'error', label: 'ERROR' },
@@ -108,6 +113,9 @@ export function BotLogListComponent({
manager.subscribeLogPush(handleBotLogPush);
manager.loadFirstPage().then((response) => {
setBotLogList(response.reverse());
if (response.some((log) => Boolean(log.message_session_id))) {
onMessageReceivedRef.current?.();
}
});
listenScroll();
}
@@ -138,6 +146,9 @@ export function BotLogListComponent({
function handleBotLogPush(response: BotLog[]) {
setBotLogList(response.reverse());
if (response.some((log) => Boolean(log.message_session_id))) {
onMessageReceivedRef.current?.();
}
}
const handleScroll = useCallback(
@@ -15,7 +15,10 @@ import {
FormMessage,
} from '@/components/ui/form';
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
import { normalizeDynamicFormValuesForSave } from '@/app/home/components/dynamic-form/DynamicFormSaveValues';
import {
normalizeDynamicFormFieldValue,
normalizeDynamicFormValuesForSave,
} from '@/app/home/components/dynamic-form/DynamicFormSaveValues';
import QrCodeLoginDialog, {
QrLoginPlatform,
} from '@/app/home/components/qrcode-login/QrCodeLoginDialog';
@@ -475,61 +478,6 @@ export default function DynamicFormComponent({
const previousInitialValues = useRef(initialValues);
const { t, i18n } = useTranslation();
// Normalize a form value according to its field type.
// This ensures legacy/malformed data (e.g. a plain string for
// model-fallback-selector) is coerced to the expected shape
// so that downstream components never crash.
const normalizeFieldValue = (
item: DynamicFormValueSpec,
value: unknown,
): unknown => {
if (
item.name === 'mcp-resources' ||
item.type === DynamicFormItemType.RESOURCES_SELECTOR ||
item.type === DynamicFormItemType.RICH_TOOLS_SELECTOR
) {
return Array.isArray(value) ? value : [];
}
if (item.type === 'model-fallback-selector') {
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
const obj = value as Record<string, unknown>;
return {
primary: typeof obj.primary === 'string' ? obj.primary : '',
fallbacks: Array.isArray(obj.fallbacks)
? (obj.fallbacks as unknown[]).filter(
(v): v is string => typeof v === 'string',
)
: [],
reasoning:
obj.reasoning != null &&
typeof obj.reasoning === 'object' &&
!Array.isArray(obj.reasoning)
? Object.fromEntries(
Object.entries(obj.reasoning).filter(
(entry): entry is [string, string] =>
typeof entry[1] === 'string',
),
)
: {},
};
}
// Legacy string format or any other unexpected type
return {
primary: typeof value === 'string' ? value : '',
fallbacks: [],
reasoning: {},
};
}
if (item.type === 'prompt-editor') {
if (Array.isArray(value)) {
return value;
}
// Default to a single empty system prompt entry
return [{ role: 'system', content: '' }];
}
return value;
};
// Filter out display-only fields (webhook-url/embed-code/qr-code-login types
// and `__system.*`-named fields) that should not participate in form state,
// validation, or value emission.
@@ -585,7 +533,7 @@ export default function DynamicFormComponent({
const rawValue = initialValues?.[item.name] ?? item.default;
return {
...acc,
[item.name]: normalizeFieldValue(item, rawValue),
[item.name]: normalizeDynamicFormFieldValue(item, rawValue),
};
}, {} as FormValues),
});
@@ -622,7 +570,10 @@ export default function DynamicFormComponent({
const mergedValues = editableValueSpecs.reduce(
(acc, item) => {
const rawValue = initialValues[item.name] ?? item.default;
acc[item.name] = normalizeFieldValue(item, rawValue) as object;
acc[item.name] = normalizeDynamicFormFieldValue(
item,
rawValue,
) as object;
return acc;
},
{} as Record<string, object>,
@@ -5,6 +5,80 @@ export type DynamicFormSaveValueSpec = Pick<
'default' | 'name' | 'type'
>;
const ARRAY_FIELD_TYPES = new Set([
'array[string]',
'array[file]',
'knowledge-base-multi-selector',
'resources-selector',
'rich-tools-selector',
'tools-selector',
]);
const STRING_FIELD_TYPES = new Set([
'string',
'text',
'select',
'llm-model-selector',
'embedding-model-selector',
'rerank-model-selector',
'knowledge-base-selector',
'bot-selector',
]);
/**
* Coerce empty dynamic-form defaults into controlled React values.
* Metadata from older adapters and runners can omit `default`; inputs must
* still receive a stable value from their first render.
*/
export function normalizeDynamicFormFieldValue(
spec: DynamicFormSaveValueSpec,
value: unknown,
): unknown {
if (spec.name === 'mcp-resources' || ARRAY_FIELD_TYPES.has(spec.type)) {
return Array.isArray(value) ? value : [];
}
if (spec.type === 'boolean') {
return typeof value === 'boolean' ? value : false;
}
if (STRING_FIELD_TYPES.has(spec.type)) {
return typeof value === 'string' ? value : '';
}
if (spec.type === 'model-fallback-selector') {
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
const objectValue = value as Record<string, unknown>;
return {
primary:
typeof objectValue.primary === 'string' ? objectValue.primary : '',
fallbacks: Array.isArray(objectValue.fallbacks)
? objectValue.fallbacks.filter(
(fallback): fallback is string => typeof fallback === 'string',
)
: [],
reasoning:
objectValue.reasoning != null &&
typeof objectValue.reasoning === 'object' &&
!Array.isArray(objectValue.reasoning)
? Object.fromEntries(
Object.entries(objectValue.reasoning).filter(
(entry): entry is [string, string] =>
typeof entry[1] === 'string',
),
)
: {},
};
}
return {
primary: typeof value === 'string' ? value : '',
fallbacks: [],
reasoning: {},
};
}
if (spec.type === 'prompt-editor') {
return Array.isArray(value) ? value : [{ role: 'system', content: '' }];
}
return value;
}
const reasoningLevels = new Set([
'disabled',
'enabled',
@@ -33,7 +33,7 @@ const getFormSchema = (t: (key: string) => string) =>
interface ProviderFormProps {
providerId?: string;
onFormSubmit: () => void;
onFormSubmit: (providerUuid: string) => void | Promise<void>;
onFormCancel: () => void;
}
@@ -171,14 +171,16 @@ export default function ProviderForm({
};
try {
let savedProviderUuid = providerId;
if (providerId) {
await httpClient.updateModelProvider(providerId, data);
toast.success(t('models.providerSaved'));
} else {
await httpClient.createModelProvider(data);
const response = await httpClient.createModelProvider(data);
savedProviderUuid = response.uuid;
toast.success(t('models.providerCreated'));
}
onFormSubmit();
await onFormSubmit(savedProviderUuid as string);
} catch (err) {
toast.error(t('models.providerSaveError') + (err as CustomApiError).msg);
}
@@ -20,6 +20,7 @@ import {
TrendingUp,
} from 'lucide-react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { getErrorMessage } from '../utils';
interface TokenSummary {
total_calls: number;
@@ -152,7 +153,7 @@ export default function TokenMonitoring({
});
setStats(result);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setError(getErrorMessage(e));
} finally {
setLoading(false);
}
+12
View File
@@ -0,0 +1,12 @@
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (
typeof error === 'object' &&
error !== null &&
'msg' in error &&
typeof error.msg === 'string'
) {
return error.msg;
}
return String(error);
}
+2
View File
@@ -456,7 +456,9 @@ export interface WizardProgress {
selected_scenario?: string | null;
selected_adapter: string | null;
created_bot_uuid: string | null;
created_pipeline_uuid?: string | null;
bot_saved: boolean;
message_received?: boolean;
selected_runner: string | null;
}
+46 -33
View File
@@ -157,7 +157,9 @@ export class BackendClient extends BaseHttpClient {
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);
}
@@ -542,6 +544,15 @@ export class BackendClient extends BaseHttpClient {
return this.post(`/api/v1/platform/bots/${botId}/logs`, request);
}
public testHttpBotInbound(
botId: string,
message: string,
): Promise<{ session_id: string; accepted_message_id: string }> {
return this.post(`/api/v1/platform/bots/${botId}/test-inbound`, {
message,
});
}
public getBotSessions(
botId: string,
limit: number = 100,
@@ -791,11 +802,32 @@ export class BackendClient extends BaseHttpClient {
);
}
private async getAuthenticatedObjectURL(path: string): Promise<string> {
private async getAuthenticatedObjectURL(
path: string,
rewritePluginPageSdk = false,
): Promise<string> {
const response = await this.instance.get<Blob>(path, {
responseType: 'blob',
});
return URL.createObjectURL(response.data);
let blob = response.data;
if (rewritePluginPageSdk && blob.type.startsWith('text/html')) {
const apiBase =
this.instance.defaults.baseURL === '/'
? window.location.origin
: this.instance.defaults.baseURL?.replace(/\/$/, '');
const pageSdkUrl = `${apiBase}/api/v1/plugins/_sdk/page-sdk.js`;
const html = await blob.text();
blob = new Blob(
[
html.replace(
/(<script\b[^>]*\bsrc\s*=\s*)(["'])\/api\/v1\/plugins\/_sdk\/page-sdk\.js\2/gi,
`$1$2${pageSdkUrl}$2`,
),
],
{ type: blob.type },
);
}
return URL.createObjectURL(blob);
}
public getAuthenticatedPluginAssetURL(
@@ -803,37 +835,9 @@ export class BackendClient extends BaseHttpClient {
name: string,
filepath: string,
): Promise<string> {
return this.getAuthenticatedPluginPageURL(
return this.getAuthenticatedObjectURL(
`/api/v1/plugins/${author}/${name}/authenticated-assets/${filepath}`,
);
}
private async getAuthenticatedPluginPageURL(path: string): Promise<string> {
const response = await this.instance.get<Blob>(path, {
responseType: 'blob',
});
if (!response.data.type.startsWith('text/html')) {
return URL.createObjectURL(response.data);
}
const html = await response.data.text();
const pageSdkPattern =
/<script\b[^>]*\bsrc=(['"])\/api\/v1\/plugins\/_sdk\/page-sdk\.js\1[^>]*>\s*<\/script>/i;
if (!pageSdkPattern.test(html)) {
return URL.createObjectURL(response.data);
}
const sdkResponse = await this.instance.get<string>(
'/api/v1/plugins/_sdk/page-sdk.js',
{ responseType: 'text' },
);
const inlineSdk = sdkResponse.data.replace(/<\/script/gi, '<\\/script');
const hydratedHtml = html.replace(
pageSdkPattern,
`<script>${inlineSdk}</script>`,
);
return URL.createObjectURL(
new Blob([hydratedHtml], { type: response.data.type }),
true,
);
}
@@ -1163,12 +1167,21 @@ export class BackendClient extends BaseHttpClient {
selected_scenario?: string | null;
selected_adapter: string | null;
created_bot_uuid: string | null;
created_pipeline_uuid?: string | null;
bot_saved: boolean;
message_received?: boolean;
selected_runner: string | null;
}): Promise<void> {
return this.put('/api/v1/system/wizard/progress', progress);
}
public getWizardRecommendedModel(): Promise<{
uuid: string;
name: string;
}> {
return this.get('/api/v1/system/wizard/recommended-model');
}
public getAsyncTasks(params?: {
type?: string;
kind?: string;
@@ -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-4xl space-y-6">
<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>
<div>
<Button variant="ghost" size="sm" onClick={handleBack}>
<ArrowLeft className="mr-1.5 size-4" />
{t('wizard.aiEngine.backToChoices')}
</Button>
</div>
{showProviderForm ? (
<Card className="mx-auto w-full max-w-3xl">
<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="mx-auto w-full max-w-3xl 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>
);
}
+257 -7
View File
@@ -6,6 +6,7 @@ import { toast } from 'sonner';
import {
ArrowLeft,
ArrowRight,
AlertTriangle,
Check,
ChevronDown,
ChevronRight,
@@ -21,6 +22,9 @@ import {
Download,
RefreshCw,
CircleAlert,
Copy,
Send,
Webhook,
} from 'lucide-react';
import { httpClient } from '@/app/infra/http/HttpClient';
@@ -30,6 +34,7 @@ import {
initializeSystemInfo,
getCloudServiceClient,
getCloudServiceClientSync,
userInfo,
} from '@/app/infra/http';
import {
Adapter,
@@ -60,8 +65,13 @@ import {
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
import i18n from 'i18next';
import { PluginV4 } from '@/app/infra/entities/plugin';
import {
ensureHttpBotSigningSecret,
isWebhookModeEnabled,
} from '@/app/wizard/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Card,
CardContent,
@@ -218,6 +228,7 @@ export default function WizardPage() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSavingBot, setIsSavingBot] = useState(false);
const [botSaved, setBotSaved] = useState(false);
const [messageReceived, setMessageReceived] = useState(false);
const loadRunnerCatalog = useCallback(async () => {
setIsRunnerCatalogLoading(true);
@@ -323,6 +334,7 @@ export default function WizardPage() {
? overrides.created_bot_uuid
: createdBotUuid,
bot_saved: overrides.bot_saved ?? botSaved,
message_received: overrides.message_received ?? messageReceived,
selected_runner:
overrides.selected_runner !== undefined
? overrides.selected_runner
@@ -338,6 +350,7 @@ export default function WizardPage() {
selectedAdapter,
createdBotUuid,
botSaved,
messageReceived,
selectedRunner,
],
);
@@ -375,17 +388,33 @@ export default function WizardPage() {
const botData = await httpClient.getBot(progress.created_bot_uuid);
if (cancelled) return;
setSelectedAdapter(progress.selected_adapter);
const restoredAdapter =
progress.selected_adapter ?? botData.bot.adapter;
const restoredConfig = (botData.bot.adapter_config ?? {}) as Record<
string,
unknown
>;
const configToRestore = ensureHttpBotSigningSecret(
restoredAdapter,
restoredConfig,
);
const configNeedsSave = configToRestore !== restoredConfig;
setSelectedAdapter(restoredAdapter);
setSelectedScenario(
(progress.selected_scenario as WizardScenarioId | null) ??
'message_reply',
);
setCreatedBotUuid(progress.created_bot_uuid);
setBotSaved(progress.bot_saved ?? false);
setBotSaved(
configNeedsSave ? false : (progress.bot_saved ?? false),
);
setMessageReceived(progress.message_received ?? false);
setSelectedRunner(progress.selected_runner);
// Restore bot name from fetched bot data
setBotName(botData.bot.name);
setAdapterConfig(configToRestore);
// Restore webhook URLs
const runtimeValues = botData.bot.adapter_runtime_values as
@@ -407,6 +436,7 @@ export default function WizardPage() {
selected_adapter: null,
created_bot_uuid: null,
bot_saved: false,
message_received: false,
selected_runner: null,
})
.catch(() => {});
@@ -609,7 +639,11 @@ export default function WizardPage() {
case 0:
return selectedScenario !== null && selectedAdapter !== null;
case 1:
return createdBotUuid !== null && botSaved;
return (
createdBotUuid !== null &&
botSaved &&
(selectedScenario !== 'message_reply' || messageReceived)
);
case 2:
return selectedRunner !== null;
default:
@@ -621,6 +655,7 @@ export default function WizardPage() {
selectedAdapter,
createdBotUuid,
botSaved,
messageReceived,
selectedRunner,
]);
@@ -676,12 +711,17 @@ export default function WizardPage() {
const defaultConfig = adapter
? getDefaultValues(adapter.spec.config)
: {};
const initialConfig = ensureHttpBotSigningSecret(
selectedAdapter,
defaultConfig,
);
setAdapterConfig(initialConfig);
const bot: Bot = {
name: defaultName,
description: '',
adapter: selectedAdapter,
adapter_config: defaultConfig,
adapter_config: initialConfig,
enable: false,
};
const resp = await httpClient.createBot(bot);
@@ -711,6 +751,7 @@ export default function WizardPage() {
selected_adapter: selectedAdapter,
created_bot_uuid: resp.uuid,
bot_saved: false,
message_received: false,
selected_runner: null,
});
} catch (err) {
@@ -731,14 +772,21 @@ export default function WizardPage() {
setIsSavingBot(true);
try {
const configToSave = ensureHttpBotSigningSecret(
selectedAdapter,
adapterConfig,
);
setAdapterConfig(configToSave);
await httpClient.updateBot(createdBotUuid, {
name: botName,
description: botDescription || '',
adapter: selectedAdapter,
adapter_config: adapterConfig,
adapter_config: configToSave,
enable: true,
});
setBotSaved(true);
setMessageReceived(false);
// Re-fetch runtime info to get updated webhook URL(s)
try {
@@ -755,7 +803,7 @@ export default function WizardPage() {
}
// Persist progress
saveProgress({ step: 1, bot_saved: true });
saveProgress({ step: 1, bot_saved: true, message_received: false });
} catch (err) {
const apiErr = err as { msg?: string };
toast.error(
@@ -774,6 +822,12 @@ export default function WizardPage() {
saveProgress,
]);
const handleMessageReceived = useCallback(() => {
if (messageReceived) return;
setMessageReceived(true);
saveProgress({ step: 1, message_received: true });
}, [messageReceived, saveProgress]);
// ---- Create Pipeline & Link (Step 2 finish) ----
const handleFinish = useCallback(async () => {
@@ -1041,6 +1095,9 @@ export default function WizardPage() {
createdBotUuid={createdBotUuid}
isSavingBot={isSavingBot}
botSaved={botSaved}
messageReceived={messageReceived}
requiresMessageVerification={selectedScenario === 'message_reply'}
onMessageReceived={handleMessageReceived}
onSaveBot={handleSaveBot}
webhookUrl={webhookUrl}
extraWebhookUrl={extraWebhookUrl}
@@ -1408,6 +1465,38 @@ function StepPlatform({
// Step 1: Bot Configuration + Logs
// ---------------------------------------------------------------------------
function PageBotFloatingWidget({
botUuid,
title,
testNotice,
}: {
botUuid: string;
title?: string;
testNotice: string;
}) {
useEffect(() => {
const script = document.createElement('script');
script.src = `${window.location.origin}/api/v1/embed/${botUuid}/widget.js?preview=wizard&v=${Date.now()}`;
script.dataset.title = title || 'LangBot';
script.dataset.testNotice = testNotice;
document.body.appendChild(script);
return () => {
script.remove();
const root = document.getElementById('langbot-widget-root') as
| (HTMLElement & { langbotDestroy?: () => void })
| null;
if (root?.langbotDestroy) {
root.langbotDestroy();
} else {
root?.remove();
}
};
}, [botUuid, testNotice, title]);
return null;
}
function StepBotConfig({
adapterConfigItems,
adapterConfigValues,
@@ -1417,6 +1506,9 @@ function StepBotConfig({
createdBotUuid,
isSavingBot,
botSaved,
messageReceived,
requiresMessageVerification,
onMessageReceived,
onSaveBot,
webhookUrl,
extraWebhookUrl,
@@ -1429,17 +1521,35 @@ function StepBotConfig({
createdBotUuid: string | null;
isSavingBot: boolean;
botSaved: boolean;
messageReceived: boolean;
requiresMessageVerification: boolean;
onMessageReceived: () => void;
onSaveBot: () => void;
webhookUrl: string;
extraWebhookUrl: string;
}) {
const { t } = useTranslation();
const [testMessage, setTestMessage] = useState(
t('wizard.botConfig.httpTestDefaultMessage'),
);
const [isSendingTest, setIsSendingTest] = useState(false);
const adapterLabel = useMemo(() => {
const a = adapters.find((ad) => ad.name === selectedAdapterName);
return a ? extractI18nObject(a.label) : (selectedAdapterName ?? '');
}, [adapters, selectedAdapterName]);
const webhookModeEnabled = useMemo(
() =>
isWebhookModeEnabled(adapterConfigItems, adapterConfigValues) &&
Boolean(webhookUrl),
[adapterConfigItems, adapterConfigValues, webhookUrl],
);
const receivedMessageWithoutLangBotAccount =
messageReceived && userInfo?.account_type !== 'space';
const receivedMessageSuccessfully =
messageReceived && !receivedMessageWithoutLangBotAccount;
// Stable callback ref
const onAdapterConfigRef = useRef(onAdapterConfigChange);
onAdapterConfigRef.current = onAdapterConfigChange;
@@ -1448,8 +1558,43 @@ function StepBotConfig({
[],
);
const copyWebhookUrl = useCallback(async () => {
if (!webhookUrl) return;
await navigator.clipboard.writeText(webhookUrl);
toast.success(t('common.copySuccess'));
}, [t, webhookUrl]);
const sendHttpBotTest = useCallback(async () => {
if (!createdBotUuid || !testMessage.trim()) return;
setIsSendingTest(true);
try {
await httpClient.testHttpBotInbound(createdBotUuid, testMessage.trim());
toast.success(t('wizard.botConfig.httpTestAccepted'));
} catch (error) {
toast.error(
t('wizard.botConfig.httpTestFailed', {
error: getErrorMessage(error),
}),
);
} finally {
setIsSendingTest(false);
}
}, [createdBotUuid, testMessage, t]);
return (
<div className="max-w-5xl mx-auto space-y-6">
{selectedAdapterName === 'web_page_bot' && botSaved && createdBotUuid && (
<PageBotFloatingWidget
botUuid={createdBotUuid}
title={
typeof adapterConfigValues.title === 'string'
? adapterConfigValues.title
: undefined
}
testNotice={t('wizard.botConfig.pageBotTestNotice')}
/>
)}
<div className="text-center">
<h2 className="text-xl font-semibold">{t('wizard.botConfig.title')}</h2>
<p className="text-sm text-muted-foreground mt-1">
@@ -1457,6 +1602,110 @@ function StepBotConfig({
</p>
</div>
{botSaved && requiresMessageVerification && (
<div
className={cn(
'border px-4 py-3',
receivedMessageSuccessfully
? 'border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/30'
: 'border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30',
)}
>
<div className="flex items-start gap-3">
<div
className={cn(
'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full',
receivedMessageSuccessfully ? 'bg-green-500' : 'bg-amber-500',
)}
>
{receivedMessageWithoutLangBotAccount ? (
<AlertTriangle className="size-3 text-white" />
) : messageReceived ? (
<Check className="size-3 text-white" />
) : selectedAdapterName === 'web_page_bot' ? (
<MessageSquare className="size-3 text-white" />
) : selectedAdapterName === 'http_bot' ? (
<Send className="size-3 text-white" />
) : webhookModeEnabled ? (
<Webhook className="size-3 text-white" />
) : (
<Loader2 className="size-3 animate-spin text-white" />
)}
</div>
<div className="min-w-0 flex-1">
<p
className={cn(
'text-sm font-medium',
receivedMessageSuccessfully
? 'text-green-800 dark:text-green-200'
: 'text-amber-800 dark:text-amber-200',
)}
>
{messageReceived
? t(
receivedMessageWithoutLangBotAccount
? 'wizard.botConfig.messageReceivedLocalAccountWarning'
: 'wizard.botConfig.messageReceived',
)
: selectedAdapterName === 'web_page_bot'
? t('wizard.botConfig.pageBotTestPrompt')
: selectedAdapterName === 'http_bot'
? t('wizard.botConfig.httpTestPrompt')
: webhookModeEnabled
? t('wizard.botConfig.webhookTestPrompt')
: t('wizard.botConfig.waitingForMessage')}
</p>
{!messageReceived && webhookModeEnabled && (
<div className="mt-3 space-y-3">
<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">
{webhookUrl}
</code>
<Button
type="button"
variant="outline"
size="icon"
className="size-9 shrink-0"
onClick={copyWebhookUrl}
title={t('common.copy')}
>
<Copy className="size-4" />
</Button>
</div>
{selectedAdapterName === 'http_bot' && (
<div className="flex flex-col gap-2 sm:flex-row">
<Input
value={testMessage}
onChange={(event) => setTestMessage(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void sendHttpBotTest();
}}
className="bg-background"
/>
<Button
type="button"
onClick={() => void sendHttpBotTest()}
disabled={isSendingTest || !testMessage.trim()}
className="shrink-0"
>
{isSendingTest ? (
<Loader2 className="mr-1.5 size-4 animate-spin" />
) : (
<Send className="mr-1.5 size-4" />
)}
{t('wizard.botConfig.sendHttpTest')}
</Button>
</div>
)}
</div>
)}
</div>
</div>
</div>
)}
<div className="grid gap-6 grid-cols-1 lg:grid-cols-2">
{/* Left column: Adapter config form */}
<div className="space-y-4">
@@ -1522,7 +1771,7 @@ function StepBotConfig({
)}
{/* Bot saved indicator */}
{botSaved && (
{botSaved && !requiresMessageVerification && (
<div className="flex items-center gap-2 px-4 py-3 rounded-lg border border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/30">
<div className="w-5 h-5 rounded-full bg-green-500 flex items-center justify-center shrink-0">
<Check className="w-3 h-3 text-white" />
@@ -1548,6 +1797,7 @@ function StepBotConfig({
botId={createdBotUuid}
autoExpandImages
hideToolbar
onMessageReceived={onMessageReceived}
/>
</CardContent>
</Card>
+137
View File
@@ -0,0 +1,137 @@
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === 'object' && error !== null && 'msg' in error) {
const message = (error as { msg?: unknown }).msg;
if (typeof message === 'string') return message;
}
return String(error);
}
function createSigningSecret(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(
'',
);
}
export function ensureHttpBotSigningSecret(
adapterName: string,
config: Record<string, unknown>,
): Record<string, unknown> {
if (
adapterName !== 'http_bot' ||
config.signature_required === false ||
(typeof config.inbound_secret === 'string' && config.inbound_secret)
) {
return config;
}
return {
...config,
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)
);
}
interface RequiredConfigItem {
name: string;
required: boolean;
default: unknown;
}
function isPlaceholderDefault(value: string, defaultValue: unknown): boolean {
if (typeof defaultValue !== 'string' || value !== defaultValue.trim()) {
return false;
}
return /(^|:\/\/)your-/i.test(value);
}
export function isRequiredRunnerConfigComplete(
configItems: RequiredConfigItem[],
configValues: Record<string, unknown>,
): boolean {
return configItems
.filter((item) => item.required)
.every((item) => {
const value = configValues[item.name];
if (typeof value === 'string') {
const normalizedValue = value.trim();
return (
normalizedValue.length > 0 &&
!isPlaceholderDefault(normalizedValue, item.default)
);
}
if (Array.isArray(value)) return value.length > 0;
return value !== undefined && value !== null;
});
}
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
: [],
},
},
},
};
}