mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-11 00:06:04 +00:00
Merge remote-tracking branch 'langbot-app/master' into feat/sandbox
Resolve conflicts in: - .github/workflows/run-tests.yml: keep master's src/langbot/** paths plus feat/** push branch - src/langbot/pkg/plugin/connector.py: keep both branches' marketplace MCP/skill install logic (HEAD) and runtime/wait helpers (master); add missing return in _inspect_plugin_package so LOCAL/GITHUB install paths get author/name back - tests/unit_tests/pipeline/test_n8nsvapi.py: keep HEAD's try/finally sys.modules save/restore pattern - web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx: union imports + keep HEAD's disable_if/tooltip support and master's QrCodeLoginDialog - web/src/i18n/locales/*: union of disjoint top-level keys from both branches - web/src/app/home/market/page.tsx: accept our deletion (unified extensions page) - uv.lock: regenerate via uv sync --dev
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useCallback, Suspense } from 'react';
|
||||
import { useEffect, useState, useCallback, Suspense, useRef } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { toast } from 'sonner';
|
||||
@@ -20,10 +20,39 @@ import { Button } from '@/components/ui/button';
|
||||
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import langbotIcon from '@/app/assets/langbot-logo.webp';
|
||||
|
||||
type SpaceOAuthLoginResult = {
|
||||
token: string;
|
||||
user: string;
|
||||
};
|
||||
|
||||
const pendingSpaceOAuthLogins = new Map<
|
||||
string,
|
||||
Promise<SpaceOAuthLoginResult>
|
||||
>();
|
||||
|
||||
function getOrCreateSpaceOAuthLoginPromise(
|
||||
authCode: string,
|
||||
): Promise<SpaceOAuthLoginResult> {
|
||||
const pendingRequest = pendingSpaceOAuthLogins.get(authCode);
|
||||
if (pendingRequest) {
|
||||
return pendingRequest;
|
||||
}
|
||||
|
||||
const requestPromise = httpClient
|
||||
.exchangeSpaceOAuthCode(authCode)
|
||||
.finally(() => {
|
||||
pendingSpaceOAuthLogins.delete(authCode);
|
||||
});
|
||||
|
||||
pendingSpaceOAuthLogins.set(authCode, requestPromise);
|
||||
return requestPromise;
|
||||
}
|
||||
|
||||
function SpaceOAuthCallbackContent() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { t } = useTranslation();
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
const [status, setStatus] = useState<
|
||||
'loading' | 'confirm' | 'success' | 'error'
|
||||
@@ -37,7 +66,11 @@ function SpaceOAuthCallbackContent() {
|
||||
const handleOAuthCallback = useCallback(
|
||||
async (authCode: string) => {
|
||||
try {
|
||||
const response = await httpClient.exchangeSpaceOAuthCode(authCode);
|
||||
const response = await getOrCreateSpaceOAuthLoginPromise(authCode);
|
||||
if (!isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('token', response.token);
|
||||
if (response.user) {
|
||||
localStorage.setItem('userEmail', response.user);
|
||||
@@ -52,6 +85,10 @@ function SpaceOAuthCallbackContent() {
|
||||
navigate(redirectTo);
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
if (!isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('error');
|
||||
const errorObj = err as { msg?: string };
|
||||
const errMsg = (errorObj?.msg || '').toLowerCase();
|
||||
@@ -72,6 +109,10 @@ function SpaceOAuthCallbackContent() {
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const response = await httpClient.bindSpaceAccount(authCode, state);
|
||||
if (!isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('token', response.token);
|
||||
if (response.user) {
|
||||
localStorage.setItem('userEmail', response.user);
|
||||
@@ -82,6 +123,10 @@ function SpaceOAuthCallbackContent() {
|
||||
navigate('/home');
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
if (!isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('error');
|
||||
const errorObj = err as { msg?: string };
|
||||
const errMsg = (errorObj?.msg || '').toLowerCase();
|
||||
@@ -91,13 +136,17 @@ function SpaceOAuthCallbackContent() {
|
||||
setErrorMessage(t('account.bindSpaceFailed'));
|
||||
}
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
if (isMountedRef.current) {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[navigate, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
const authCode = searchParams.get('code');
|
||||
const error = searchParams.get('error');
|
||||
const errorDescription = searchParams.get('error_description');
|
||||
@@ -135,6 +184,9 @@ function SpaceOAuthCallbackContent() {
|
||||
// Normal login/register mode
|
||||
handleOAuthCallback(authCode);
|
||||
}
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, [searchParams, handleOAuthCallback, t]);
|
||||
|
||||
const handleConfirmBind = () => {
|
||||
|
||||
@@ -267,6 +267,7 @@ export default function BotForm({
|
||||
type: parseDynamicFormItemType(item.type),
|
||||
options: item.options,
|
||||
show_if: item.show_if,
|
||||
login_platform: item.login_platform,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -11,13 +11,16 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
|
||||
import QrCodeLoginDialog, {
|
||||
QrLoginPlatform,
|
||||
} from '@/app/home/components/qrcode-login/QrCodeLoginDialog';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Copy, Check, Globe, Info } from 'lucide-react';
|
||||
import { Copy, Check, Globe, Info, QrCode } from 'lucide-react';
|
||||
import { copyToClipboard } from '@/app/utils/clipboard';
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -203,6 +206,7 @@ export default function DynamicFormComponent({
|
||||
isEditing,
|
||||
externalDependentValues,
|
||||
systemContext,
|
||||
onValidate,
|
||||
}: {
|
||||
itemConfigList: IDynamicFormItemSchema[];
|
||||
onSubmit?: (val: object) => unknown;
|
||||
@@ -213,6 +217,9 @@ export default function DynamicFormComponent({
|
||||
/** Extra variables accessible via the `__system.*` namespace in show_if conditions.
|
||||
* e.g. `{ is_wizard: true }` makes `show_if: { field: "__system.is_wizard", ... }` work. */
|
||||
systemContext?: Record<string, unknown>;
|
||||
/** Callback to expose validation function to parent component.
|
||||
* Parent can call this function to trigger validation and get validity state. */
|
||||
onValidate?: (validateFn: () => Promise<boolean>) => void;
|
||||
}) {
|
||||
const isInitialMount = useRef(true);
|
||||
const previousInitialValues = useRef(initialValues);
|
||||
@@ -259,7 +266,10 @@ export default function DynamicFormComponent({
|
||||
const editableItems = useMemo(
|
||||
() =>
|
||||
itemConfigList.filter(
|
||||
(item) => item.type !== 'webhook-url' && item.type !== 'embed-code',
|
||||
(item) =>
|
||||
item.type !== 'webhook-url' &&
|
||||
item.type !== 'embed-code' &&
|
||||
item.type !== 'qr-code-login',
|
||||
),
|
||||
[itemConfigList],
|
||||
);
|
||||
@@ -360,6 +370,17 @@ export default function DynamicFormComponent({
|
||||
}, {} as FormValues),
|
||||
});
|
||||
|
||||
// Expose validation function to parent component
|
||||
const validate = async (): Promise<boolean> => {
|
||||
// Trigger validation for all fields
|
||||
const result = await form.trigger();
|
||||
return result;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onValidate?.(validate);
|
||||
}, [onValidate]);
|
||||
|
||||
// 当 initialValues 变化时更新表单值
|
||||
// 但要避免因为内部表单更新触发的 onSubmit 导致的 initialValues 变化而重新设置表单
|
||||
useEffect(() => {
|
||||
@@ -442,9 +463,28 @@ export default function DynamicFormComponent({
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, editableItems]);
|
||||
|
||||
// State for QR code login dialog
|
||||
const [qrDialogOpen, setQrDialogOpen] = useState(false);
|
||||
const [qrDialogPlatform, setQrDialogPlatform] =
|
||||
useState<QrLoginPlatform>('feishu');
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<div className="min-w-0 max-w-full space-y-4 overflow-x-hidden">
|
||||
{/* QR code login dialog */}
|
||||
<QrCodeLoginDialog
|
||||
open={qrDialogOpen}
|
||||
onOpenChange={setQrDialogOpen}
|
||||
platform={qrDialogPlatform}
|
||||
onSuccess={(credentials) => {
|
||||
for (const [key, value] of Object.entries(credentials)) {
|
||||
if (value) {
|
||||
form.setValue(key as keyof FormValues, value as never);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{itemConfigList.map((config) => {
|
||||
if (config.show_if) {
|
||||
const dependValue = resolveShowIfValue(
|
||||
@@ -576,6 +616,66 @@ export default function DynamicFormComponent({
|
||||
);
|
||||
}
|
||||
|
||||
// QR code login button (e.g. Feishu one-click create, WeChat scan login)
|
||||
if (config.type === 'qr-code-login') {
|
||||
return (
|
||||
<FormItem key={config.id}>
|
||||
<div
|
||||
className="relative flex items-center gap-4 p-4 rounded-xl border-2 border-dashed cursor-pointer transition-all hover:border-solid hover:shadow-md group"
|
||||
style={{
|
||||
borderColor:
|
||||
'color-mix(in srgb, var(--primary) 25%, transparent)',
|
||||
background:
|
||||
'color-mix(in srgb, var(--primary) 3%, transparent)',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!isEditing) {
|
||||
setQrDialogPlatform(
|
||||
(config.login_platform as QrLoginPlatform) || 'feishu',
|
||||
);
|
||||
setQrDialogOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-center h-12 w-12 rounded-lg bg-primary/10 shrink-0">
|
||||
<QrCode className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{extractI18nObject(config.label)}
|
||||
</span>
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded bg-primary text-primary-foreground">
|
||||
{t('common.recommend')}
|
||||
</span>
|
||||
</div>
|
||||
{config.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
{extractI18nObject(config.description)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!!isEditing}
|
||||
className="shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setQrDialogPlatform(
|
||||
(config.login_platform as QrLoginPlatform) || 'feishu',
|
||||
);
|
||||
setQrDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<QrCode className="h-3.5 w-3.5 mr-1" />
|
||||
{t('common.start')}
|
||||
</Button>
|
||||
</div>
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
|
||||
// Boolean fields use a special inline layout
|
||||
if (config.type === 'boolean') {
|
||||
return (
|
||||
|
||||
@@ -16,6 +16,7 @@ export class DynamicFormItemConfig implements IDynamicFormItemSchema {
|
||||
description?: I18nObject;
|
||||
options?: IDynamicFormItemOption[];
|
||||
show_if?: IShowIfCondition;
|
||||
login_platform?: string;
|
||||
|
||||
constructor(params: IDynamicFormItemSchema) {
|
||||
this.id = params.id;
|
||||
@@ -27,6 +28,7 @@ export class DynamicFormItemConfig implements IDynamicFormItemSchema {
|
||||
this.description = params.description;
|
||||
this.options = params.options;
|
||||
this.show_if = params.show_if;
|
||||
this.login_platform = params.login_platform;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -436,7 +436,9 @@ function NavItems({
|
||||
tooltip={config.name}
|
||||
>
|
||||
{config.icon}
|
||||
<span>{config.name}</span>
|
||||
<span className="cursor-pointer select-none">
|
||||
{config.name}
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
@@ -967,7 +969,9 @@ function NavItems({
|
||||
}}
|
||||
>
|
||||
{config.icon}
|
||||
<span>{config.name}</span>
|
||||
<span className="cursor-pointer select-none">
|
||||
{config.name}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-0.5 -mr-1">
|
||||
{canCreate &&
|
||||
(isPlugin ? (
|
||||
@@ -1386,7 +1390,7 @@ function PluginPagesNav() {
|
||||
className="select-none"
|
||||
>
|
||||
{pluginIcon}
|
||||
<span>{page.name}</span>
|
||||
<span className="cursor-pointer">{page.name}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
@@ -1406,7 +1410,7 @@ function PluginPagesNav() {
|
||||
className="select-none"
|
||||
>
|
||||
{pluginIcon}
|
||||
<span>{label}</span>
|
||||
<span className="cursor-pointer">{label}</span>
|
||||
<ChevronRight className="ml-auto size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
@@ -1422,7 +1426,9 @@ function PluginPagesNav() {
|
||||
onClick={() => navigate(route)}
|
||||
className="select-none"
|
||||
>
|
||||
<span>{page.name}</span>
|
||||
<span className="cursor-pointer">
|
||||
{page.name}
|
||||
</span>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
);
|
||||
|
||||
@@ -295,7 +295,7 @@ export default function ModelsDialog({
|
||||
|
||||
async function handleScanModels(
|
||||
providerUuid: string,
|
||||
modelType: ModelType,
|
||||
modelType?: ModelType,
|
||||
): Promise<ScanModelsResult> {
|
||||
try {
|
||||
const resp = await httpClient.scanProviderModels(providerUuid, modelType);
|
||||
@@ -319,19 +319,26 @@ export default function ModelsDialog({
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
for (const item of models) {
|
||||
if (modelType === 'llm') {
|
||||
const effectiveType = item.model.type || modelType;
|
||||
if (effectiveType === 'llm') {
|
||||
await httpClient.createProviderLLMModel({
|
||||
name: item.model.name,
|
||||
provider_uuid: providerUuid,
|
||||
abilities: item.abilities,
|
||||
extra_args: {},
|
||||
} as never);
|
||||
} else {
|
||||
} else if (effectiveType === 'embedding') {
|
||||
await httpClient.createProviderEmbeddingModel({
|
||||
name: item.model.name,
|
||||
provider_uuid: providerUuid,
|
||||
extra_args: {},
|
||||
} as never);
|
||||
} else {
|
||||
await httpClient.createProviderRerankModel({
|
||||
name: item.model.name,
|
||||
provider_uuid: providerUuid,
|
||||
extra_args: {},
|
||||
} as never);
|
||||
}
|
||||
}
|
||||
setAddModelPopoverOpen(null);
|
||||
|
||||
@@ -73,10 +73,13 @@ export default function ProviderForm({
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadRequesters();
|
||||
if (providerId) {
|
||||
loadProvider(providerId);
|
||||
async function init() {
|
||||
await loadRequesters();
|
||||
if (providerId) {
|
||||
await loadProvider(providerId);
|
||||
}
|
||||
}
|
||||
init();
|
||||
}, [providerId]);
|
||||
|
||||
async function loadRequesters() {
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Wrench,
|
||||
Check,
|
||||
RefreshCw,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -33,6 +32,8 @@ import ExtraArgsEditor from './ExtraArgsEditor';
|
||||
|
||||
interface AddModelPopoverProps {
|
||||
isOpen: boolean;
|
||||
initialMode?: 'manual' | 'scan';
|
||||
trigger?: React.ReactNode;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
onAddModel: (
|
||||
@@ -41,7 +42,7 @@ interface AddModelPopoverProps {
|
||||
abilities: string[],
|
||||
extraArgs: ExtraArg[],
|
||||
) => Promise<void>;
|
||||
onScanModels: (modelType: ModelType) => Promise<ScanModelsResult>;
|
||||
onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>;
|
||||
onAddScannedModels: (
|
||||
modelType: ModelType,
|
||||
models: SelectedScannedModel[],
|
||||
@@ -60,6 +61,8 @@ interface AddModelPopoverProps {
|
||||
|
||||
export default function AddModelPopover({
|
||||
isOpen,
|
||||
initialMode = 'manual',
|
||||
trigger,
|
||||
onOpen,
|
||||
onClose,
|
||||
onAddModel,
|
||||
@@ -92,7 +95,7 @@ export default function AddModelPopover({
|
||||
const wasOpen = prevIsOpenRef.current;
|
||||
if (isOpen && !wasOpen) {
|
||||
setTab('llm');
|
||||
setMode('manual');
|
||||
setMode(initialMode);
|
||||
setName('');
|
||||
setAbilities([]);
|
||||
setExtraArgs([]);
|
||||
@@ -101,8 +104,12 @@ export default function AddModelPopover({
|
||||
setSelectedScannedModels({});
|
||||
setScanQuery('');
|
||||
onResetTestResult();
|
||||
if (initialMode === 'scan') {
|
||||
handleScan();
|
||||
}
|
||||
}
|
||||
prevIsOpenRef.current = isOpen;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen, onResetTestResult]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -122,9 +129,8 @@ export default function AddModelPopover({
|
||||
const handleScan = async () => {
|
||||
setScanLoading(true);
|
||||
try {
|
||||
const result = await onScanModels(tab);
|
||||
const result = await onScanModels(trigger ? undefined : tab);
|
||||
|
||||
// Enrich abilities from debug.response.data (e.g. features.tools.function_calling)
|
||||
const debugData = (
|
||||
result.debug?.response as { data?: Record<string, unknown>[] }
|
||||
)?.data;
|
||||
@@ -143,9 +149,9 @@ export default function AddModelPopover({
|
||||
| undefined;
|
||||
const tools = features?.tools as Record<string, unknown> | undefined;
|
||||
if (tools?.function_calling === true) {
|
||||
const abilities = new Set(model.abilities || []);
|
||||
abilities.add('func_call');
|
||||
model.abilities = [...abilities];
|
||||
const nextAbilities = new Set(model.abilities || []);
|
||||
nextAbilities.add('func_call');
|
||||
model.abilities = [...nextAbilities];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -247,305 +253,321 @@ export default function AddModelPopover({
|
||||
onOpenChange={(open) => (open ? onOpen() : onClose())}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-xs"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
{t('models.addModel')}
|
||||
</Button>
|
||||
{trigger || (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-xs"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
{t('models.addModel')}
|
||||
</Button>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[min(24rem,calc(100vw-2rem))] max-h-[70vh] overflow-y-auto overscroll-none focus:outline-none focus-visible:outline-none focus-visible:ring-0"
|
||||
style={{
|
||||
maxHeight: 'min(70vh, var(--radix-popover-content-available-height))',
|
||||
}}
|
||||
className="w-[min(24rem,calc(100vw-2rem))] max-h-[calc(100vh-8rem)] flex flex-col overflow-hidden"
|
||||
align="end"
|
||||
side="left"
|
||||
side="bottom"
|
||||
sideOffset={8}
|
||||
collisionPadding={16}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
onTouchMove={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as ModelType)}>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="llm">
|
||||
<MessageSquareText className="h-4 w-4 mr-1" />
|
||||
{t('models.chat')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="embedding">
|
||||
<Cpu className="h-4 w-4 mr-1" />
|
||||
{t('models.embedding')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="rerank">
|
||||
<ArrowUpDown className="h-4 w-4 mr-1" />
|
||||
{t('models.rerank')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) => setTab(v as ModelType)}
|
||||
className="flex flex-col min-h-0 flex-1"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
{!(trigger && initialMode === 'scan') && (
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="llm">
|
||||
<MessageSquareText className="h-4 w-4 mr-1" />
|
||||
{t('models.chat')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="embedding">
|
||||
<Cpu className="h-4 w-4 mr-1" />
|
||||
{t('models.embedding')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="rerank">
|
||||
<ArrowUpDown className="h-4 w-4 mr-1" />
|
||||
{t('models.rerank')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={mode}
|
||||
onValueChange={(v) => setMode(v as 'manual' | 'scan')}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2 mt-3">
|
||||
<TabsTrigger value="manual">{t('models.manualAdd')}</TabsTrigger>
|
||||
<TabsTrigger value="scan">{t('models.scanAdd')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="overflow-y-auto flex-1 min-h-0">
|
||||
<Tabs
|
||||
value={mode}
|
||||
onValueChange={(v) => setMode(v as 'manual' | 'scan')}
|
||||
>
|
||||
{!trigger && (
|
||||
<TabsList className="grid w-full grid-cols-2 mt-3">
|
||||
<TabsTrigger value="manual">
|
||||
{t('models.manualAdd')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="scan">{t('models.scanAdd')}</TabsTrigger>
|
||||
</TabsList>
|
||||
)}
|
||||
|
||||
<TabsContent value="manual" className="mt-3">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('models.modelName')}</Label>
|
||||
<Input
|
||||
placeholder={t('models.modelName')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{tab === 'llm' && (
|
||||
<TabsContent value="manual" className="mt-3">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('models.abilities')}</Label>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="add-vision"
|
||||
checked={abilities.includes('vision')}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleAbility('vision', checked as boolean)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="add-vision" className="text-sm">
|
||||
<Eye className="h-3 w-3 inline mr-1" />
|
||||
{t('models.visionAbility')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="add-func-call"
|
||||
checked={abilities.includes('func_call')}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleAbility('func_call', checked as boolean)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="add-func-call" className="text-sm">
|
||||
<Wrench className="h-3 w-3 inline mr-1" />
|
||||
{t('models.functionCallAbility')}
|
||||
</Label>
|
||||
<Label>{t('models.modelName')}</Label>
|
||||
<Input
|
||||
placeholder={t('models.modelName')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{tab === 'llm' && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('models.abilities')}</Label>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="add-vision"
|
||||
checked={abilities.includes('vision')}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleAbility('vision', checked as boolean)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="add-vision" className="text-sm">
|
||||
<Eye className="h-3 w-3 inline mr-1" />
|
||||
{t('models.visionAbility')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="add-func-call"
|
||||
checked={abilities.includes('func_call')}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleAbility('func_call', checked as boolean)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="add-func-call" className="text-sm">
|
||||
<Wrench className="h-3 w-3 inline mr-1" />
|
||||
{t('models.functionCallAbility')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ExtraArgsEditor
|
||||
args={extraArgs}
|
||||
onChange={setExtraArgs}
|
||||
modelType={tab}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
onClick={handleAdd}
|
||||
disabled={isSubmitting || isTesting}
|
||||
>
|
||||
{isSubmitting ? t('common.saving') : t('common.add')}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleTest}
|
||||
disabled={isSubmitting || isTesting}
|
||||
>
|
||||
{isTesting ? (
|
||||
t('common.loading')
|
||||
) : testResult?.success ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 mr-1 text-green-500" />
|
||||
{(testResult.duration / 1000).toFixed(1)}s
|
||||
</>
|
||||
) : (
|
||||
t('common.test')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="scan" className="space-y-2 mt-0 pt-0">
|
||||
{scanLoading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('models.scanModels')}...
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder={t('models.searchScannedModels')}
|
||||
value={scanQuery}
|
||||
onChange={(e) => setScanQuery(e.target.value)}
|
||||
disabled={scannedModels.length === 0}
|
||||
/>
|
||||
{selectableModels.length > 0 && (
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Checkbox
|
||||
id="scan-select-all"
|
||||
checked={allSelected}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="scan-select-all"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t('models.selectAll')}
|
||||
<span className="text-muted-foreground ml-1">
|
||||
({Object.keys(selectedScannedModels).length}/
|
||||
{selectableModels.length})
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="h-64 overflow-y-auto overscroll-contain rounded-md border"
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-3 space-y-2">
|
||||
{filteredScannedModels.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{scannedModels.length === 0
|
||||
? t('models.noScannedModels')
|
||||
: t('models.noScannedModelsMatch')}
|
||||
</p>
|
||||
) : (
|
||||
filteredScannedModels.map((model) => {
|
||||
const isSelected = Boolean(
|
||||
selectedScannedModels[model.id],
|
||||
);
|
||||
const selectedAbilities =
|
||||
selectedScannedModels[model.id]?.abilities || [];
|
||||
return (
|
||||
<div
|
||||
key={model.id}
|
||||
className="rounded-md border p-3 space-y-2"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={isSelected || model.already_added}
|
||||
disabled={model.already_added}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleScannedModel(
|
||||
model,
|
||||
checked as boolean,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium break-all">
|
||||
{model.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{model.already_added
|
||||
? t('models.alreadyAdded')
|
||||
: model.type === 'llm'
|
||||
? t('models.chat')
|
||||
: model.type === 'embedding'
|
||||
? t('models.embedding')
|
||||
: t('models.rerank')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{model.type === 'llm' &&
|
||||
isSelected &&
|
||||
!model.already_added && (
|
||||
<div className="flex gap-4 pl-7">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`scan-vision-${model.id}`}
|
||||
checked={selectedAbilities.includes(
|
||||
'vision',
|
||||
)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleScannedModelAbility(
|
||||
model.id,
|
||||
'vision',
|
||||
checked as boolean,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`scan-vision-${model.id}`}
|
||||
className="text-sm"
|
||||
>
|
||||
<Eye className="h-3 w-3 inline mr-1" />
|
||||
{t('models.visionAbility')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`scan-func-${model.id}`}
|
||||
checked={selectedAbilities.includes(
|
||||
'func_call',
|
||||
)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleScannedModelAbility(
|
||||
model.id,
|
||||
'func_call',
|
||||
checked as boolean,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`scan-func-${model.id}`}
|
||||
className="text-sm"
|
||||
>
|
||||
<Wrench className="h-3 w-3 inline mr-1" />
|
||||
{t('models.functionCallAbility')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ExtraArgsEditor
|
||||
args={extraArgs}
|
||||
onChange={setExtraArgs}
|
||||
modelType={tab}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
onClick={handleAdd}
|
||||
disabled={isSubmitting || isTesting}
|
||||
onClick={handleAddScanned}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
scanLoading ||
|
||||
Object.keys(selectedScannedModels).length === 0
|
||||
}
|
||||
>
|
||||
{isSubmitting ? t('common.saving') : t('common.add')}
|
||||
{isSubmitting
|
||||
? t('common.saving')
|
||||
: t('models.addSelectedModels')}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleTest}
|
||||
disabled={isSubmitting || isTesting}
|
||||
size="sm"
|
||||
onClick={handleScan}
|
||||
disabled={scanLoading || isSubmitting}
|
||||
>
|
||||
{isTesting ? (
|
||||
t('common.loading')
|
||||
) : testResult?.success ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 mr-1 text-green-500" />
|
||||
{(testResult.duration / 1000).toFixed(1)}s
|
||||
</>
|
||||
) : (
|
||||
t('common.test')
|
||||
)}
|
||||
<RefreshCw
|
||||
className={`h-3.5 w-3.5 ${scanLoading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="scan" className="space-y-3 mt-3">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('models.scanModelsHint')}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleScan}
|
||||
disabled={scanLoading || isSubmitting}
|
||||
>
|
||||
{scanLoading ? (
|
||||
<RefreshCw className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Search className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
{t('models.scanModels')}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
onClick={handleAddScanned}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
scanLoading ||
|
||||
Object.keys(selectedScannedModels).length === 0
|
||||
}
|
||||
>
|
||||
{isSubmitting
|
||||
? t('common.saving')
|
||||
: t('models.addSelectedModels')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('models.scannedModels')}</Label>
|
||||
<Input
|
||||
placeholder={t('models.searchScannedModels')}
|
||||
value={scanQuery}
|
||||
onChange={(e) => setScanQuery(e.target.value)}
|
||||
disabled={scannedModels.length === 0}
|
||||
/>
|
||||
{selectableModels.length > 0 && (
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Checkbox
|
||||
id="scan-select-all"
|
||||
checked={allSelected}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="scan-select-all"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t('models.selectAll')}
|
||||
<span className="text-muted-foreground ml-1">
|
||||
({Object.keys(selectedScannedModels).length}/
|
||||
{selectableModels.length})
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="h-64 overflow-y-auto overscroll-none rounded-md border"
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-3 space-y-2">
|
||||
{filteredScannedModels.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{scannedModels.length === 0
|
||||
? t('models.noScannedModels')
|
||||
: t('models.noScannedModelsMatch')}
|
||||
</p>
|
||||
) : (
|
||||
filteredScannedModels.map((model) => {
|
||||
const isSelected = Boolean(
|
||||
selectedScannedModels[model.id],
|
||||
);
|
||||
const selectedAbilities =
|
||||
selectedScannedModels[model.id]?.abilities || [];
|
||||
return (
|
||||
<div
|
||||
key={model.id}
|
||||
className="rounded-md border p-3 space-y-2"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={isSelected || model.already_added}
|
||||
disabled={model.already_added}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleScannedModel(model, checked as boolean)
|
||||
}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium break-all">
|
||||
{model.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{model.already_added
|
||||
? t('models.alreadyAdded')
|
||||
: model.type === 'llm'
|
||||
? t('models.chat')
|
||||
: model.type === 'embedding'
|
||||
? t('models.embedding')
|
||||
: t('models.rerank')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === 'llm' &&
|
||||
isSelected &&
|
||||
!model.already_added && (
|
||||
<div className="flex gap-4 pl-7">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`scan-vision-${model.id}`}
|
||||
checked={selectedAbilities.includes(
|
||||
'vision',
|
||||
)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleScannedModelAbility(
|
||||
model.id,
|
||||
'vision',
|
||||
checked as boolean,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`scan-vision-${model.id}`}
|
||||
className="text-sm"
|
||||
>
|
||||
<Eye className="h-3 w-3 inline mr-1" />
|
||||
{t('models.visionAbility')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`scan-func-${model.id}`}
|
||||
checked={selectedAbilities.includes(
|
||||
'func_call',
|
||||
)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleScannedModelAbility(
|
||||
model.id,
|
||||
'func_call',
|
||||
checked as boolean,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`scan-func-${model.id}`}
|
||||
className="text-sm"
|
||||
>
|
||||
<Wrench className="h-3 w-3 inline mr-1" />
|
||||
{t('models.functionCallAbility')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</Tabs>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Trash2,
|
||||
Settings,
|
||||
LogIn,
|
||||
Radar,
|
||||
} from 'lucide-react';
|
||||
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
|
||||
import { ModelProvider } from '@/app/infra/entities/api';
|
||||
@@ -60,7 +61,7 @@ interface ProviderCardProps {
|
||||
abilities: string[],
|
||||
extraArgs: ExtraArg[],
|
||||
) => Promise<void>;
|
||||
onScanModels: (modelType: ModelType) => Promise<ScanModelsResult>;
|
||||
onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>;
|
||||
onAddScannedModels: (
|
||||
modelType: ModelType,
|
||||
models: SelectedScannedModel[],
|
||||
@@ -130,6 +131,7 @@ export default function ProviderCard({
|
||||
const { t } = useTranslation();
|
||||
const [deleteProviderConfirmOpen, setDeleteProviderConfirmOpen] =
|
||||
useState(false);
|
||||
const [addModelMode, setAddModelMode] = useState<'manual' | 'scan'>('manual');
|
||||
|
||||
const canDelete =
|
||||
!isLangBotModels &&
|
||||
@@ -310,19 +312,75 @@ export default function ProviderCard({
|
||||
<div />
|
||||
)}
|
||||
{!isLangBotModels && (
|
||||
<AddModelPopover
|
||||
isOpen={addModelPopoverOpen === provider.uuid}
|
||||
onOpen={onOpenAddModel}
|
||||
onClose={onCloseAddModel}
|
||||
onAddModel={onAddModel}
|
||||
onScanModels={onScanModels}
|
||||
onAddScannedModels={onAddScannedModels}
|
||||
onTestModel={onTestModel}
|
||||
isSubmitting={isSubmitting}
|
||||
isTesting={isTesting}
|
||||
testResult={testResult}
|
||||
onResetTestResult={onResetTestResult}
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
<AddModelPopover
|
||||
isOpen={
|
||||
addModelPopoverOpen === provider.uuid &&
|
||||
addModelMode === 'manual'
|
||||
}
|
||||
initialMode="manual"
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAddModelMode('manual');
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
{t('models.addModel')}
|
||||
</Button>
|
||||
}
|
||||
onOpen={() => {
|
||||
setAddModelMode('manual');
|
||||
onOpenAddModel();
|
||||
}}
|
||||
onClose={onCloseAddModel}
|
||||
onAddModel={onAddModel}
|
||||
onScanModels={onScanModels}
|
||||
onAddScannedModels={onAddScannedModels}
|
||||
onTestModel={onTestModel}
|
||||
isSubmitting={isSubmitting}
|
||||
isTesting={isTesting}
|
||||
testResult={testResult}
|
||||
onResetTestResult={onResetTestResult}
|
||||
/>
|
||||
<AddModelPopover
|
||||
isOpen={
|
||||
addModelPopoverOpen === provider.uuid &&
|
||||
addModelMode === 'scan'
|
||||
}
|
||||
initialMode="scan"
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAddModelMode('scan');
|
||||
}}
|
||||
>
|
||||
<Radar className="h-3 w-3" />
|
||||
</Button>
|
||||
}
|
||||
onOpen={() => {
|
||||
setAddModelMode('scan');
|
||||
onOpenAddModel();
|
||||
}}
|
||||
onClose={onCloseAddModel}
|
||||
onAddModel={onAddModel}
|
||||
onScanModels={onScanModels}
|
||||
onAddScannedModels={onAddScannedModels}
|
||||
onTestModel={onTestModel}
|
||||
isSubmitting={isSubmitting}
|
||||
isTesting={isTesting}
|
||||
testResult={testResult}
|
||||
onResetTestResult={onResetTestResult}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
@@ -90,7 +90,7 @@ export interface ProviderCardProps {
|
||||
abilities: string[],
|
||||
extraArgs: ExtraArg[],
|
||||
) => Promise<void>;
|
||||
onScanModels: (modelType: ModelType) => Promise<ScanModelsResult>;
|
||||
onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>;
|
||||
onAddScannedModels: (
|
||||
modelType: ModelType,
|
||||
models: SelectedScannedModel[],
|
||||
|
||||
366
web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx
Normal file
366
web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx
Normal file
@@ -0,0 +1,366 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, RefreshCw, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
export type QrLoginPlatform = 'feishu' | 'weixin' | 'dingtalk' | 'wecombot';
|
||||
|
||||
interface PlatformConfig {
|
||||
titleKey: string;
|
||||
connectingKey: string;
|
||||
scanQRCodeKey: string;
|
||||
waitingKey: string;
|
||||
successKey: string;
|
||||
failedKey: string;
|
||||
retryKey: string;
|
||||
apiBase: string;
|
||||
extractSuccess: (data: Record<string, string>) => Record<string, string>;
|
||||
successNoteKey?: string;
|
||||
}
|
||||
|
||||
const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
|
||||
feishu: {
|
||||
titleKey: 'feishu.createApp',
|
||||
connectingKey: 'feishu.connecting',
|
||||
scanQRCodeKey: 'feishu.scanQRCode',
|
||||
waitingKey: 'feishu.waitingForScan',
|
||||
successKey: 'feishu.createSuccess',
|
||||
failedKey: 'feishu.createFailed',
|
||||
retryKey: 'feishu.retry',
|
||||
apiBase: '/api/v1/platform/adapters/lark/create-app',
|
||||
extractSuccess: (data) => ({
|
||||
app_id: data.app_id,
|
||||
app_secret: data.app_secret,
|
||||
...(data.app_name ? { app_name: data.app_name } : {}),
|
||||
}),
|
||||
},
|
||||
weixin: {
|
||||
titleKey: 'weixin.scanLogin',
|
||||
connectingKey: 'feishu.connecting',
|
||||
scanQRCodeKey: 'weixin.scanQRCode',
|
||||
waitingKey: 'feishu.waitingForScan',
|
||||
successKey: 'weixin.loginSuccess',
|
||||
failedKey: 'weixin.loginFailed',
|
||||
retryKey: 'feishu.retry',
|
||||
apiBase: '/api/v1/platform/adapters/weixin/login',
|
||||
extractSuccess: (data) => ({
|
||||
token: data.token,
|
||||
base_url: data.base_url,
|
||||
...(data.account_id ? { account_id: data.account_id } : {}),
|
||||
}),
|
||||
},
|
||||
dingtalk: {
|
||||
titleKey: 'dingtalk.createApp',
|
||||
connectingKey: 'dingtalk.connecting',
|
||||
scanQRCodeKey: 'dingtalk.scanQRCode',
|
||||
waitingKey: 'dingtalk.waitingForScan',
|
||||
successKey: 'dingtalk.createSuccess',
|
||||
failedKey: 'dingtalk.createFailed',
|
||||
retryKey: 'dingtalk.retry',
|
||||
apiBase: '/api/v1/platform/adapters/dingtalk/create-app',
|
||||
extractSuccess: (data) => ({
|
||||
client_id: data.client_id,
|
||||
client_secret: data.client_secret,
|
||||
}),
|
||||
successNoteKey: 'dingtalk.robotCodeNote',
|
||||
},
|
||||
wecombot: {
|
||||
titleKey: 'wecombot.createBot',
|
||||
connectingKey: 'wecombot.connecting',
|
||||
scanQRCodeKey: 'wecombot.scanQRCode',
|
||||
waitingKey: 'wecombot.waitingForScan',
|
||||
successKey: 'wecombot.createSuccess',
|
||||
failedKey: 'wecombot.createFailed',
|
||||
retryKey: 'wecombot.retry',
|
||||
apiBase: '/api/v1/platform/adapters/wecombot/create-bot',
|
||||
extractSuccess: (data) => ({
|
||||
BotId: data.botid,
|
||||
Secret: data.secret,
|
||||
}),
|
||||
successNoteKey: 'wecombot.robotNameNote',
|
||||
},
|
||||
};
|
||||
|
||||
interface QrCodeLoginDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
platform: QrLoginPlatform;
|
||||
onSuccess: (credentials: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
type DialogState = 'connecting' | 'waiting' | 'success' | 'error';
|
||||
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
|
||||
export default function QrCodeLoginDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
platform,
|
||||
onSuccess,
|
||||
}: QrCodeLoginDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const platformConfig = PLATFORM_CONFIGS[platform];
|
||||
|
||||
const [state, setState] = useState<DialogState>('connecting');
|
||||
const [qrDataUrl, setQrDataUrl] = useState('');
|
||||
const [expireIn, setExpireIn] = useState(0);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
const cleanedRef = useRef(false);
|
||||
|
||||
const onSuccessRef = useRef(onSuccess);
|
||||
onSuccessRef.current = onSuccess;
|
||||
const onOpenChangeRef = useRef(onOpenChange);
|
||||
onOpenChangeRef.current = onOpenChange;
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
const platformConfigRef = useRef(platformConfig);
|
||||
platformConfigRef.current = platformConfig;
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (cleanedRef.current) return;
|
||||
cleanedRef.current = true;
|
||||
|
||||
if (pollTimerRef.current) {
|
||||
clearInterval(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
if (countdownRef.current) {
|
||||
clearInterval(countdownRef.current);
|
||||
countdownRef.current = null;
|
||||
}
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
abortRef.current = null;
|
||||
}
|
||||
// Cancel backend session
|
||||
if (sessionIdRef.current) {
|
||||
const token = localStorage.getItem('token');
|
||||
const baseUrl =
|
||||
import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||
fetch(
|
||||
`${baseUrl}${platformConfigRef.current.apiBase}/${sessionIdRef.current}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
keepalive: true,
|
||||
},
|
||||
).catch(() => {});
|
||||
sessionIdRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startLogin = useCallback(async () => {
|
||||
cleanup();
|
||||
cleanedRef.current = false;
|
||||
setState('connecting');
|
||||
setQrDataUrl('');
|
||||
setExpireIn(0);
|
||||
setErrorMessage('');
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||
const cfg = platformConfigRef.current;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
const res = await fetch(`${baseUrl}${cfg.apiBase}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) throw new Error(json.msg || 'Request failed');
|
||||
|
||||
const { session_id, qr_data_url, qr_url, expire_at } = json.data;
|
||||
sessionIdRef.current = session_id;
|
||||
|
||||
// qr_data_url is a pre-rendered data URL (WeChat);
|
||||
// qr_url is a plain URL string (Feishu) that needs local QR generation.
|
||||
if (qr_data_url) {
|
||||
setQrDataUrl(qr_data_url);
|
||||
} else if (qr_url) {
|
||||
const dataUrl = await QRCode.toDataURL(qr_url, {
|
||||
width: 224,
|
||||
margin: 2,
|
||||
});
|
||||
setQrDataUrl(dataUrl);
|
||||
}
|
||||
setState('waiting');
|
||||
|
||||
// Calculate remaining seconds
|
||||
const remaining = Math.max(0, Math.floor(expire_at - Date.now() / 1000));
|
||||
setExpireIn(remaining);
|
||||
|
||||
// Start countdown
|
||||
countdownRef.current = setInterval(() => {
|
||||
setExpireIn((prev) => {
|
||||
if (prev <= 1) {
|
||||
if (countdownRef.current) {
|
||||
clearInterval(countdownRef.current);
|
||||
countdownRef.current = null;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
// Start polling
|
||||
pollTimerRef.current = setInterval(async () => {
|
||||
try {
|
||||
const pollRes = await fetch(
|
||||
`${baseUrl}${cfg.apiBase}/status/${session_id}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
if (!pollRes.ok) return;
|
||||
|
||||
const pollJson = await pollRes.json();
|
||||
if (pollJson.code !== 0) return;
|
||||
|
||||
const { status, error, ...rest } = pollJson.data;
|
||||
|
||||
if (status === 'success') {
|
||||
sessionIdRef.current = null; // backend already cleaned up
|
||||
cleanup();
|
||||
setState('success');
|
||||
setTimeout(() => {
|
||||
onSuccessRef.current(cfg.extractSuccess(rest));
|
||||
onOpenChangeRef.current(false);
|
||||
}, 1500);
|
||||
} else if (status === 'error') {
|
||||
sessionIdRef.current = null;
|
||||
cleanup();
|
||||
setState('error');
|
||||
setErrorMessage(error || tRef.current(cfg.failedKey));
|
||||
}
|
||||
} catch {
|
||||
// ignore poll errors, will retry next interval
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name === 'AbortError') return;
|
||||
setState('error');
|
||||
setErrorMessage(
|
||||
err instanceof Error ? err.message : tRef.current(cfg.failedKey),
|
||||
);
|
||||
}
|
||||
}, [cleanup]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
startLogin();
|
||||
}
|
||||
return () => {
|
||||
cleanup();
|
||||
};
|
||||
}, [open, startLogin, cleanup]);
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
cleanup();
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
if (m > 0) {
|
||||
return `${m}m${s.toString().padStart(2, '0')}s`;
|
||||
}
|
||||
return `${s}s`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t(platformConfig.titleKey)}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col items-center justify-center py-4 space-y-4">
|
||||
{/* Connecting */}
|
||||
{state === 'connecting' && (
|
||||
<div className="flex flex-col items-center space-y-3 py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(platformConfig.connectingKey)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* QR code area */}
|
||||
{state === 'waiting' && qrDataUrl && (
|
||||
<div className="flex flex-col items-center space-y-3">
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{t(platformConfig.scanQRCodeKey)}
|
||||
</p>
|
||||
<div className="border rounded-lg p-2 bg-white">
|
||||
<img src={qrDataUrl} alt="QR Code" className="w-56 h-56" />
|
||||
</div>
|
||||
{expireIn > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(platformConfig.waitingKey)} ({formatTime(expireIn)})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success */}
|
||||
{state === 'success' && (
|
||||
<div className="flex flex-col items-center space-y-3 py-8">
|
||||
<CheckCircle2 className="h-12 w-12 text-green-500" />
|
||||
<p className="text-sm text-green-600 font-medium">
|
||||
{t(platformConfig.successKey)}
|
||||
</p>
|
||||
{platformConfig.successNoteKey && (
|
||||
<p className="text-xs text-muted-foreground text-center max-w-xs">
|
||||
{t(platformConfig.successNoteKey)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{state === 'error' && (
|
||||
<div className="flex flex-col items-center space-y-3 py-8">
|
||||
<XCircle className="h-12 w-12 text-red-500" />
|
||||
<p className="text-sm text-red-600 text-center">
|
||||
{errorMessage || t(platformConfig.failedKey)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{state === 'error' && (
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={() => startLogin()}>
|
||||
<RefreshCw className="h-4 w-4 mr-1.5" />
|
||||
{t(platformConfig.retryKey)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
ScanLine,
|
||||
} from 'lucide-react';
|
||||
import QRCode from 'qrcode';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
|
||||
export type QrLoginPlatform = 'feishu' | 'weixin';
|
||||
|
||||
interface PlatformConfig {
|
||||
titleKey: string;
|
||||
connectingKey: string;
|
||||
scanQRCodeKey: string;
|
||||
waitingKey: string;
|
||||
successKey: string;
|
||||
failedKey: string;
|
||||
retryKey: string;
|
||||
apiBase: string;
|
||||
brandColor: string;
|
||||
adapterName: string;
|
||||
extractSuccess: (data: Record<string, string>) => Record<string, string>;
|
||||
}
|
||||
|
||||
const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
|
||||
feishu: {
|
||||
titleKey: 'feishu.createApp',
|
||||
connectingKey: 'feishu.connecting',
|
||||
scanQRCodeKey: 'feishu.scanQRCode',
|
||||
waitingKey: 'feishu.waitingForScan',
|
||||
successKey: 'feishu.createSuccess',
|
||||
failedKey: 'feishu.createFailed',
|
||||
retryKey: 'feishu.retry',
|
||||
apiBase: '/api/v1/platform/adapters/lark/create-app',
|
||||
brandColor: '#3370ff',
|
||||
adapterName: 'lark',
|
||||
extractSuccess: (data) => ({
|
||||
app_id: data.app_id,
|
||||
app_secret: data.app_secret,
|
||||
...(data.app_name ? { app_name: data.app_name } : {}),
|
||||
}),
|
||||
},
|
||||
weixin: {
|
||||
titleKey: 'weixin.scanLogin',
|
||||
connectingKey: 'feishu.connecting',
|
||||
scanQRCodeKey: 'weixin.scanQRCode',
|
||||
waitingKey: 'feishu.waitingForScan',
|
||||
successKey: 'weixin.loginSuccess',
|
||||
failedKey: 'weixin.loginFailed',
|
||||
retryKey: 'feishu.retry',
|
||||
apiBase: '/api/v1/platform/adapters/weixin/login',
|
||||
brandColor: '#07c160',
|
||||
adapterName: 'openclaw-weixin',
|
||||
extractSuccess: (data) => ({
|
||||
token: data.token,
|
||||
base_url: data.base_url,
|
||||
...(data.account_id ? { account_id: data.account_id } : {}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
interface QrCodeLoginDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
platform: QrLoginPlatform;
|
||||
onSuccess: (credentials: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
type DialogState = 'connecting' | 'waiting' | 'success' | 'error';
|
||||
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
|
||||
export default function QrCodeLoginDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
platform,
|
||||
onSuccess,
|
||||
}: QrCodeLoginDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const platformConfig = PLATFORM_CONFIGS[platform];
|
||||
|
||||
const [state, setState] = useState<DialogState>('connecting');
|
||||
const [qrDataUrl, setQrDataUrl] = useState('');
|
||||
const [expireIn, setExpireIn] = useState(0);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
|
||||
const onSuccessRef = useRef(onSuccess);
|
||||
onSuccessRef.current = onSuccess;
|
||||
const onOpenChangeRef = useRef(onOpenChange);
|
||||
onOpenChangeRef.current = onOpenChange;
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
const platformConfigRef = useRef(platformConfig);
|
||||
platformConfigRef.current = platformConfig;
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (pollTimerRef.current) {
|
||||
clearInterval(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
if (countdownRef.current) {
|
||||
clearInterval(countdownRef.current);
|
||||
countdownRef.current = null;
|
||||
}
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
abortRef.current = null;
|
||||
}
|
||||
if (sessionIdRef.current) {
|
||||
const token = localStorage.getItem('token');
|
||||
const baseUrl =
|
||||
import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||
fetch(
|
||||
`${baseUrl}${platformConfigRef.current.apiBase}/${sessionIdRef.current}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
).catch(() => {});
|
||||
sessionIdRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startLogin = useCallback(async () => {
|
||||
cleanup();
|
||||
setState('connecting');
|
||||
setQrDataUrl('');
|
||||
setExpireIn(0);
|
||||
setErrorMessage('');
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||
const cfg = platformConfigRef.current;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
const res = await fetch(`${baseUrl}${cfg.apiBase}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) throw new Error(json.msg || 'Request failed');
|
||||
|
||||
const { session_id, qr_data_url, qr_url, expire_at } = json.data;
|
||||
sessionIdRef.current = session_id;
|
||||
|
||||
if (qr_data_url) {
|
||||
setQrDataUrl(qr_data_url);
|
||||
} else if (qr_url) {
|
||||
const dataUrl = await QRCode.toDataURL(qr_url, {
|
||||
width: 280,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#ffffff',
|
||||
},
|
||||
});
|
||||
setQrDataUrl(dataUrl);
|
||||
}
|
||||
setState('waiting');
|
||||
|
||||
const remaining = Math.max(0, Math.floor(expire_at - Date.now() / 1000));
|
||||
setExpireIn(remaining);
|
||||
|
||||
countdownRef.current = setInterval(() => {
|
||||
setExpireIn((prev) => {
|
||||
if (prev <= 1) {
|
||||
if (countdownRef.current) {
|
||||
clearInterval(countdownRef.current);
|
||||
countdownRef.current = null;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
pollTimerRef.current = setInterval(async () => {
|
||||
try {
|
||||
const pollRes = await fetch(
|
||||
`${baseUrl}${cfg.apiBase}/status/${session_id}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
if (!pollRes.ok) return;
|
||||
|
||||
const pollJson = await pollRes.json();
|
||||
if (pollJson.code !== 0) return;
|
||||
|
||||
const { status, error, ...rest } = pollJson.data;
|
||||
|
||||
if (status === 'success') {
|
||||
sessionIdRef.current = null;
|
||||
cleanup();
|
||||
setState('success');
|
||||
setTimeout(() => {
|
||||
onSuccessRef.current(cfg.extractSuccess(rest));
|
||||
onOpenChangeRef.current(false);
|
||||
}, 1500);
|
||||
} else if (status === 'error') {
|
||||
sessionIdRef.current = null;
|
||||
cleanup();
|
||||
setState('error');
|
||||
setErrorMessage(error || tRef.current(cfg.failedKey));
|
||||
}
|
||||
} catch {
|
||||
// ignore poll errors
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name === 'AbortError') return;
|
||||
setState('error');
|
||||
setErrorMessage(
|
||||
err instanceof Error ? err.message : tRef.current(cfg.failedKey),
|
||||
);
|
||||
}
|
||||
}, [cleanup]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
startLogin();
|
||||
}
|
||||
return () => {
|
||||
cleanup();
|
||||
};
|
||||
}, [open, startLogin, cleanup]);
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
cleanup();
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
if (m > 0) {
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `0:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-md p-0 overflow-hidden">
|
||||
{/* Brand header */}
|
||||
<div className="flex items-center gap-3 px-6 pt-6 pb-2">
|
||||
<img
|
||||
src={httpClient.getAdapterIconURL(platformConfig.adapterName)}
|
||||
alt={platform}
|
||||
className="h-10 w-10 rounded-lg"
|
||||
/>
|
||||
<div>
|
||||
<DialogTitle className="text-lg">
|
||||
{t(platformConfig.titleKey)}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-center px-6 pb-6 space-y-4">
|
||||
{/* Connecting */}
|
||||
{state === 'connecting' && (
|
||||
<div className="flex flex-col items-center space-y-4 py-12">
|
||||
<div className="relative">
|
||||
<div
|
||||
className="absolute inset-0 rounded-full animate-ping opacity-20"
|
||||
style={{ backgroundColor: platformConfig.brandColor }}
|
||||
/>
|
||||
<Loader2
|
||||
className="h-10 w-10 animate-spin relative"
|
||||
style={{ color: platformConfig.brandColor }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground font-medium">
|
||||
{t(platformConfig.connectingKey)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* QR code area */}
|
||||
{state === 'waiting' && qrDataUrl && (
|
||||
<div className="flex flex-col items-center space-y-4 py-2">
|
||||
{/* Instruction */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium"
|
||||
style={{
|
||||
backgroundColor: `${platformConfig.brandColor}10`,
|
||||
color: platformConfig.brandColor,
|
||||
}}
|
||||
>
|
||||
<ScanLine className="h-4 w-4" />
|
||||
{t(platformConfig.scanQRCodeKey)}
|
||||
</div>
|
||||
|
||||
{/* QR Code with border animation */}
|
||||
<div className="relative">
|
||||
<div
|
||||
className="absolute -inset-1 rounded-2xl opacity-30 animate-pulse"
|
||||
style={{ backgroundColor: platformConfig.brandColor }}
|
||||
/>
|
||||
<div className="relative bg-white rounded-xl p-3 shadow-lg">
|
||||
<img src={qrDataUrl} alt="QR Code" className="w-64 h-64" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Countdown */}
|
||||
{expireIn > 0 && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div
|
||||
className="h-2 w-2 rounded-full animate-pulse"
|
||||
style={{ backgroundColor: platformConfig.brandColor }}
|
||||
/>
|
||||
<span className="text-muted-foreground">
|
||||
{t(platformConfig.waitingKey)}
|
||||
</span>
|
||||
<span
|
||||
className="font-mono font-semibold tabular-nums"
|
||||
style={{
|
||||
color:
|
||||
expireIn < 60 ? '#ef4444' : platformConfig.brandColor,
|
||||
}}
|
||||
>
|
||||
{formatTime(expireIn)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success */}
|
||||
{state === 'success' && (
|
||||
<div className="flex flex-col items-center space-y-3 py-12">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 rounded-full bg-green-100 animate-ping opacity-30" />
|
||||
<CheckCircle2 className="h-16 w-16 text-green-500 relative" />
|
||||
</div>
|
||||
<p className="text-base text-green-600 font-semibold">
|
||||
{t(platformConfig.successKey)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{state === 'error' && (
|
||||
<div className="flex flex-col items-center space-y-3 py-12">
|
||||
<XCircle className="h-16 w-16 text-red-400" />
|
||||
<p className="text-sm text-red-500 text-center max-w-xs">
|
||||
{errorMessage || t(platformConfig.failedKey)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error footer with retry */}
|
||||
{state === 'error' && (
|
||||
<DialogFooter className="px-6 pb-6 pt-0">
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => startLogin()}
|
||||
style={{ backgroundColor: platformConfig.brandColor }}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-1.5" />
|
||||
{t(platformConfig.retryKey)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -57,7 +57,6 @@ const getFormSchema = (t: (key: string) => string) =>
|
||||
* Parse creation schema from Knowledge Engine to IDynamicFormItemSchema[]
|
||||
*/
|
||||
function parseCreationSchema(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
schemaItems: any | any[] | undefined,
|
||||
): IDynamicFormItemSchema[] {
|
||||
if (!schemaItems) return [];
|
||||
@@ -107,6 +106,10 @@ export default function KBForm({
|
||||
const savedSnapshotRef = useRef<string>('');
|
||||
const isInitializing = useRef(true);
|
||||
|
||||
// Refs to store validation functions from dynamic forms
|
||||
const configValidateRef = useRef<(() => Promise<boolean>) | null>(null);
|
||||
const retrievalValidateRef = useRef<(() => Promise<boolean>) | null>(null);
|
||||
|
||||
const formSchema = getFormSchema(t);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
@@ -235,7 +238,24 @@ export default function KBForm({
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = (data: z.infer<typeof formSchema>) => {
|
||||
const onSubmit = async (data: z.infer<typeof formSchema>) => {
|
||||
// Validate dynamic forms before submission
|
||||
if (configValidateRef.current) {
|
||||
const configValid = await configValidateRef.current();
|
||||
if (!configValid) {
|
||||
toast.error(t('knowledge.engineSettingsInvalid'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (retrievalValidateRef.current) {
|
||||
const retrievalValid = await retrievalValidateRef.current();
|
||||
if (!retrievalValid) {
|
||||
toast.error(t('knowledge.retrievalSettingsInvalid'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const kbData: KnowledgeBase = {
|
||||
name: data.name,
|
||||
description: data.description ?? '',
|
||||
@@ -490,6 +510,9 @@ export default function KBForm({
|
||||
}
|
||||
isEditing={isEditing}
|
||||
externalDependentValues={retrievalSettings}
|
||||
onValidate={(validateFn) =>
|
||||
(configValidateRef.current = validateFn)
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -512,6 +535,9 @@ export default function KBForm({
|
||||
setRetrievalSettings(val as Record<string, unknown>)
|
||||
}
|
||||
externalDependentValues={configSettings}
|
||||
onValidate={(validateFn) =>
|
||||
(retrievalValidateRef.current = validateFn)
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -46,7 +46,11 @@ export interface PluginInstallTask {
|
||||
currentAction: string; // raw backend action string
|
||||
}
|
||||
|
||||
type OnTaskCompleteCallback = (taskId: number, success: boolean) => void;
|
||||
type OnTaskCompleteCallback = (
|
||||
taskId: number,
|
||||
success: boolean,
|
||||
error?: string,
|
||||
) => void;
|
||||
|
||||
interface PluginInstallTaskContextValue {
|
||||
tasks: PluginInstallTask[];
|
||||
@@ -239,13 +243,16 @@ export function PluginInstallTaskProvider({
|
||||
onTaskCompleteCallbacks.current.delete(cb);
|
||||
}, []);
|
||||
|
||||
const notifyTaskComplete = useCallback((taskId: number, success: boolean) => {
|
||||
if (notifiedTaskIds.current.has(taskId)) return;
|
||||
notifiedTaskIds.current.add(taskId);
|
||||
onTaskCompleteCallbacks.current.forEach((cb) => {
|
||||
cb(taskId, success);
|
||||
});
|
||||
}, []);
|
||||
const notifyTaskComplete = useCallback(
|
||||
(taskId: number, success: boolean, error?: string) => {
|
||||
if (notifiedTaskIds.current.has(taskId)) return;
|
||||
notifiedTaskIds.current.add(taskId);
|
||||
onTaskCompleteCallbacks.current.forEach((cb) => {
|
||||
cb(taskId, success, error);
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const pollTask = useCallback(
|
||||
(taskKey: string, taskId: number) => {
|
||||
@@ -304,7 +311,7 @@ export function PluginInstallTaskProvider({
|
||||
}
|
||||
|
||||
if (exception) {
|
||||
notifyTaskComplete(taskId, false);
|
||||
notifyTaskComplete(taskId, false, exception);
|
||||
return {
|
||||
...t,
|
||||
stage: InstallStage.ERROR,
|
||||
|
||||
@@ -82,11 +82,13 @@ function PluginListView() {
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
const onComplete = (_taskId: number, success: boolean) => {
|
||||
const onComplete = (_taskId: number, success: boolean, error?: string) => {
|
||||
if (success) {
|
||||
toast.success(t('plugins.installSuccess'));
|
||||
pluginInstalledRef.current?.refreshPluginList();
|
||||
refreshPlugins();
|
||||
} else {
|
||||
toast.error(error || t('plugins.installFailed'));
|
||||
}
|
||||
};
|
||||
registerOnTaskComplete(onComplete);
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface IDynamicFormItemSchema {
|
||||
/** when type is PLUGIN_SELECTOR, the scopes is the scopes of components(plugin contains), the default is all */
|
||||
scopes?: string[];
|
||||
accept?: string; // For file type: accepted MIME types
|
||||
login_platform?: string; // For qr-code-login type: platform identifier (e.g. 'feishu', 'weixin')
|
||||
}
|
||||
|
||||
export enum DynamicFormItemType {
|
||||
@@ -57,6 +58,7 @@ export enum DynamicFormItemType {
|
||||
TOOLS_SELECTOR = 'tools-selector',
|
||||
WEBHOOK_URL = 'webhook-url',
|
||||
EMBED_CODE = 'embed-code',
|
||||
QR_CODE_LOGIN = 'qr-code-login',
|
||||
}
|
||||
|
||||
export interface IFileConfig {
|
||||
|
||||
@@ -609,6 +609,9 @@ export class BackendClient extends BaseHttpClient {
|
||||
name: string,
|
||||
filepath: string,
|
||||
): string {
|
||||
if (this.instance.defaults.baseURL === '/') {
|
||||
return `${window.location.origin}/api/v1/plugins/${author}/${name}/assets/${filepath}`;
|
||||
}
|
||||
return (
|
||||
this.instance.defaults.baseURL +
|
||||
`/api/v1/plugins/${author}/${name}/assets/${filepath}`
|
||||
|
||||
@@ -228,6 +228,7 @@ export default function WizardPage() {
|
||||
type: parseDynamicFormItemType(item.type),
|
||||
options: item.options,
|
||||
show_if: item.show_if,
|
||||
login_platform: item.login_platform,
|
||||
}),
|
||||
);
|
||||
}, [adapters, selectedAdapter]);
|
||||
@@ -247,6 +248,7 @@ export default function WizardPage() {
|
||||
type: parseDynamicFormItemType(item.type),
|
||||
options: item.options,
|
||||
show_if: item.show_if,
|
||||
login_platform: item.login_platform,
|
||||
}),
|
||||
);
|
||||
}, [selectedRunnerConfigStage]);
|
||||
|
||||
@@ -47,6 +47,8 @@ const enUS = {
|
||||
success: 'Success',
|
||||
save: 'Save',
|
||||
saving: 'Saving...',
|
||||
recommend: 'Recommended',
|
||||
start: 'Start',
|
||||
confirm: 'Confirm',
|
||||
confirmDelete: 'Confirm Delete',
|
||||
deleteConfirmation: 'Are you sure you want to delete this?',
|
||||
@@ -1008,6 +1010,10 @@ const enUS = {
|
||||
engineSettingsDescription:
|
||||
'Configuration for the selected knowledge engine',
|
||||
engineSettingsReadonly: 'read-only in edit mode',
|
||||
engineSettingsInvalid:
|
||||
'Engine settings validation failed, please check required fields',
|
||||
retrievalSettingsInvalid:
|
||||
'Retrieval settings validation failed, please check required fields',
|
||||
retrievalSettings: 'Retrieval Settings',
|
||||
retrievalSettingsDescription:
|
||||
'Configure how documents are retrieved from this knowledge base',
|
||||
@@ -1559,6 +1565,51 @@ const enUS = {
|
||||
retryFailed:
|
||||
'Still cannot connect to the backend. Start the service and try again.',
|
||||
},
|
||||
feishu: {
|
||||
createApp: 'One-Click Create Feishu App',
|
||||
scanQRCode:
|
||||
'Scan the QR code below with Feishu to authorize and automatically create the app',
|
||||
waitingForScan: 'Waiting for scan',
|
||||
createSuccess: 'App created successfully! Credentials have been filled in',
|
||||
createFailed: 'Creation failed',
|
||||
connecting: 'Connecting to Feishu service...',
|
||||
expired: 'QR code expired, please try again',
|
||||
denied: 'Authorization denied by user',
|
||||
connectionLost: 'Connection lost, please try again',
|
||||
reconnecting: 'Reconnecting...',
|
||||
retry: 'Retry',
|
||||
},
|
||||
weixin: {
|
||||
scanLogin: 'Scan QR Login',
|
||||
scanQRCode:
|
||||
'Scan the QR code below with WeChat to authorize and automatically fill in the token',
|
||||
loginSuccess: 'Login successful! Token has been filled in',
|
||||
loginFailed: 'Login failed',
|
||||
},
|
||||
dingtalk: {
|
||||
createApp: 'One-Click Create DingTalk App',
|
||||
scanQRCode:
|
||||
'Scan the QR code below with DingTalk to authorize and automatically create the app',
|
||||
waitingForScan: 'Waiting for scan',
|
||||
createSuccess: 'App created successfully! Credentials have been filled in',
|
||||
createFailed: 'Creation failed',
|
||||
connecting: 'Connecting to DingTalk service...',
|
||||
retry: 'Retry',
|
||||
robotCodeNote:
|
||||
'Robot Code cannot be obtained automatically. Please go to DingTalk Developer Backend > Robot Configuration to copy it manually. It is required for features like image recognition and file upload.',
|
||||
},
|
||||
wecombot: {
|
||||
createBot: 'One-Click Create WeCom Bot',
|
||||
scanQRCode:
|
||||
'Scan the QR code below with WeCom to authorize and automatically create the bot',
|
||||
waitingForScan: 'Waiting for scan',
|
||||
createSuccess: 'Bot created successfully! Credentials have been filled in',
|
||||
createFailed: 'Creation failed',
|
||||
connecting: 'Connecting to WeCom service...',
|
||||
retry: 'Retry',
|
||||
robotNameNote:
|
||||
'Robot Name cannot be obtained automatically. Please fill it in manually.',
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: 'Select a plugin page from the sidebar',
|
||||
invalidPage: 'Invalid plugin page',
|
||||
|
||||
@@ -50,6 +50,8 @@ const esES = {
|
||||
success: 'Éxito',
|
||||
save: 'Guardar',
|
||||
saving: 'Guardando...',
|
||||
recommend: 'Recomendado',
|
||||
start: 'Iniciar',
|
||||
confirm: 'Confirmar',
|
||||
confirmDelete: 'Confirmar eliminación',
|
||||
deleteConfirmation: '¿Estás seguro de que deseas eliminar esto?',
|
||||
@@ -1030,6 +1032,10 @@ const esES = {
|
||||
engineSettingsDescription:
|
||||
'Configuración del motor de conocimiento seleccionado',
|
||||
engineSettingsReadonly: 'solo lectura en modo de edición',
|
||||
engineSettingsInvalid:
|
||||
'La configuración del motor no es válida, verifique los campos obligatorios',
|
||||
retrievalSettingsInvalid:
|
||||
'La configuración de recuperación no es válida, verifique los campos obligatorios',
|
||||
retrievalSettings: 'Configuración de recuperación',
|
||||
retrievalSettingsDescription:
|
||||
'Configura cómo se recuperan los documentos de esta base de conocimiento',
|
||||
@@ -1483,6 +1489,55 @@ const esES = {
|
||||
retryFailed:
|
||||
'Aún no se puede conectar con el backend. Inicia el servicio e inténtalo de nuevo.',
|
||||
},
|
||||
feishu: {
|
||||
createApp: 'Crear aplicación de Feishu con un clic',
|
||||
scanQRCode:
|
||||
'Escanea el código QR de abajo con Feishu para autorizar y crear la aplicación automáticamente',
|
||||
waitingForScan: 'Esperando escaneo',
|
||||
createSuccess:
|
||||
'¡Aplicación creada correctamente! Las credenciales se han rellenado automáticamente',
|
||||
createFailed: 'Error al crear la aplicación',
|
||||
connecting: 'Conectando con el servicio de Feishu...',
|
||||
expired: 'El código QR ha caducado. Inténtalo de nuevo',
|
||||
denied: 'El usuario rechazó la autorización',
|
||||
connectionLost: 'Se perdió la conexión. Inténtalo de nuevo',
|
||||
reconnecting: 'Reconectando...',
|
||||
retry: 'Reintentar',
|
||||
},
|
||||
weixin: {
|
||||
scanLogin: 'Iniciar sesión en WeChat con QR',
|
||||
scanQRCode:
|
||||
'Escanea el código QR de abajo con WeChat para autorizar e introducir el token automáticamente',
|
||||
loginSuccess:
|
||||
'¡Inicio de sesión correcto! El token se ha rellenado automáticamente',
|
||||
loginFailed: 'Error al iniciar sesión',
|
||||
},
|
||||
dingtalk: {
|
||||
createApp: 'Crear aplicación de DingTalk con un clic',
|
||||
scanQRCode:
|
||||
'Escanea el código QR de abajo con DingTalk para autorizar y crear la aplicación automáticamente',
|
||||
waitingForScan: 'Esperando escaneo',
|
||||
createSuccess:
|
||||
'¡Aplicación creada correctamente! Las credenciales se han rellenado automáticamente',
|
||||
createFailed: 'Error al crear la aplicación',
|
||||
connecting: 'Conectando con el servicio de DingTalk...',
|
||||
retry: 'Reintentar',
|
||||
robotCodeNote:
|
||||
'El código del robot no puede obtenerse automáticamente. Ve al panel de desarrolladores de DingTalk > Configuración del robot para copiarlo manualmente. Es necesario para funciones como reconocimiento de imágenes y carga de archivos.',
|
||||
},
|
||||
wecombot: {
|
||||
createBot: 'Crear bot de WeCom con un clic',
|
||||
scanQRCode:
|
||||
'Escanea el código QR de abajo con WeCom para autorizar y crear el bot automáticamente',
|
||||
waitingForScan: 'Esperando escaneo',
|
||||
createSuccess:
|
||||
'¡Bot creado correctamente! Las credenciales se han rellenado automáticamente',
|
||||
createFailed: 'Error al crear el bot',
|
||||
connecting: 'Conectando con el servicio de WeCom...',
|
||||
retry: 'Reintentar',
|
||||
robotNameNote:
|
||||
'El nombre del robot no puede obtenerse automáticamente. Introdúcelo manualmente.',
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: 'Selecciona una página de plugin en la barra lateral',
|
||||
invalidPage: 'Página de plugin no válida',
|
||||
|
||||
@@ -48,6 +48,8 @@ const jaJP = {
|
||||
success: '成功',
|
||||
save: '保存',
|
||||
saving: '保存中...',
|
||||
recommend: 'おすすめ',
|
||||
start: '開始',
|
||||
confirm: '確認',
|
||||
confirmDelete: '削除の確認',
|
||||
deleteConfirmation: '本当に削除しますか?',
|
||||
@@ -1004,6 +1006,10 @@ const jaJP = {
|
||||
engineSettings: 'エンジン設定',
|
||||
engineSettingsDescription: '選択したナレッジエンジンの設定',
|
||||
engineSettingsReadonly: '編集モードでは変更できません',
|
||||
engineSettingsInvalid:
|
||||
'エンジン設定の検証に失敗しました、必須項目を確認してください',
|
||||
retrievalSettingsInvalid:
|
||||
'検索設定の検証に失敗しました、必須項目を確認してください',
|
||||
retrievalSettings: '検索設定',
|
||||
retrievalSettingsDescription: 'このナレッジベースからの文書検索方法を設定',
|
||||
dangerZone: '危険ゾーン',
|
||||
@@ -1474,6 +1480,46 @@ const jaJP = {
|
||||
retryFailed:
|
||||
'バックエンドにまだ接続できません。サービスを起動してからもう一度お試しください。',
|
||||
},
|
||||
feishu: {
|
||||
createApp: 'ワンクリックでFeishuアプリ作成',
|
||||
scanQRCode: '以下のQRコードをFeishuでスキャンし、アプリを自動作成',
|
||||
waitingForScan: 'スキャン待ち',
|
||||
createSuccess: 'アプリ作成成功!認証情報が自動入力されました',
|
||||
createFailed: '作成失敗',
|
||||
connecting: 'Feishuサービスに接続中...',
|
||||
expired: 'QRコードの有効期限が切れました。もう一度お試しください',
|
||||
denied: 'ユーザーが承認を拒否しました',
|
||||
connectionLost: '接続が切断されました。もう一度お試しください',
|
||||
reconnecting: '再接続中...',
|
||||
retry: '再試行',
|
||||
},
|
||||
weixin: {
|
||||
scanLogin: 'QRコードでWeChatログイン',
|
||||
scanQRCode: '以下のQRコードをWeChatでスキャンし、トークンを自動入力',
|
||||
loginSuccess: 'ログイン成功!トークンが自動入力されました',
|
||||
loginFailed: 'ログイン失敗',
|
||||
},
|
||||
dingtalk: {
|
||||
createApp: 'ワンクリックでDingTalkアプリ作成',
|
||||
scanQRCode: '以下のQRコードをDingTalkでスキャンし、アプリを自動作成',
|
||||
waitingForScan: 'スキャン待ち',
|
||||
createSuccess: 'アプリ作成成功!認証情報が自動入力されました',
|
||||
createFailed: '作成失敗',
|
||||
connecting: 'DingTalkサービスに接続中...',
|
||||
retry: '再試行',
|
||||
robotCodeNote:
|
||||
'ロボットコードは自動取得できません。DingTalk開発者バックエンド > ロボット設定から手動でコピーしてください。画像認識やファイルアップロードなどの機能に必要です。',
|
||||
},
|
||||
wecombot: {
|
||||
createBot: 'ワンクリックでWeComボット作成',
|
||||
scanQRCode: '以下のQRコードをWeComでスキャンし、ボットを自動作成',
|
||||
waitingForScan: 'スキャン待ち',
|
||||
createSuccess: 'ボット作成成功!認証情報が自動入力されました',
|
||||
createFailed: '作成失敗',
|
||||
connecting: 'WeComサービスに接続中...',
|
||||
retry: '再試行',
|
||||
robotNameNote: 'ロボット名は自動取得できません。手動で入力してください。',
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: 'サイドバーからプラグインページを選択してください',
|
||||
invalidPage: '無効なプラグインページ',
|
||||
|
||||
@@ -48,6 +48,8 @@ const ruRU = {
|
||||
success: 'Успешно',
|
||||
save: 'Сохранить',
|
||||
saving: 'Сохранение...',
|
||||
recommend: 'Рекомендуется',
|
||||
start: 'Начать',
|
||||
confirm: 'Подтвердить',
|
||||
confirmDelete: 'Подтвердить удаление',
|
||||
deleteConfirmation: 'Вы уверены, что хотите удалить это?',
|
||||
@@ -1015,6 +1017,10 @@ const ruRU = {
|
||||
engineSettings: 'Настройки движка',
|
||||
engineSettingsDescription: 'Конфигурация выбранного движка знаний',
|
||||
engineSettingsReadonly: 'только чтение в режиме редактирования',
|
||||
engineSettingsInvalid:
|
||||
'Настройки движка недействительны, проверьте обязательные поля',
|
||||
retrievalSettingsInvalid:
|
||||
'Настройки извлечения недействительны, проверьте обязательные поля',
|
||||
retrievalSettings: 'Настройки извлечения',
|
||||
retrievalSettingsDescription:
|
||||
'Настройте способ извлечения документов из базы знаний',
|
||||
@@ -1455,6 +1461,53 @@ const ruRU = {
|
||||
retryFailed:
|
||||
'По-прежнему не удается подключиться к бэкенду. Запустите сервис и повторите попытку.',
|
||||
},
|
||||
feishu: {
|
||||
createApp: 'Создать приложение Feishu в один клик',
|
||||
scanQRCode:
|
||||
'Отсканируйте QR-код ниже в Feishu, чтобы авторизоваться и автоматически создать приложение',
|
||||
waitingForScan: 'Ожидание сканирования',
|
||||
createSuccess:
|
||||
'Приложение успешно создано! Учётные данные заполнены автоматически',
|
||||
createFailed: 'Не удалось создать приложение',
|
||||
connecting: 'Подключение к сервису Feishu...',
|
||||
expired: 'Срок действия QR-кода истёк. Повторите попытку',
|
||||
denied: 'Пользователь отклонил авторизацию',
|
||||
connectionLost: 'Соединение потеряно. Повторите попытку',
|
||||
reconnecting: 'Переподключение...',
|
||||
retry: 'Повторить',
|
||||
},
|
||||
weixin: {
|
||||
scanLogin: 'Войти в WeChat по QR-коду',
|
||||
scanQRCode:
|
||||
'Отсканируйте QR-код ниже в WeChat, чтобы авторизоваться и автоматически заполнить токен',
|
||||
loginSuccess: 'Вход выполнен успешно! Токен заполнен автоматически',
|
||||
loginFailed: 'Не удалось выполнить вход',
|
||||
},
|
||||
dingtalk: {
|
||||
createApp: 'Создать приложение DingTalk в один клик',
|
||||
scanQRCode:
|
||||
'Отсканируйте QR-код ниже в DingTalk, чтобы авторизоваться и автоматически создать приложение',
|
||||
waitingForScan: 'Ожидание сканирования',
|
||||
createSuccess:
|
||||
'Приложение успешно создано! Учётные данные заполнены автоматически',
|
||||
createFailed: 'Не удалось создать приложение',
|
||||
connecting: 'Подключение к сервису DingTalk...',
|
||||
retry: 'Повторить',
|
||||
robotCodeNote:
|
||||
'Код робота нельзя получить автоматически. Перейдите в консоль разработчика DingTalk > Настройки робота и скопируйте его вручную. Он нужен для таких функций, как распознавание изображений и загрузка файлов.',
|
||||
},
|
||||
wecombot: {
|
||||
createBot: 'Создать бота WeCom в один клик',
|
||||
scanQRCode:
|
||||
'Отсканируйте QR-код ниже в WeCom, чтобы авторизоваться и автоматически создать бота',
|
||||
waitingForScan: 'Ожидание сканирования',
|
||||
createSuccess: 'Бот успешно создан! Учётные данные заполнены автоматически',
|
||||
createFailed: 'Не удалось создать бота',
|
||||
connecting: 'Подключение к сервису WeCom...',
|
||||
retry: 'Повторить',
|
||||
robotNameNote:
|
||||
'Имя бота нельзя получить автоматически. Пожалуйста, введите его вручную.',
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: 'Выберите страницу плагина на боковой панели',
|
||||
invalidPage: 'Недопустимая страница плагина',
|
||||
|
||||
@@ -47,6 +47,8 @@ const thTH = {
|
||||
success: 'สำเร็จ',
|
||||
save: 'บันทึก',
|
||||
saving: 'กำลังบันทึก...',
|
||||
recommend: 'แนะนำ',
|
||||
start: 'เริ่ม',
|
||||
confirm: 'ยืนยัน',
|
||||
confirmDelete: 'ยืนยันการลบ',
|
||||
deleteConfirmation: 'คุณแน่ใจหรือไม่ว่าต้องการลบสิ่งนี้?',
|
||||
@@ -994,6 +996,10 @@ const thTH = {
|
||||
engineSettings: 'การตั้งค่าเครื่องมือ',
|
||||
engineSettingsDescription: 'การกำหนดค่าสำหรับเครื่องมือความรู้ที่เลือก',
|
||||
engineSettingsReadonly: 'อ่านอย่างเดียวในโหมดแก้ไข',
|
||||
engineSettingsInvalid:
|
||||
'การตั้งค่าเครื่องมือไม่ถูกต้อง โปรดตรวจสอบฟิลด์ที่จำเป็น',
|
||||
retrievalSettingsInvalid:
|
||||
'การตั้งค่าการดึงข้อมูลไม่ถูกต้อง โปรดตรวจสอบฟิลด์ที่จำเป็น',
|
||||
retrievalSettings: 'การตั้งค่าการดึงข้อมูล',
|
||||
retrievalSettingsDescription: 'กำหนดค่าวิธีดึงเอกสารจากฐานความรู้นี้',
|
||||
dangerZone: 'โซนอันตราย',
|
||||
@@ -1423,6 +1429,50 @@ const thTH = {
|
||||
retryFailed:
|
||||
'ยังไม่สามารถเชื่อมต่อแบ็กเอนด์ได้ โปรดเริ่มบริการแล้วลองใหม่อีกครั้ง',
|
||||
},
|
||||
feishu: {
|
||||
createApp: 'สร้างแอป Feishu ด้วยคลิกเดียว',
|
||||
scanQRCode:
|
||||
'สแกนคิวอาร์โค้ดด้านล่างด้วย Feishu เพื่ออนุญาตและสร้างแอปโดยอัตโนมัติ',
|
||||
waitingForScan: 'กำลังรอสแกน',
|
||||
createSuccess: 'สร้างแอปสำเร็จแล้ว และกรอกข้อมูลรับรองให้อัตโนมัติ',
|
||||
createFailed: 'สร้างแอปไม่สำเร็จ',
|
||||
connecting: 'กำลังเชื่อมต่อบริการ Feishu...',
|
||||
expired: 'คิวอาร์โค้ดหมดอายุแล้ว กรุณาลองใหม่',
|
||||
denied: 'ผู้ใช้ปฏิเสธการอนุญาต',
|
||||
connectionLost: 'การเชื่อมต่อขาดหาย กรุณาลองใหม่',
|
||||
reconnecting: 'กำลังเชื่อมต่อใหม่...',
|
||||
retry: 'ลองใหม่',
|
||||
},
|
||||
weixin: {
|
||||
scanLogin: 'เข้าสู่ระบบ WeChat ด้วยคิวอาร์โค้ด',
|
||||
scanQRCode:
|
||||
'สแกนคิวอาร์โค้ดด้านล่างด้วย WeChat เพื่ออนุญาตและกรอกโทเคนอัตโนมัติ',
|
||||
loginSuccess: 'เข้าสู่ระบบสำเร็จ และกรอกโทเคนอัตโนมัติแล้ว',
|
||||
loginFailed: 'เข้าสู่ระบบไม่สำเร็จ',
|
||||
},
|
||||
dingtalk: {
|
||||
createApp: 'สร้างแอป DingTalk ด้วยคลิกเดียว',
|
||||
scanQRCode:
|
||||
'สแกนคิวอาร์โค้ดด้านล่างด้วย DingTalk เพื่ออนุญาตและสร้างแอปโดยอัตโนมัติ',
|
||||
waitingForScan: 'กำลังรอสแกน',
|
||||
createSuccess: 'สร้างแอปสำเร็จแล้ว และกรอกข้อมูลรับรองให้อัตโนมัติ',
|
||||
createFailed: 'สร้างแอปไม่สำเร็จ',
|
||||
connecting: 'กำลังเชื่อมต่อบริการ DingTalk...',
|
||||
retry: 'ลองใหม่',
|
||||
robotCodeNote:
|
||||
'ไม่สามารถดึงรหัส Robot ได้โดยอัตโนมัติ กรุณาไปที่หลังบ้านนักพัฒนา DingTalk > การตั้งค่า Robot เพื่อคัดลอกด้วยตนเอง ฟิลด์นี้จำเป็นสำหรับฟังก์ชันอย่างการรู้จำภาพและการอัปโหลดไฟล์',
|
||||
},
|
||||
wecombot: {
|
||||
createBot: 'สร้างบอต WeCom ด้วยคลิกเดียว',
|
||||
scanQRCode:
|
||||
'สแกนคิวอาร์โค้ดด้านล่างด้วย WeCom เพื่ออนุญาตและสร้างบอตโดยอัตโนมัติ',
|
||||
waitingForScan: 'กำลังรอสแกน',
|
||||
createSuccess: 'สร้างบอตสำเร็จแล้ว และกรอกข้อมูลรับรองให้อัตโนมัติ',
|
||||
createFailed: 'สร้างบอตไม่สำเร็จ',
|
||||
connecting: 'กำลังเชื่อมต่อบริการ WeCom...',
|
||||
retry: 'ลองใหม่',
|
||||
robotNameNote: 'ไม่สามารถดึงชื่อบอตได้โดยอัตโนมัติ กรุณากรอกด้วยตนเอง',
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: 'เลือกหน้าปลั๊กอินจากแถบด้านข้าง',
|
||||
invalidPage: 'หน้าปลั๊กอินไม่ถูกต้อง',
|
||||
|
||||
@@ -48,6 +48,8 @@ const viVN = {
|
||||
success: 'Thành công',
|
||||
save: 'Lưu',
|
||||
saving: 'Đang lưu...',
|
||||
recommend: 'Đề xuất',
|
||||
start: 'Bắt đầu',
|
||||
confirm: 'Xác nhận',
|
||||
confirmDelete: 'Xác nhận xóa',
|
||||
deleteConfirmation: 'Bạn có chắc chắn muốn xóa mục này không?',
|
||||
@@ -1008,6 +1010,10 @@ const viVN = {
|
||||
engineSettings: 'Cài đặt công cụ',
|
||||
engineSettingsDescription: 'Cấu hình cho công cụ tri thức đã chọn',
|
||||
engineSettingsReadonly: 'chỉ đọc trong chế độ chỉnh sửa',
|
||||
engineSettingsInvalid:
|
||||
'Cài đặt công cụ không hợp lệ, vui lòng kiểm tra các trường bắt buộc',
|
||||
retrievalSettingsInvalid:
|
||||
'Cài đặt truy xuất không hợp lệ, vui lòng kiểm tra các trường bắt buộc',
|
||||
retrievalSettings: 'Cài đặt truy xuất',
|
||||
retrievalSettingsDescription:
|
||||
'Cấu hình cách truy xuất tài liệu từ cơ sở tri thức này',
|
||||
@@ -1447,6 +1453,52 @@ const viVN = {
|
||||
retryFailed:
|
||||
'Vẫn không thể kết nối backend. Hãy khởi động dịch vụ rồi thử lại.',
|
||||
},
|
||||
feishu: {
|
||||
createApp: 'Tạo ứng dụng Feishu chỉ với một lần nhấp',
|
||||
scanQRCode:
|
||||
'Quét mã QR bên dưới bằng Feishu để ủy quyền và tự động tạo ứng dụng',
|
||||
waitingForScan: 'Đang chờ quét',
|
||||
createSuccess:
|
||||
'Tạo ứng dụng thành công! Thông tin xác thực đã được điền tự động',
|
||||
createFailed: 'Tạo ứng dụng thất bại',
|
||||
connecting: 'Đang kết nối tới dịch vụ Feishu...',
|
||||
expired: 'Mã QR đã hết hạn, vui lòng thử lại',
|
||||
denied: 'Người dùng đã từ chối ủy quyền',
|
||||
connectionLost: 'Kết nối đã bị mất, vui lòng thử lại',
|
||||
reconnecting: 'Đang kết nối lại...',
|
||||
retry: 'Thử lại',
|
||||
},
|
||||
weixin: {
|
||||
scanLogin: 'Đăng nhập WeChat bằng mã QR',
|
||||
scanQRCode:
|
||||
'Quét mã QR bên dưới bằng WeChat để ủy quyền và tự động điền token',
|
||||
loginSuccess: 'Đăng nhập thành công! Token đã được điền tự động',
|
||||
loginFailed: 'Đăng nhập thất bại',
|
||||
},
|
||||
dingtalk: {
|
||||
createApp: 'Tạo ứng dụng DingTalk chỉ với một lần nhấp',
|
||||
scanQRCode:
|
||||
'Quét mã QR bên dưới bằng DingTalk để ủy quyền và tự động tạo ứng dụng',
|
||||
waitingForScan: 'Đang chờ quét',
|
||||
createSuccess:
|
||||
'Tạo ứng dụng thành công! Thông tin xác thực đã được điền tự động',
|
||||
createFailed: 'Tạo ứng dụng thất bại',
|
||||
connecting: 'Đang kết nối tới dịch vụ DingTalk...',
|
||||
retry: 'Thử lại',
|
||||
robotCodeNote:
|
||||
'Không thể tự động lấy Robot Code. Vui lòng vào trang quản trị nhà phát triển DingTalk > Cấu hình robot để sao chép thủ công. Trường này là bắt buộc cho các tính năng như nhận diện hình ảnh và tải tệp lên.',
|
||||
},
|
||||
wecombot: {
|
||||
createBot: 'Tạo bot WeCom chỉ với một lần nhấp',
|
||||
scanQRCode: 'Quét mã QR bên dưới bằng WeCom để ủy quyền và tự động tạo bot',
|
||||
waitingForScan: 'Đang chờ quét',
|
||||
createSuccess:
|
||||
'Tạo bot thành công! Thông tin xác thực đã được điền tự động',
|
||||
createFailed: 'Tạo bot thất bại',
|
||||
connecting: 'Đang kết nối tới dịch vụ WeCom...',
|
||||
retry: 'Thử lại',
|
||||
robotNameNote: 'Không thể tự động lấy tên bot. Vui lòng điền thủ công.',
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: 'Chọn một trang plugin từ thanh bên',
|
||||
invalidPage: 'Trang plugin không hợp lệ',
|
||||
|
||||
@@ -46,6 +46,8 @@ const zhHans = {
|
||||
success: '成功',
|
||||
save: '保存',
|
||||
saving: '保存中...',
|
||||
recommend: '推荐',
|
||||
start: '开始',
|
||||
confirm: '确认',
|
||||
confirmDelete: '确认删除',
|
||||
deleteConfirmation: '你确定要删除这个吗?',
|
||||
@@ -966,6 +968,8 @@ const zhHans = {
|
||||
engineSettings: '引擎设置',
|
||||
engineSettingsDescription: '所选知识引擎的配置',
|
||||
engineSettingsReadonly: '编辑模式下不可修改',
|
||||
engineSettingsInvalid: '引擎设置中存在无效项,请检查必填字段',
|
||||
retrievalSettingsInvalid: '检索设置中存在无效项,请检查必填字段',
|
||||
retrievalSettings: '检索设置',
|
||||
retrievalSettingsDescription: '配置从此知识库检索文档的方式',
|
||||
dangerZone: '危险区域',
|
||||
@@ -1493,6 +1497,47 @@ const zhHans = {
|
||||
retrying: '正在重试',
|
||||
retryFailed: '仍然无法连接后端,请确认服务已启动后再重试。',
|
||||
},
|
||||
feishu: {
|
||||
createApp: '一键创建飞书应用',
|
||||
scanQRCode: '请使用飞书扫描以下二维码,授权后将自动创建应用并填写凭据',
|
||||
waitingForScan: '等待扫码中',
|
||||
createSuccess: '应用创建成功!凭据已自动填入',
|
||||
createFailed: '创建失败',
|
||||
connecting: '正在连接飞书服务...',
|
||||
expired: '二维码已过期,请重试',
|
||||
denied: '用户已拒绝授权',
|
||||
connectionLost: '连接已断开,请重试',
|
||||
reconnecting: '正在重新连接...',
|
||||
retry: '重试',
|
||||
},
|
||||
weixin: {
|
||||
scanLogin: '扫码登录微信',
|
||||
scanQRCode: '请使用微信扫描以下二维码,授权后将自动登录并填写令牌',
|
||||
loginSuccess: '登录成功!令牌已自动填入',
|
||||
loginFailed: '登录失败',
|
||||
},
|
||||
dingtalk: {
|
||||
createApp: '一键创建钉钉应用',
|
||||
scanQRCode: '请使用钉钉扫描以下二维码,授权后将自动创建应用并填写凭据',
|
||||
waitingForScan: '等待扫码中',
|
||||
createSuccess: '应用创建成功!凭据已自动填入',
|
||||
createFailed: '创建失败',
|
||||
connecting: '正在连接钉钉服务...',
|
||||
retry: '重试',
|
||||
robotCodeNote:
|
||||
'机器人代码无法自动获取,请前往钉钉开发者后台 > 机器人配置中手动复制。识图、上传文件等功能需要填写此字段。',
|
||||
},
|
||||
wecombot: {
|
||||
createBot: '一键创建企业微信机器人',
|
||||
scanQRCode:
|
||||
'请使用企业微信扫描以下二维码,授权后将自动创建机器人并填写凭据',
|
||||
waitingForScan: '等待扫码中',
|
||||
createSuccess: '机器人创建成功!凭据已自动填入',
|
||||
createFailed: '创建失败',
|
||||
connecting: '正在连接企业微信服务...',
|
||||
retry: '重试',
|
||||
robotNameNote: '机器人名称无法自动获取,请手动填写。',
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: '从侧边栏选择一个插件页面',
|
||||
invalidPage: '无效的插件页面',
|
||||
|
||||
@@ -46,6 +46,8 @@ const zhHant = {
|
||||
success: '成功',
|
||||
save: '儲存',
|
||||
saving: '儲存中...',
|
||||
recommend: '推薦',
|
||||
start: '開始',
|
||||
confirm: '確認',
|
||||
confirmDelete: '確認刪除',
|
||||
deleteConfirmation: '您確定要刪除這個嗎?',
|
||||
@@ -959,6 +961,8 @@ const zhHant = {
|
||||
engineSettings: '引擎設定',
|
||||
engineSettingsDescription: '所選知識引擎的設定',
|
||||
engineSettingsReadonly: '編輯模式下不可修改',
|
||||
engineSettingsInvalid: '引擎設定中存在無效項,請檢查必填欄位',
|
||||
retrievalSettingsInvalid: '檢索設定中存在無效項,請檢查必填欄位',
|
||||
retrievalSettings: '檢索設定',
|
||||
retrievalSettingsDescription: '設定從此知識庫檢索文件的方式',
|
||||
dangerZone: '危險區域',
|
||||
@@ -1403,6 +1407,47 @@ const zhHant = {
|
||||
retrying: '正在重試',
|
||||
retryFailed: '仍然無法連接後端,請確認服務已啟動後再重試。',
|
||||
},
|
||||
feishu: {
|
||||
createApp: '一鍵建立飛書應用',
|
||||
scanQRCode: '請使用飛書掃描以下 QR Code,授權後將自動建立應用並填寫憑證',
|
||||
waitingForScan: '等待掃描中',
|
||||
createSuccess: '應用建立成功!憑證已自動填入',
|
||||
createFailed: '建立失敗',
|
||||
connecting: '正在連線飛書服務...',
|
||||
expired: 'QR Code 已過期,請重試',
|
||||
denied: '使用者已拒絕授權',
|
||||
connectionLost: '連線已斷開,請重試',
|
||||
reconnecting: '正在重新連線...',
|
||||
retry: '重試',
|
||||
},
|
||||
weixin: {
|
||||
scanLogin: '掃碼登入微信',
|
||||
scanQRCode: '請使用微信掃描以下 QR Code,授權後將自動登入並填寫令牌',
|
||||
loginSuccess: '登入成功!令牌已自動填入',
|
||||
loginFailed: '登入失敗',
|
||||
},
|
||||
dingtalk: {
|
||||
createApp: '一鍵建立釘釘應用',
|
||||
scanQRCode: '請使用釘釘掃描以下 QR Code,授權後將自動建立應用並填寫憑證',
|
||||
waitingForScan: '等待掃碼中',
|
||||
createSuccess: '應用建立成功!憑證已自動填入',
|
||||
createFailed: '建立失敗',
|
||||
connecting: '正在連線釘釘服務...',
|
||||
retry: '重試',
|
||||
robotCodeNote:
|
||||
'機器人代碼無法自動取得,請前往釘釘開發者後台 > 機器人設定中手動複製。識圖、上傳檔案等功能需要填寫此欄位。',
|
||||
},
|
||||
wecombot: {
|
||||
createBot: '一鍵建立企業微信機器人',
|
||||
scanQRCode:
|
||||
'請使用企業微信掃描以下 QR Code,授權後將自動建立機器人並填寫憑證',
|
||||
waitingForScan: '等待掃碼中',
|
||||
createSuccess: '機器人建立成功!憑證已自動填入',
|
||||
createFailed: '建立失敗',
|
||||
connecting: '正在連線企業微信服務...',
|
||||
retry: '重試',
|
||||
robotNameNote: '機器人名稱無法自動取得,請手動填寫。',
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: '從側邊欄選擇一個插件頁面',
|
||||
invalidPage: '無效的插件頁面',
|
||||
|
||||
Reference in New Issue
Block a user