Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion web/src/locales/en.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
{
"common": {
"save": "Save",
"cancel": "Cancel",
Expand Down Expand Up @@ -918,6 +918,10 @@
"createError": "Failed to create provider. Please check your connection and try again.",
"updateError": "Failed to update provider. Please check your connection and try again.",
"saveChanges": "Save Changes",
"clone": "Clone",
"cloning": "Cloning...",
"cloneSuffix": " (Copy)",
"cloneSuccess": "Cloned \"{{name}}\" successfully.",
"delete": "Delete",
"cancel": "Cancel",
"none": "None",
Expand Down Expand Up @@ -1083,3 +1087,4 @@
"emptyHint": "No model mappings configured. Add mappings to transform request models before sending to upstream."
}
}

5 changes: 5 additions & 0 deletions web/src/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -918,6 +918,10 @@
"createError": "创建提供商失败。请检查您的连接并重试。",
"updateError": "更新提供商失败。请检查您的连接并重试。",
"saveChanges": "保存更改",
"clone": "克隆",
"cloning": "克隆中...",
"cloneSuffix": "(副本)",
"cloneSuccess": "已成功克隆「{{name}}」。",
"delete": "删除",
"cancel": "取消",
"none": "无",
Expand Down Expand Up @@ -1082,3 +1086,4 @@
"emptyHint": "暂无模型映射。添加映射以在发送到上游前转换请求模型。"
}
}

