mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-23 08:55:44 +08:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user