Files
cc-switch/src/App.tsx
YoVinchen 3d69da5b66 feat: add model configuration support and fix Gemini deeplink bug (#251)
* feat(providers): add notes field for provider management

- Add notes field to Provider model (backend and frontend)
- Display notes with higher priority than URL in provider card
- Style notes as non-clickable text to differentiate from URLs
- Add notes input field in provider form
- Add i18n support (zh/en) for notes field

* chore: format code and clean up unused props

- Run cargo fmt on Rust backend code
- Format TypeScript imports and code style
- Remove unused appId prop from ProviderPresetSelector
- Clean up unused variables in tests
- Integrate notes field handling in provider dialogs

* feat(deeplink): implement ccswitch:// protocol for provider import

Add deep link support to enable one-click provider configuration import via ccswitch:// URLs.

Backend:
- Implement URL parsing and validation (src-tauri/src/deeplink.rs)
- Add Tauri commands for parse and import (src-tauri/src/commands/deeplink.rs)
- Register ccswitch:// protocol in macOS Info.plist
- Add comprehensive unit tests (src-tauri/tests/deeplink_import.rs)

Frontend:
- Create confirmation dialog with security review UI (src/components/DeepLinkImportDialog.tsx)
- Add API wrapper (src/lib/api/deeplink.ts)
- Integrate event listeners in App.tsx

Configuration:
- Update Tauri config for deep link handling
- Add i18n support for Chinese and English
- Include test page for deep link validation (deeplink-test.html)

Files: 15 changed, 1312 insertions(+)

* chore(deeplink): integrate deep link handling into app lifecycle

Wire up deep link infrastructure with app initialization and event handling.

Backend Integration:
- Register deep link module and commands in mod.rs
- Add URL handling in app setup (src-tauri/src/lib.rs:handle_deeplink_url)
- Handle deep links from single instance callback (Windows/Linux CLI)
- Handle deep links from macOS system events
- Add tauri-plugin-deep-link dependency (Cargo.toml)

Frontend Integration:
- Listen for deeplink-import/deeplink-error events in App.tsx
- Update DeepLinkImportDialog component imports

Configuration:
- Enable deep link plugin in tauri.conf.json
- Update Cargo.lock for new dependencies

Localization:
- Add Chinese translations for deep link UI (zh.json)
- Add English translations for deep link UI (en.json)

Files: 9 changed, 359 insertions(+), 18 deletions(-)

* refactor(deeplink): enhance Codex provider template generation

Align deep link import with UI preset generation logic by:
- Adding complete config.toml template matching frontend defaults
- Generating safe provider name from sanitized input
- Including model_provider, reasoning_effort, and wire_api settings
- Removing minimal template that only contained base_url
- Cleaning up deprecated test file deeplink-test.html

* style: fix clippy uninlined_format_args warnings

Apply clippy --fix to use inline format arguments in:
- src/mcp.rs (8 fixes)
- src/services/env_manager.rs (10 fixes)

* style: apply code formatting and cleanup

- Format TypeScript files with Prettier (App.tsx, EnvWarningBanner.tsx, formatters.ts)
- Organize Rust imports and module order alphabetically
- Add newline at end of JSON files (en.json, zh.json)
- Update Cargo.lock for dependency changes

* feat: add model name configuration support for Codex and fix Gemini model handling

- Add visual model name input field for Codex providers
  - Add model name extraction and update utilities in providerConfigUtils
  - Implement model name state management in useCodexConfigState hook
  - Add conditional model field rendering in CodexFormFields (non-official only)
  - Integrate model name sync with TOML config in ProviderForm

- Fix Gemini deeplink model injection bug
  - Correct environment variable name from GOOGLE_GEMINI_MODEL to GEMINI_MODEL
  - Add test cases for Gemini model injection (with/without model)
  - All tests passing (9/9)

- Fix Gemini model field binding in edit mode
  - Add geminiModel state to useGeminiConfigState hook
  - Extract model value during initialization and reset
  - Sync model field with geminiEnv state to prevent data loss on submit
  - Fix missing model value display when editing Gemini providers

Changes:
  - 6 files changed, 245 insertions(+), 13 deletions(-)
2025-11-19 09:03:18 +08:00

421 lines
13 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Plus, Settings, Edit3 } from "lucide-react";
import type { Provider } from "@/types";
import type { EnvConflict } from "@/types/env";
import { useProvidersQuery } from "@/lib/query";
import {
providersApi,
settingsApi,
type AppId,
type ProviderSwitchEvent,
} from "@/lib/api";
import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
import { useProviderActions } from "@/hooks/useProviderActions";
import { extractErrorMessage } from "@/utils/errorUtils";
import { AppSwitcher } from "@/components/AppSwitcher";
import { ProviderList } from "@/components/providers/ProviderList";
import { AddProviderDialog } from "@/components/providers/AddProviderDialog";
import { EditProviderDialog } from "@/components/providers/EditProviderDialog";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { SettingsDialog } from "@/components/settings/SettingsDialog";
import { UpdateBadge } from "@/components/UpdateBadge";
import { EnvWarningBanner } from "@/components/env/EnvWarningBanner";
import UsageScriptModal from "@/components/UsageScriptModal";
import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
import PromptPanel from "@/components/prompts/PromptPanel";
import { SkillsPage } from "@/components/skills/SkillsPage";
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
function App() {
const { t } = useTranslation();
const [activeApp, setActiveApp] = useState<AppId>("claude");
const [isEditMode, setIsEditMode] = useState(false);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isAddOpen, setIsAddOpen] = useState(false);
const [isMcpOpen, setIsMcpOpen] = useState(false);
const [isPromptOpen, setIsPromptOpen] = useState(false);
const [isSkillsOpen, setIsSkillsOpen] = useState(false);
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
const [usageProvider, setUsageProvider] = useState<Provider | null>(null);
const [confirmDelete, setConfirmDelete] = useState<Provider | null>(null);
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
const [showEnvBanner, setShowEnvBanner] = useState(false);
const { data, isLoading, refetch } = useProvidersQuery(activeApp);
const providers = useMemo(() => data?.providers ?? {}, [data]);
const currentProviderId = data?.currentProviderId ?? "";
// 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作
const {
addProvider,
updateProvider,
switchProvider,
deleteProvider,
saveUsageScript,
} = useProviderActions(activeApp);
// 监听来自托盘菜单的切换事件
useEffect(() => {
let unsubscribe: (() => void) | undefined;
const setupListener = async () => {
try {
unsubscribe = await providersApi.onSwitched(
async (event: ProviderSwitchEvent) => {
if (event.appType === activeApp) {
await refetch();
}
},
);
} catch (error) {
console.error("[App] Failed to subscribe provider switch event", error);
}
};
setupListener();
return () => {
unsubscribe?.();
};
}, [activeApp, refetch]);
// 应用启动时检测所有应用的环境变量冲突
useEffect(() => {
const checkEnvOnStartup = async () => {
try {
const allConflicts = await checkAllEnvConflicts();
const flatConflicts = Object.values(allConflicts).flat();
if (flatConflicts.length > 0) {
setEnvConflicts(flatConflicts);
setShowEnvBanner(true);
}
} catch (error) {
console.error(
"[App] Failed to check environment conflicts on startup:",
error,
);
}
};
checkEnvOnStartup();
}, []);
// 切换应用时检测当前应用的环境变量冲突
useEffect(() => {
const checkEnvOnSwitch = async () => {
try {
const conflicts = await checkEnvConflicts(activeApp);
if (conflicts.length > 0) {
// 合并新检测到的冲突
setEnvConflicts((prev) => {
const existingKeys = new Set(
prev.map((c) => `${c.varName}:${c.sourcePath}`),
);
const newConflicts = conflicts.filter(
(c) => !existingKeys.has(`${c.varName}:${c.sourcePath}`),
);
return [...prev, ...newConflicts];
});
setShowEnvBanner(true);
}
} catch (error) {
console.error(
"[App] Failed to check environment conflicts on app switch:",
error,
);
}
};
checkEnvOnSwitch();
}, [activeApp]);
// 打开网站链接
const handleOpenWebsite = async (url: string) => {
try {
await settingsApi.openExternal(url);
} catch (error) {
const detail =
extractErrorMessage(error) ||
t("notifications.openLinkFailed", {
defaultValue: "链接打开失败",
});
toast.error(detail);
}
};
// 编辑供应商
const handleEditProvider = async (provider: Provider) => {
await updateProvider(provider);
setEditingProvider(null);
};
// 确认删除供应商
const handleConfirmDelete = async () => {
if (!confirmDelete) return;
await deleteProvider(confirmDelete.id);
setConfirmDelete(null);
};
// 复制供应商
const handleDuplicateProvider = async (provider: Provider) => {
// 1⃣ 计算新的 sortIndex如果原供应商有 sortIndex则复制它
const newSortIndex =
provider.sortIndex !== undefined ? provider.sortIndex + 1 : undefined;
const duplicatedProvider: Omit<Provider, "id" | "createdAt"> = {
name: `${provider.name} copy`,
settingsConfig: JSON.parse(JSON.stringify(provider.settingsConfig)), // 深拷贝
websiteUrl: provider.websiteUrl,
category: provider.category,
sortIndex: newSortIndex, // 复制原 sortIndex + 1
meta: provider.meta
? JSON.parse(JSON.stringify(provider.meta))
: undefined, // 深拷贝
};
// 2⃣ 如果原供应商有 sortIndex需要将后续所有供应商的 sortIndex +1
if (provider.sortIndex !== undefined) {
const updates = Object.values(providers)
.filter(
(p) =>
p.sortIndex !== undefined &&
p.sortIndex >= newSortIndex! &&
p.id !== provider.id,
)
.map((p) => ({
id: p.id,
sortIndex: p.sortIndex! + 1,
}));
// 先更新现有供应商的 sortIndex为新供应商腾出位置
if (updates.length > 0) {
try {
await providersApi.updateSortOrder(updates, activeApp);
} catch (error) {
console.error("[App] Failed to update sort order", error);
toast.error(
t("provider.sortUpdateFailed", {
defaultValue: "排序更新失败",
}),
);
return; // 如果排序更新失败,不继续添加
}
}
}
// 3⃣ 添加复制的供应商
await addProvider(duplicatedProvider);
};
// 导入配置成功后刷新
const handleImportSuccess = async () => {
await refetch();
try {
await providersApi.updateTrayMenu();
} catch (error) {
console.error("[App] Failed to refresh tray menu", error);
}
};
return (
<div className="flex h-screen flex-col bg-gray-50 dark:bg-gray-950">
{/* 环境变量警告横幅 */}
{showEnvBanner && envConflicts.length > 0 && (
<EnvWarningBanner
conflicts={envConflicts}
onDismiss={() => setShowEnvBanner(false)}
onDeleted={async () => {
// 删除后重新检测
try {
const allConflicts = await checkAllEnvConflicts();
const flatConflicts = Object.values(allConflicts).flat();
setEnvConflicts(flatConflicts);
if (flatConflicts.length === 0) {
setShowEnvBanner(false);
}
} catch (error) {
console.error(
"[App] Failed to re-check conflicts after deletion:",
error,
);
}
}}
/>
)}
<header className="flex-shrink-0 border-b border-gray-200 bg-white px-6 py-4 dark:border-gray-800 dark:bg-gray-900">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-1">
<a
href="https://github.com/farion1231/cc-switch"
target="_blank"
rel="noreferrer"
className="text-xl font-semibold text-blue-500 transition-colors hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
>
CC Switch
</a>
<Button
variant="ghost"
size="icon"
onClick={() => setIsSettingsOpen(true)}
title={t("common.settings")}
className="ml-2"
>
<Settings className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setIsEditMode(!isEditMode)}
title={t(
isEditMode ? "header.exitEditMode" : "header.enterEditMode",
)}
className={
isEditMode
? "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
: ""
}
>
<Edit3 className="h-4 w-4" />
</Button>
<UpdateBadge onClick={() => setIsSettingsOpen(true)} />
</div>
<div className="flex flex-wrap items-center gap-2">
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
<Button
variant="mcp"
onClick={() => setIsPromptOpen(true)}
className="min-w-[80px]"
>
{t("prompts.manage")}
</Button>
<Button
variant="mcp"
onClick={() => setIsMcpOpen(true)}
className="min-w-[80px]"
>
MCP
</Button>
<Button
variant="mcp"
onClick={() => setIsSkillsOpen(true)}
className="min-w-[80px]"
>
{t("skills.manage")}
</Button>
<Button onClick={() => setIsAddOpen(true)}>
<Plus className="h-4 w-4" />
{t("header.addProvider")}
</Button>
</div>
</div>
</header>
<main className="flex-1 overflow-y-scroll">
<div className="mx-auto max-w-4xl px-6 py-6">
<ProviderList
providers={providers}
currentProviderId={currentProviderId}
appId={activeApp}
isLoading={isLoading}
isEditMode={isEditMode}
onSwitch={switchProvider}
onEdit={setEditingProvider}
onDelete={setConfirmDelete}
onDuplicate={handleDuplicateProvider}
onConfigureUsage={setUsageProvider}
onOpenWebsite={handleOpenWebsite}
onCreate={() => setIsAddOpen(true)}
/>
</div>
</main>
<AddProviderDialog
open={isAddOpen}
onOpenChange={setIsAddOpen}
appId={activeApp}
onSubmit={addProvider}
/>
<EditProviderDialog
open={Boolean(editingProvider)}
provider={editingProvider}
onOpenChange={(open) => {
if (!open) {
setEditingProvider(null);
}
}}
onSubmit={handleEditProvider}
appId={activeApp}
/>
{usageProvider && (
<UsageScriptModal
provider={usageProvider}
appId={activeApp}
isOpen={Boolean(usageProvider)}
onClose={() => setUsageProvider(null)}
onSave={(script) => {
void saveUsageScript(usageProvider, script);
}}
/>
)}
<ConfirmDialog
isOpen={Boolean(confirmDelete)}
title={t("confirm.deleteProvider")}
message={
confirmDelete
? t("confirm.deleteProviderMessage", {
name: confirmDelete.name,
})
: ""
}
onConfirm={() => void handleConfirmDelete()}
onCancel={() => setConfirmDelete(null)}
/>
<SettingsDialog
open={isSettingsOpen}
onOpenChange={setIsSettingsOpen}
onImportSuccess={handleImportSuccess}
/>
<PromptPanel
open={isPromptOpen}
onOpenChange={setIsPromptOpen}
appId={activeApp}
/>
<UnifiedMcpPanel open={isMcpOpen} onOpenChange={setIsMcpOpen} />
<Dialog open={isSkillsOpen} onOpenChange={setIsSkillsOpen}>
<DialogContent className="max-w-4xl max-h-[85vh] min-h-[600px] flex flex-col p-0">
<DialogHeader className="sr-only">
<VisuallyHidden>
<DialogTitle>{t("skills.title")}</DialogTitle>
</VisuallyHidden>
</DialogHeader>
<SkillsPage onClose={() => setIsSkillsOpen(false)} />
</DialogContent>
</Dialog>
<DeepLinkImportDialog />
</div>
);
}
export default App;