118 changes: 111 additions & 7 deletions web/src/pages/providers/components/provider-edit-flow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
Key,
Check,
Trash2,
Copy,
Plus,
ArrowRight,
Zap,
Expand All @@ -20,6 +21,7 @@ import {
DialogFooter,
} from '@/components/ui/dialog';
import {
useCreateProvider,
useUpdateProvider,
useDeleteProvider,
useModelMappings,
Expand Down Expand Up @@ -280,11 +282,16 @@ type EditFormData = {
export function ProviderEditFlow({ provider, onClose }: ProviderEditFlowProps) {
const { t } = useTranslation();
const [saving, setSaving] = useState(false);
const [cloning, setCloning] = useState(false);
const [cloneToastMessage, setCloneToastMessage] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const createProvider = useCreateProvider();
const updateProvider = useUpdateProvider();
const deleteProvider = useDeleteProvider();
const createModelMapping = useCreateModelMapping();
const { data: allMappings } = useModelMappings();

Comment on lines +294 to 295
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

克隆时存在“映射未加载即被当成空集合”的正确性风险。

allMappings 还在加载时,当前实现会直接继续克隆并提示成功,导致新 provider 可能没有复制到任何映射。建议在映射加载完成前禁用克隆,或在克隆前显式拉取一次映射。

🔧 建议修复
- const { data: allMappings } = useModelMappings();
+ const { data: allMappings, isLoading: mappingsLoading } = useModelMappings();

- if (!isValid() || cloning || cloneToastMessage) return;
+ if (!isValid() || cloning || cloneToastMessage || mappingsLoading) return;

  <Button
    onClick={handleClone}
-   disabled={cloning || saving || !isValid() || !!cloneToastMessage}
+   disabled={cloning || saving || mappingsLoading || !isValid() || !!cloneToastMessage}
    variant={'outline'}
  >

Also applies to: 449-451, 565-568

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/pages/providers/components/provider-edit-flow.tsx` around lines 294 -
295, The clone logic uses useModelMappings() (allMappings) but proceeds even
when mappings are still loading, so update the clone flow (the handler that
performs the provider clone — e.g., the clone button handler like onClone /
handleCloneProvider) to wait for mappings to finish: either disable the clone
action while useModelMappings reports loading, or call the mappings
refetch/fetch method and await completion before performing the clone and
showing success; ensure you reference and use the allMappings/loading/refetch
values returned by useModelMappings and apply the same guard in the other clone
sites noted (around the blocks corresponding to lines ~449-451 and ~565-568).

const initClients = (): ClientConfig[] => {
const supportedTypes = provider.supportedClientTypes || [];
Expand Down Expand Up @@ -323,20 +330,20 @@ export function ProviderEditFlow({ provider, onClose }: ProviderEditFlowProps) {
return hasEnabledClient && hasUrl;
};

const parseSensitiveWords = (value: string): string[] => {
return value
.split(/[\n,]/)
.map((item) => item.trim())
.filter(Boolean);
};

const handleSave = async () => {
if (!isValid()) return;

setSaving(true);
setSaveStatus('idle');

try {
const parseSensitiveWords = (value: string): string[] => {
return value
.split(/[\n,]/)
.map((item) => item.trim())
.filter(Boolean);
};

const supportedClientTypes = formData.clients.filter((c) => c.enabled).map((c) => c.id);
const clientBaseURL: Partial<Record<ClientType, string>> = {};
const clientMultiplier: Partial<Record<ClientType, number>> = {};
Expand Down Expand Up @@ -387,6 +394,89 @@ export function ProviderEditFlow({ provider, onClose }: ProviderEditFlowProps) {
}
};

const handleClone = async () => {
if (!isValid() || cloning || cloneToastMessage) return;

setCloning(true);

try {
const supportedClientTypes = formData.clients.filter((c) => c.enabled).map((c) => c.id);
const clientBaseURL: Partial<Record<ClientType, string>> = {};
const clientMultiplier: Partial<Record<ClientType, number>> = {};
formData.clients.forEach((c) => {
if (c.enabled && c.urlOverride) {
clientBaseURL[c.id] = c.urlOverride;
}
if (c.enabled && c.multiplier !== 10000) {
clientMultiplier[c.id] = c.multiplier;
}
});

const baseName = formData.name.trim() || provider.name;
const suffix = t('provider.cloneSuffix');
const cloneName = baseName.endsWith(suffix) ? baseName : `${baseName}${suffix}`;

const data: CreateProviderData = {
type: provider.type || 'custom',
name: cloneName,
logo: provider.logo,
config: {
disableErrorCooldown: !!formData.disableErrorCooldown,
custom: {
baseURL: formData.baseURL,
apiKey: formData.apiKey || provider.config?.custom?.apiKey || '',
clientBaseURL: Object.keys(clientBaseURL).length > 0 ? clientBaseURL : undefined,
clientMultiplier:
Object.keys(clientMultiplier).length > 0 ? clientMultiplier : undefined,
cloak:
formData.cloakMode !== 'auto' ||
formData.cloakStrictMode ||
parseSensitiveWords(formData.cloakSensitiveWords || '').length > 0
? {
mode: formData.cloakMode,
strictMode: formData.cloakStrictMode,
sensitiveWords: parseSensitiveWords(formData.cloakSensitiveWords || ''),
}
: undefined,
},
},
supportedClientTypes,
supportModels: formData.supportModels.length > 0 ? formData.supportModels : undefined,
};

const newProvider = await createProvider.mutateAsync(data);

const providerMappings = (allMappings || []).filter(
(mapping) => mapping.scope === 'provider' && mapping.providerID === provider.id,
);

if (providerMappings.length > 0) {
for (const mapping of providerMappings) {
await createModelMapping.mutateAsync({
scope: mapping.scope,
clientType: mapping.clientType,
providerType: mapping.providerType,
providerID: newProvider.id,
projectID: mapping.projectID,
routeID: mapping.routeID,
apiTokenID: mapping.apiTokenID,
pattern: mapping.pattern,
target: mapping.target,
priority: mapping.priority,
isEnabled: mapping.isEnabled,
});
}
}

setCloneToastMessage(t('provider.cloneSuccess', { name: cloneName }));
setTimeout(() => onClose(), 800);
} catch (error) {
console.error('Failed to clone provider:', error);
} finally {
setCloning(false);
}
Comment on lines +453 to +477
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

映射复制失败会产生“部分克隆”且缺少明确失败反馈。

当前实现中,provider 创建成功后若某条映射复制失败,会进入 catch 仅打印日志;用户侧没有失败/部分成功提示,数据容易处于不一致状态。建议统计失败数并区分“成功 / 部分成功 / 失败”提示。

🔧 建议修复
- if (providerMappings.length > 0) {
-   for (const mapping of providerMappings) {
-     await createModelMapping.mutateAsync({
-       ...
-     });
-   }
- }
-
- setCloneToastMessage(t('provider.cloneSuccess', { name: cloneName }));
+ let failedCount = 0;
+ for (const mapping of providerMappings) {
+   try {
+     await createModelMapping.mutateAsync({
+       ...
+     });
+   } catch (err) {
+     failedCount += 1;
+   }
+ }
+
+ setCloneToastMessage(
+   failedCount === 0
+     ? t('provider.cloneSuccess', { name: cloneName })
+     : t('provider.clonePartialSuccess', { name: cloneName, failed: failedCount }),
+ );

  ...
  } catch (error) {
    console.error('Failed to clone provider:', error);
+   setCloneToastMessage(t('provider.cloneError'));
  }

Also applies to: 717-721

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/pages/providers/components/provider-edit-flow.tsx` around lines 453 -
477, When cloning provider mappings inside the try block (looping over
providerMappings and calling createModelMapping.mutateAsync), capture
per-mapping success/failure counts and errors instead of only console.error in
the catch; update the logic around createModelMapping.mutateAsync, the catch
block, setCloneToastMessage, and onClose/newProvider handling to show three
outcomes: all succeeded (current success toast), partial success (toast
indicating X succeeded / Y failed with brief error summary), or total failure
(error toast), and ensure setCloning(false) remains in finally; include
references to providerMappings, createModelMapping.mutateAsync, newProvider.id,
cloneName, setCloneToastMessage, onClose, and setCloning when implementing the
counting and user-facing messages.

};

const handleDelete = async () => {
setDeleting(true);
try {
Expand Down Expand Up @@ -472,6 +562,14 @@ export function ProviderEditFlow({ provider, onClose }: ProviderEditFlowProps) {
<Trash2 size={14} />
{t('provider.delete')}
</Button>
<Button
onClick={handleClone}
disabled={cloning || saving || !isValid() || !!cloneToastMessage}
variant={'outline'}
>
<Copy size={14} />
{cloning ? t('provider.cloning') : t('provider.clone')}
</Button>
<Button onClick={onClose} variant={'secondary'}>
{t('provider.cancel')}
</Button>
Expand Down Expand Up @@ -616,6 +714,12 @@ export function ProviderEditFlow({ provider, onClose }: ProviderEditFlowProps) {
</div>
</div>

{cloneToastMessage && (
<div className="fixed bottom-6 right-6 bg-card border border-border rounded-lg shadow-lg p-4 z-50">
<div className="text-sm font-medium text-foreground">{cloneToastMessage}</div>
</div>
)}

<DeleteConfirmModal
providerName={provider.name}
deleting={deleting}
Expand Down