Files
cc-switch/src/hooks/useProviderActions.ts
T
Jason 3afec8a10f feat(backup): add pre-migration backup, periodic backup, backfill warning, and backup management UI
Four improvements to the database backup mechanism:

1. Auto backup before schema migration - creates a snapshot when
   upgrading from an older database version, providing a safety net
   beyond the existing SAVEPOINT rollback mechanism.

2. Periodic startup backup - checks on app launch whether the latest
   backup is older than 24 hours and creates a new one if needed,
   ensuring all users have recent backups regardless of usage patterns.

3. Backfill failure notification - switch now returns SwitchResult with
   warnings instead of silently ignoring backfill errors, so users are
   informed when their manual config changes may not have been saved.

4. Backup management UI - new BackupListSection in Settings > Data
   Management showing all backup snapshots with restore capability,
   including a confirmation dialog and automatic safety backup before
   restore.
2026-02-23 11:27:28 +08:00

293 lines
9.1 KiB
TypeScript

import { useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { providersApi, settingsApi, openclawApi, type AppId } from "@/lib/api";
import type {
Provider,
UsageScript,
OpenClawProviderConfig,
OpenClawDefaultModel,
} from "@/types";
import type { OpenClawSuggestedDefaults } from "@/config/openclawProviderPresets";
import {
useAddProviderMutation,
useUpdateProviderMutation,
useDeleteProviderMutation,
useSwitchProviderMutation,
} from "@/lib/query";
import { extractErrorMessage } from "@/utils/errorUtils";
import { openclawKeys } from "@/hooks/useOpenClaw";
/**
* Hook for managing provider actions (add, update, delete, switch)
* Extracts business logic from App.tsx
*/
export function useProviderActions(activeApp: AppId) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const addProviderMutation = useAddProviderMutation(activeApp);
const updateProviderMutation = useUpdateProviderMutation(activeApp);
const deleteProviderMutation = useDeleteProviderMutation(activeApp);
const switchProviderMutation = useSwitchProviderMutation(activeApp);
// Claude 插件同步逻辑
const syncClaudePlugin = useCallback(
async (provider: Provider) => {
if (activeApp !== "claude") return;
try {
const settings = await settingsApi.get();
if (!settings?.enableClaudePluginIntegration) {
return;
}
const isOfficial = provider.category === "official";
await settingsApi.applyClaudePluginConfig({ official: isOfficial });
// 静默执行,不显示成功通知
} catch (error) {
const detail =
extractErrorMessage(error) ||
t("notifications.syncClaudePluginFailed", {
defaultValue: "同步 Claude 插件失败",
});
toast.error(detail, { duration: 4200 });
}
},
[activeApp, t],
);
// 添加供应商
const addProvider = useCallback(
async (
provider: Omit<Provider, "id"> & {
providerKey?: string;
suggestedDefaults?: OpenClawSuggestedDefaults;
},
) => {
await addProviderMutation.mutateAsync(provider);
// OpenClaw: register models to allowlist after adding provider
if (activeApp === "openclaw" && provider.suggestedDefaults) {
const { model, modelCatalog } = provider.suggestedDefaults;
let modelsRegistered = false;
try {
// 1. Merge model catalog (allowlist)
if (modelCatalog && Object.keys(modelCatalog).length > 0) {
const existingCatalog = (await openclawApi.getModelCatalog()) || {};
const mergedCatalog = { ...existingCatalog, ...modelCatalog };
await openclawApi.setModelCatalog(mergedCatalog);
modelsRegistered = true;
}
// 2. Set default model (only if not already set)
if (model) {
const existingDefault = await openclawApi.getDefaultModel();
if (!existingDefault?.primary) {
await openclawApi.setDefaultModel(model);
}
}
// Show success toast if models were registered
if (modelsRegistered) {
toast.success(
t("notifications.openclawModelsRegistered", {
defaultValue: "模型已注册到 /model 列表",
}),
{ closeButton: true },
);
}
} catch (error) {
// Log warning but don't block main flow - provider config is already saved
console.warn(
"[OpenClaw] Failed to register models to allowlist:",
error,
);
}
}
},
[addProviderMutation, activeApp, t],
);
// 更新供应商
const updateProvider = useCallback(
async (provider: Provider) => {
await updateProviderMutation.mutateAsync(provider);
// 更新托盘菜单(失败不影响主操作)
try {
await providersApi.updateTrayMenu();
} catch (trayError) {
console.error(
"Failed to update tray menu after updating provider",
trayError,
);
}
},
[updateProviderMutation],
);
// 切换供应商
const switchProvider = useCallback(
async (provider: Provider) => {
try {
const result = await switchProviderMutation.mutateAsync(provider.id);
await syncClaudePlugin(provider);
// Show backfill warning if present
if (result?.warnings?.length) {
toast.warning(
t("notifications.backfillWarning", {
defaultValue:
"切换成功,但旧供应商配置回填失败,您手动修改的配置可能未保存",
}),
{ duration: 5000 },
);
}
// 根据供应商类型显示不同的成功提示
if (
activeApp === "claude" &&
provider.category !== "official" &&
provider.meta?.apiFormat === "openai_chat"
) {
// OpenAI Chat 格式供应商:显示代理提示
toast.info(
t("notifications.openAIChatFormatHint", {
defaultValue:
"此供应商使用 OpenAI Chat 格式,需要开启代理服务才能正常使用",
}),
{
duration: 5000,
closeButton: true,
},
);
} else {
// 普通供应商:显示切换成功
// OpenCode/OpenClaw: show "added to config" message instead of "switched"
const isMultiProviderApp =
activeApp === "opencode" || activeApp === "openclaw";
const messageKey = isMultiProviderApp
? "notifications.addToConfigSuccess"
: "notifications.switchSuccess";
const defaultMessage = isMultiProviderApp
? "已添加到配置"
: "切换成功!";
toast.success(t(messageKey, { defaultValue: defaultMessage }), {
closeButton: true,
});
}
} catch {
// 错误提示由 mutation 处理
}
},
[switchProviderMutation, syncClaudePlugin, activeApp, t],
);
// 删除供应商
const deleteProvider = useCallback(
async (id: string) => {
await deleteProviderMutation.mutateAsync(id);
},
[deleteProviderMutation],
);
// 保存用量脚本
const saveUsageScript = useCallback(
async (provider: Provider, script: UsageScript) => {
try {
const updatedProvider: Provider = {
...provider,
meta: {
...provider.meta,
usage_script: script,
},
};
await providersApi.update(updatedProvider, activeApp);
await queryClient.invalidateQueries({
queryKey: ["providers", activeApp],
});
// 🔧 保存用量脚本后,也应该失效该 provider 的用量查询缓存
// 这样主页列表会使用新配置重新查询,而不是使用测试时的缓存
await queryClient.invalidateQueries({
queryKey: ["usage", provider.id, activeApp],
});
toast.success(
t("provider.usageSaved", {
defaultValue: "用量查询配置已保存",
}),
{ closeButton: true },
);
} catch (error) {
const detail =
extractErrorMessage(error) ||
t("provider.usageSaveFailed", {
defaultValue: "用量查询配置保存失败",
});
toast.error(detail);
}
},
[activeApp, queryClient, t],
);
// Set provider as default model (OpenClaw only)
const setAsDefaultModel = useCallback(
async (provider: Provider) => {
const config = provider.settingsConfig as OpenClawProviderConfig;
if (!config.models || config.models.length === 0) {
toast.error(
t("notifications.openclawNoModels", {
defaultValue: "该供应商没有配置模型",
}),
);
return;
}
const model: OpenClawDefaultModel = {
primary: `${provider.id}/${config.models[0].id}`,
fallbacks: config.models.slice(1).map((m) => `${provider.id}/${m.id}`),
};
try {
await openclawApi.setDefaultModel(model);
await queryClient.invalidateQueries({
queryKey: openclawKeys.defaultModel,
});
toast.success(
t("notifications.openclawDefaultModelSet", {
defaultValue: "已设为默认模型",
}),
{ closeButton: true },
);
} catch (error) {
const detail =
extractErrorMessage(error) ||
t("notifications.openclawDefaultModelSetFailed", {
defaultValue: "设置默认模型失败",
});
toast.error(detail);
}
},
[queryClient, t],
);
return {
addProvider,
updateProvider,
switchProvider,
deleteProvider,
saveUsageScript,
setAsDefaultModel,
isLoading:
addProviderMutation.isPending ||
updateProviderMutation.isPending ||
deleteProviderMutation.isPending ||
switchProviderMutation.isPending,
};
}