Merge master into feat/workflow: resolve conflicts by keeping workflow branch changes

This commit is contained in:
Typer_Body
2026-05-21 00:51:09 +08:00
199 changed files with 39329 additions and 4604 deletions

View File

@@ -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 = () => {

View File

@@ -252,6 +252,7 @@ export default function BotForm({
type: parseDynamicFormItemType(item.type),
options: item.options,
show_if: item.show_if,
login_platform: item.login_platform,
}),
),
);

View File

@@ -16,6 +16,7 @@ export class DynamicFormItemConfig implements IDynamicFormItemSchema {
description?: I18nObject | string;
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;
}
}

View File

@@ -349,7 +349,9 @@ function NavItems({
tooltip={config.name}
>
{config.icon}
<span>{config.name}</span>
<span className="cursor-pointer select-none">
{config.name}
</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
@@ -739,7 +741,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 ? (
@@ -1119,7 +1123,7 @@ function PluginPagesNav() {
className="select-none"
>
{pluginIcon}
<span>{page.name}</span>
<span className="cursor-pointer">{page.name}</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
@@ -1139,7 +1143,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>
@@ -1155,7 +1159,9 @@ function PluginPagesNav() {
onClick={() => navigate(route)}
className="select-none"
>
<span>{page.name}</span>
<span className="cursor-pointer">
{page.name}
</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
);

View File

@@ -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);

View File

@@ -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() {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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[],

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -76,10 +76,12 @@ function MarketplaceContent() {
// Register task completion callback for toast and plugin list refresh
useEffect(() => {
const onComplete = (_taskId: number, success: boolean) => {
const onComplete = (_taskId: number, success: boolean, error?: string) => {
if (success) {
toast.success(t('plugins.installSuccess'));
refreshPlugins();
} else {
toast.error(error || t('plugins.installFailed'));
}
};
registerOnTaskComplete(onComplete);

View File

@@ -45,7 +45,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[];
@@ -224,13 +228,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) => {
@@ -289,7 +296,7 @@ export function PluginInstallTaskProvider({
}
if (exception) {
notifyTaskComplete(taskId, false);
notifyTaskComplete(taskId, false, exception);
return {
...t,
stage: InstallStage.ERROR,

View File

@@ -167,11 +167,13 @@ function PluginListView() {
// Register task completion callback for toast and plugin list refresh
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);

View File

@@ -21,6 +21,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 {
@@ -48,6 +49,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 {

View File

@@ -600,6 +600,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}`

View File

@@ -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]);