mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 08:44:41 +08:00
revert: restore full config overwrite + Common Config Snippet (revert 992dda5c)
Revert the partial key-field merging refactoring introduced in992dda5c, along with two dependent commits (24fa8a18,87604b18) that referenced the now-removed ClaudeQuickToggles component. The whitelist-based partial merge approach had critical issues: - Non-whitelisted custom fields were lost during provider switching - Backfill permanently stripped non-key fields from the database - Whitelist required constant maintenance to track upstream changes This restores the proven "full config overwrite + Common Config Snippet" architecture where each provider stores its complete configuration and shared settings are managed via a separate snippet mechanism. Reverted commits: -24fa8a18: context-aware JSON editor hint + hide quick toggles -87604b18: hide ClaudeQuickToggles when creating -992dda5c: partial key-field merging refactoring Restored: - Full config snapshot write (write_live_snapshot) for Claude/Codex/Gemini - Full config backfill (settings_config = live_config) - Common Config Snippet UI and backend commands - 6 frontend components/hooks for common config editing - configApi barrel export and DB snippet methods Removed: - ClaudeQuickToggles component - write_live_partial / backfill_key_fields / patch_claude_live - All KEY_FIELDS constants
This commit is contained in:
@@ -192,7 +192,6 @@ export function EditProviderDialog({
|
||||
onCancel={() => onOpenChange(false)}
|
||||
initialData={initialData}
|
||||
showButtons={false}
|
||||
isCurrent={liveSettings !== null}
|
||||
/>
|
||||
</FullScreenPanel>
|
||||
);
|
||||
|
||||
@@ -10,11 +10,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import EndpointSpeedTest from "./EndpointSpeedTest";
|
||||
import { ApiKeySection, EndpointField } from "./shared";
|
||||
import type {
|
||||
ProviderCategory,
|
||||
ClaudeApiFormat,
|
||||
ClaudeApiKeyField,
|
||||
} from "@/types";
|
||||
import type { ProviderCategory, ClaudeApiFormat } from "@/types";
|
||||
import type { TemplateValueConfig } from "@/config/claudeProviderPresets";
|
||||
|
||||
interface EndpointCandidate {
|
||||
@@ -72,10 +68,6 @@ interface ClaudeFormFieldsProps {
|
||||
// API Format (for third-party providers that use OpenAI Chat Completions format)
|
||||
apiFormat: ClaudeApiFormat;
|
||||
onApiFormatChange: (format: ClaudeApiFormat) => void;
|
||||
|
||||
// Auth Key Field (ANTHROPIC_AUTH_TOKEN vs ANTHROPIC_API_KEY)
|
||||
apiKeyField: ClaudeApiKeyField;
|
||||
onApiKeyFieldChange: (field: ClaudeApiKeyField) => void;
|
||||
}
|
||||
|
||||
export function ClaudeFormFields({
|
||||
@@ -110,8 +102,6 @@ export function ClaudeFormFields({
|
||||
speedTestEndpoints,
|
||||
apiFormat,
|
||||
onApiFormatChange,
|
||||
apiKeyField,
|
||||
onApiKeyFieldChange,
|
||||
}: ClaudeFormFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -229,41 +219,6 @@ export function ClaudeFormFields({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 认证字段选择(仅非官方供应商显示) */}
|
||||
{shouldShowModelSelector && (
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="apiKeyField">
|
||||
{t("providerForm.authField", { defaultValue: "认证字段" })}
|
||||
</FormLabel>
|
||||
<Select
|
||||
value={apiKeyField}
|
||||
onValueChange={(v) => onApiKeyFieldChange(v as ClaudeApiKeyField)}
|
||||
>
|
||||
<SelectTrigger id="apiKeyField" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ANTHROPIC_AUTH_TOKEN">
|
||||
{t("providerForm.authFieldAuthToken", {
|
||||
defaultValue: "Auth Token (默认)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="ANTHROPIC_API_KEY">
|
||||
{t("providerForm.authFieldApiKey", {
|
||||
defaultValue: "API Key",
|
||||
})}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("providerForm.authFieldHint", {
|
||||
defaultValue:
|
||||
"大多数第三方供应商使用 Auth Token;少数供应商需要 API Key",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 模型选择器 */}
|
||||
{shouldShowModelSelector && (
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
||||
type ToggleKey = "hideAttribution" | "alwaysThinking" | "enableTeammates";
|
||||
|
||||
interface ClaudeQuickTogglesProps {
|
||||
/** Called after a patch is applied to the live file, so the caller can mirror it in the JSON editor. */
|
||||
onPatchApplied?: (patch: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
const defaultStates: Record<ToggleKey, boolean> = {
|
||||
hideAttribution: false,
|
||||
alwaysThinking: false,
|
||||
enableTeammates: false,
|
||||
};
|
||||
|
||||
function deriveStates(
|
||||
cfg: Record<string, unknown>,
|
||||
): Record<ToggleKey, boolean> {
|
||||
const env = cfg?.env as Record<string, unknown> | undefined;
|
||||
const attr = cfg?.attribution as Record<string, unknown> | undefined;
|
||||
return {
|
||||
hideAttribution: attr?.commit === "" && attr?.pr === "",
|
||||
alwaysThinking: cfg?.alwaysThinkingEnabled === true,
|
||||
enableTeammates: env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === "1",
|
||||
};
|
||||
}
|
||||
|
||||
/** Apply RFC 7396 JSON Merge Patch in-place: null = delete, object = recurse, else overwrite. */
|
||||
function jsonMergePatch(
|
||||
target: Record<string, unknown>,
|
||||
patch: Record<string, unknown>,
|
||||
) {
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === null || value === undefined) {
|
||||
delete target[key];
|
||||
} else if (typeof value === "object" && !Array.isArray(value)) {
|
||||
if (
|
||||
typeof target[key] !== "object" ||
|
||||
target[key] === null ||
|
||||
Array.isArray(target[key])
|
||||
) {
|
||||
target[key] = {};
|
||||
}
|
||||
jsonMergePatch(
|
||||
target[key] as Record<string, unknown>,
|
||||
value as Record<string, unknown>,
|
||||
);
|
||||
if (Object.keys(target[key] as Record<string, unknown>).length === 0) {
|
||||
delete target[key];
|
||||
}
|
||||
} else {
|
||||
target[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { jsonMergePatch };
|
||||
|
||||
export function ClaudeQuickToggles({
|
||||
onPatchApplied,
|
||||
}: ClaudeQuickTogglesProps) {
|
||||
const { t } = useTranslation();
|
||||
const [states, setStates] = useState(defaultStates);
|
||||
|
||||
const readLive = useCallback(async () => {
|
||||
try {
|
||||
const cfg = await invoke<Record<string, unknown>>(
|
||||
"read_live_provider_settings",
|
||||
{ app: "claude" },
|
||||
);
|
||||
setStates(deriveStates(cfg));
|
||||
} catch {
|
||||
// Live file missing or unreadable — show all unchecked
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
readLive();
|
||||
}, [readLive]);
|
||||
|
||||
const toggle = useCallback(
|
||||
async (key: ToggleKey) => {
|
||||
let patch: Record<string, unknown>;
|
||||
if (key === "hideAttribution") {
|
||||
patch = states.hideAttribution
|
||||
? { attribution: null }
|
||||
: { attribution: { commit: "", pr: "" } };
|
||||
} else if (key === "alwaysThinking") {
|
||||
patch = states.alwaysThinking
|
||||
? { alwaysThinkingEnabled: null }
|
||||
: { alwaysThinkingEnabled: true };
|
||||
} else {
|
||||
patch = states.enableTeammates
|
||||
? { env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: null } }
|
||||
: { env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: "1" } };
|
||||
}
|
||||
|
||||
// Optimistic update
|
||||
setStates((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
|
||||
try {
|
||||
await invoke("patch_claude_live_settings", { patch });
|
||||
onPatchApplied?.(patch);
|
||||
} catch {
|
||||
// Revert on failure
|
||||
readLive();
|
||||
}
|
||||
},
|
||||
[states, readLive, onPatchApplied],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
{(
|
||||
[
|
||||
["hideAttribution", "claudeConfig.hideAttribution"],
|
||||
["alwaysThinking", "claudeConfig.alwaysThinking"],
|
||||
["enableTeammates", "claudeConfig.enableTeammates"],
|
||||
] as const
|
||||
).map(([key, i18nKey]) => (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-center gap-1.5 text-sm cursor-pointer"
|
||||
>
|
||||
<Checkbox checked={states[key]} onCheckedChange={() => toggle(key)} />
|
||||
{t(i18nKey)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Save, Download, Loader2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import JsonEditor from "@/components/JsonEditor";
|
||||
|
||||
interface CodexCommonConfigModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: string;
|
||||
onExtract?: () => void;
|
||||
isExtracting?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* CodexCommonConfigModal - Common Codex configuration editor modal
|
||||
* Allows editing of common TOML configuration shared across providers
|
||||
*/
|
||||
export const CodexCommonConfigModal: React.FC<CodexCommonConfigModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
onExtract,
|
||||
isExtracting,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FullScreenPanel
|
||||
isOpen={isOpen}
|
||||
title={t("codexConfig.editCommonConfigTitle")}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
{onExtract && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onExtract}
|
||||
disabled={isExtracting}
|
||||
className="gap-2"
|
||||
>
|
||||
{isExtracting ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="w-4 h-4" />
|
||||
)}
|
||||
{t("codexConfig.extractFromCurrent", {
|
||||
defaultValue: "从编辑内容提取",
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="button" onClick={onClose} className="gap-2">
|
||||
<Save className="w-4 h-4" />
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("codexConfig.commonConfigHint")}
|
||||
</p>
|
||||
|
||||
<JsonEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={`# Common Codex config
|
||||
|
||||
# Add your common TOML configuration here`}
|
||||
darkMode={isDarkMode}
|
||||
rows={16}
|
||||
showValidation={false}
|
||||
language="javascript"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-500 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</FullScreenPanel>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { CodexAuthSection, CodexConfigSection } from "./CodexConfigSections";
|
||||
import { CodexCommonConfigModal } from "./CodexCommonConfigModal";
|
||||
|
||||
interface CodexConfigEditorProps {
|
||||
authValue: string;
|
||||
@@ -12,9 +13,23 @@ interface CodexConfigEditorProps {
|
||||
|
||||
onAuthBlur?: () => void;
|
||||
|
||||
useCommonConfig: boolean;
|
||||
|
||||
onCommonConfigToggle: (checked: boolean) => void;
|
||||
|
||||
commonConfigSnippet: string;
|
||||
|
||||
onCommonConfigSnippetChange: (value: string) => void;
|
||||
|
||||
commonConfigError: string;
|
||||
|
||||
authError: string;
|
||||
|
||||
configError: string;
|
||||
configError: string; // config.toml 错误提示
|
||||
|
||||
onExtract?: () => void;
|
||||
|
||||
isExtracting?: boolean;
|
||||
}
|
||||
|
||||
const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
||||
@@ -23,9 +38,25 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
||||
onAuthChange,
|
||||
onConfigChange,
|
||||
onAuthBlur,
|
||||
useCommonConfig,
|
||||
onCommonConfigToggle,
|
||||
commonConfigSnippet,
|
||||
onCommonConfigSnippetChange,
|
||||
commonConfigError,
|
||||
authError,
|
||||
configError,
|
||||
onExtract,
|
||||
isExtracting,
|
||||
}) => {
|
||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||
|
||||
// Auto-open common config modal if there's an error
|
||||
useEffect(() => {
|
||||
if (commonConfigError && !isCommonConfigModalOpen) {
|
||||
setIsCommonConfigModalOpen(true);
|
||||
}
|
||||
}, [commonConfigError, isCommonConfigModalOpen]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Auth JSON Section */}
|
||||
@@ -40,8 +71,23 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
||||
<CodexConfigSection
|
||||
value={configValue}
|
||||
onChange={onConfigChange}
|
||||
useCommonConfig={useCommonConfig}
|
||||
onCommonConfigToggle={onCommonConfigToggle}
|
||||
onEditCommonConfig={() => setIsCommonConfigModalOpen(true)}
|
||||
commonConfigError={commonConfigError}
|
||||
configError={configError}
|
||||
/>
|
||||
|
||||
{/* Common Config Modal */}
|
||||
<CodexCommonConfigModal
|
||||
isOpen={isCommonConfigModalOpen}
|
||||
onClose={() => setIsCommonConfigModalOpen(false)}
|
||||
value={commonConfigSnippet}
|
||||
onChange={onCommonConfigSnippetChange}
|
||||
error={commonConfigError}
|
||||
onExtract={onExtract}
|
||||
isExtracting={isExtracting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -78,6 +78,10 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
|
||||
interface CodexConfigSectionProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
useCommonConfig: boolean;
|
||||
onCommonConfigToggle: (checked: boolean) => void;
|
||||
onEditCommonConfig: () => void;
|
||||
commonConfigError?: string;
|
||||
configError?: string;
|
||||
}
|
||||
|
||||
@@ -87,6 +91,10 @@ interface CodexConfigSectionProps {
|
||||
export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
useCommonConfig,
|
||||
onCommonConfigToggle,
|
||||
onEditCommonConfig,
|
||||
commonConfigError,
|
||||
configError,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -109,12 +117,40 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="codexConfig"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
{t("codexConfig.configToml")}
|
||||
</label>
|
||||
<div className="flex items-center justify-between">
|
||||
<label
|
||||
htmlFor="codexConfig"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
{t("codexConfig.configToml")}
|
||||
</label>
|
||||
|
||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useCommonConfig}
|
||||
onChange={(e) => onCommonConfigToggle(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
||||
/>
|
||||
{t("codexConfig.writeCommonConfig")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditCommonConfig}
|
||||
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{t("codexConfig.editCommonConfig")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{commonConfigError && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400 text-right">
|
||||
{commonConfigError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<JsonEditor
|
||||
value={value}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Save, Download, Loader2 } from "lucide-react";
|
||||
import JsonEditor from "@/components/JsonEditor";
|
||||
|
||||
interface CommonConfigEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
useCommonConfig: boolean;
|
||||
onCommonConfigToggle: (checked: boolean) => void;
|
||||
commonConfigSnippet: string;
|
||||
onCommonConfigSnippetChange: (value: string) => void;
|
||||
commonConfigError: string;
|
||||
onEditClick: () => void;
|
||||
isModalOpen: boolean;
|
||||
onModalClose: () => void;
|
||||
onExtract?: () => void;
|
||||
isExtracting?: boolean;
|
||||
}
|
||||
|
||||
export function CommonConfigEditor({
|
||||
value,
|
||||
onChange,
|
||||
useCommonConfig,
|
||||
onCommonConfigToggle,
|
||||
commonConfigSnippet,
|
||||
onCommonConfigSnippetChange,
|
||||
commonConfigError,
|
||||
onEditClick,
|
||||
isModalOpen,
|
||||
onModalClose,
|
||||
onExtract,
|
||||
isExtracting,
|
||||
}: CommonConfigEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Mirror value prop to local state so checkbox toggles and JsonEditor stay in sync
|
||||
// (parent uses form.getValues which doesn't trigger re-renders)
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
const handleLocalChange = useCallback(
|
||||
(newValue: string) => {
|
||||
setLocalValue(newValue);
|
||||
onChange(newValue);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const toggleStates = useMemo(() => {
|
||||
try {
|
||||
const config = JSON.parse(localValue);
|
||||
return {
|
||||
hideAttribution:
|
||||
config?.attribution?.commit === "" && config?.attribution?.pr === "",
|
||||
alwaysThinking: config?.alwaysThinkingEnabled === true,
|
||||
teammates:
|
||||
config?.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === "1" ||
|
||||
config?.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === 1,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
hideAttribution: false,
|
||||
alwaysThinking: false,
|
||||
teammates: false,
|
||||
};
|
||||
}
|
||||
}, [localValue]);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(toggleKey: string, checked: boolean) => {
|
||||
try {
|
||||
const config = JSON.parse(localValue || "{}");
|
||||
|
||||
switch (toggleKey) {
|
||||
case "hideAttribution":
|
||||
if (checked) {
|
||||
config.attribution = { commit: "", pr: "" };
|
||||
} else {
|
||||
delete config.attribution;
|
||||
}
|
||||
break;
|
||||
case "alwaysThinking":
|
||||
if (checked) {
|
||||
config.alwaysThinkingEnabled = true;
|
||||
} else {
|
||||
delete config.alwaysThinkingEnabled;
|
||||
}
|
||||
break;
|
||||
case "teammates":
|
||||
if (!config.env) config.env = {};
|
||||
if (checked) {
|
||||
config.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1";
|
||||
} else {
|
||||
delete config.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS;
|
||||
if (Object.keys(config.env).length === 0) delete config.env;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
handleLocalChange(JSON.stringify(config, null, 2));
|
||||
} catch {
|
||||
// Don't modify if JSON is invalid
|
||||
}
|
||||
},
|
||||
[localValue, handleLocalChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="settingsConfig">{t("provider.configJson")}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="useCommonConfig"
|
||||
checked={useCommonConfig}
|
||||
onChange={(e) => onCommonConfigToggle(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
||||
/>
|
||||
<span>
|
||||
{t("claudeConfig.writeCommonConfig", {
|
||||
defaultValue: "写入通用配置",
|
||||
})}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditClick}
|
||||
className="text-xs text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{t("claudeConfig.editCommonConfig", {
|
||||
defaultValue: "编辑通用配置",
|
||||
})}
|
||||
</button>
|
||||
</div>
|
||||
{commonConfigError && !isModalOpen && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400 text-right">
|
||||
{commonConfigError}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={toggleStates.hideAttribution}
|
||||
onChange={(e) =>
|
||||
handleToggle("hideAttribution", e.target.checked)
|
||||
}
|
||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
||||
/>
|
||||
<span>{t("claudeConfig.hideAttribution")}</span>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={toggleStates.alwaysThinking}
|
||||
onChange={(e) => handleToggle("alwaysThinking", e.target.checked)}
|
||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
||||
/>
|
||||
<span>{t("claudeConfig.alwaysThinking")}</span>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={toggleStates.teammates}
|
||||
onChange={(e) => handleToggle("teammates", e.target.checked)}
|
||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
||||
/>
|
||||
<span>{t("claudeConfig.enableTeammates")}</span>
|
||||
</label>
|
||||
</div>
|
||||
<JsonEditor
|
||||
value={localValue}
|
||||
onChange={handleLocalChange}
|
||||
placeholder={`{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://your-api-endpoint.com",
|
||||
"ANTHROPIC_AUTH_TOKEN": "your-api-key-here"
|
||||
}
|
||||
}`}
|
||||
darkMode={isDarkMode}
|
||||
rows={14}
|
||||
showValidation={true}
|
||||
language="json"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FullScreenPanel
|
||||
isOpen={isModalOpen}
|
||||
title={t("claudeConfig.editCommonConfigTitle", {
|
||||
defaultValue: "编辑通用配置片段",
|
||||
})}
|
||||
onClose={onModalClose}
|
||||
footer={
|
||||
<>
|
||||
{onExtract && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onExtract}
|
||||
disabled={isExtracting}
|
||||
className="gap-2"
|
||||
>
|
||||
{isExtracting ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="w-4 h-4" />
|
||||
)}
|
||||
{t("claudeConfig.extractFromCurrent", {
|
||||
defaultValue: "从编辑内容提取",
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={onModalClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="button" onClick={onModalClose} className="gap-2">
|
||||
<Save className="w-4 h-4" />
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("claudeConfig.commonConfigHint", {
|
||||
defaultValue: "通用配置片段将合并到所有启用它的供应商配置中",
|
||||
})}
|
||||
</p>
|
||||
<JsonEditor
|
||||
value={commonConfigSnippet}
|
||||
onChange={onCommonConfigSnippetChange}
|
||||
placeholder={`{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://your-api-endpoint.com"
|
||||
}
|
||||
}`}
|
||||
darkMode={isDarkMode}
|
||||
rows={16}
|
||||
showValidation={true}
|
||||
language="json"
|
||||
/>
|
||||
{commonConfigError && (
|
||||
<p className="text-sm text-red-500 dark:text-red-400">
|
||||
{commonConfigError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</FullScreenPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Save, Download, Loader2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import JsonEditor from "@/components/JsonEditor";
|
||||
|
||||
interface GeminiCommonConfigModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: string;
|
||||
onExtract?: () => void;
|
||||
isExtracting?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* GeminiCommonConfigModal - Common Gemini configuration editor modal
|
||||
* Allows editing of common env snippet shared across Gemini providers
|
||||
*/
|
||||
export const GeminiCommonConfigModal: React.FC<
|
||||
GeminiCommonConfigModalProps
|
||||
> = ({ isOpen, onClose, value, onChange, error, onExtract, isExtracting }) => {
|
||||
const { t } = useTranslation();
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FullScreenPanel
|
||||
isOpen={isOpen}
|
||||
title={t("geminiConfig.editCommonConfigTitle", {
|
||||
defaultValue: "编辑 Gemini 通用配置片段",
|
||||
})}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
{onExtract && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onExtract}
|
||||
disabled={isExtracting}
|
||||
className="gap-2"
|
||||
>
|
||||
{isExtracting ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="w-4 h-4" />
|
||||
)}
|
||||
{t("geminiConfig.extractFromCurrent", {
|
||||
defaultValue: "从编辑内容提取",
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="button" onClick={onClose} className="gap-2">
|
||||
<Save className="w-4 h-4" />
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("geminiConfig.commonConfigHint", {
|
||||
defaultValue:
|
||||
"该片段会写入 Gemini 的 .env(不允许包含 GOOGLE_GEMINI_BASE_URL、GEMINI_API_KEY)",
|
||||
})}
|
||||
</p>
|
||||
|
||||
<JsonEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={`{
|
||||
"GEMINI_MODEL": "gemini-3-pro-preview"
|
||||
}`}
|
||||
darkMode={isDarkMode}
|
||||
rows={16}
|
||||
showValidation={true}
|
||||
language="json"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-500 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</FullScreenPanel>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { GeminiEnvSection, GeminiConfigSection } from "./GeminiConfigSections";
|
||||
import { GeminiCommonConfigModal } from "./GeminiCommonConfigModal";
|
||||
|
||||
interface GeminiConfigEditorProps {
|
||||
envValue: string;
|
||||
@@ -7,8 +8,15 @@ interface GeminiConfigEditorProps {
|
||||
onEnvChange: (value: string) => void;
|
||||
onConfigChange: (value: string) => void;
|
||||
onEnvBlur?: () => void;
|
||||
useCommonConfig: boolean;
|
||||
onCommonConfigToggle: (checked: boolean) => void;
|
||||
commonConfigSnippet: string;
|
||||
onCommonConfigSnippetChange: (value: string) => void;
|
||||
commonConfigError: string;
|
||||
envError: string;
|
||||
configError: string;
|
||||
onExtract?: () => void;
|
||||
isExtracting?: boolean;
|
||||
}
|
||||
|
||||
const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
|
||||
@@ -17,9 +25,25 @@ const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
|
||||
onEnvChange,
|
||||
onConfigChange,
|
||||
onEnvBlur,
|
||||
useCommonConfig,
|
||||
onCommonConfigToggle,
|
||||
commonConfigSnippet,
|
||||
onCommonConfigSnippetChange,
|
||||
commonConfigError,
|
||||
envError,
|
||||
configError,
|
||||
onExtract,
|
||||
isExtracting,
|
||||
}) => {
|
||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||
|
||||
// Auto-open common config modal if there's an error
|
||||
useEffect(() => {
|
||||
if (commonConfigError && !isCommonConfigModalOpen) {
|
||||
setIsCommonConfigModalOpen(true);
|
||||
}
|
||||
}, [commonConfigError, isCommonConfigModalOpen]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Env Section */}
|
||||
@@ -28,6 +52,10 @@ const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
|
||||
onChange={onEnvChange}
|
||||
onBlur={onEnvBlur}
|
||||
error={envError}
|
||||
useCommonConfig={useCommonConfig}
|
||||
onCommonConfigToggle={onCommonConfigToggle}
|
||||
onEditCommonConfig={() => setIsCommonConfigModalOpen(true)}
|
||||
commonConfigError={commonConfigError}
|
||||
/>
|
||||
|
||||
{/* Config JSON Section */}
|
||||
@@ -36,6 +64,17 @@ const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
|
||||
onChange={onConfigChange}
|
||||
configError={configError}
|
||||
/>
|
||||
|
||||
{/* Common Config Modal */}
|
||||
<GeminiCommonConfigModal
|
||||
isOpen={isCommonConfigModalOpen}
|
||||
onClose={() => setIsCommonConfigModalOpen(false)}
|
||||
value={commonConfigSnippet}
|
||||
onChange={onCommonConfigSnippetChange}
|
||||
error={commonConfigError}
|
||||
onExtract={onExtract}
|
||||
isExtracting={isExtracting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,6 +7,10 @@ interface GeminiEnvSectionProps {
|
||||
onChange: (value: string) => void;
|
||||
onBlur?: () => void;
|
||||
error?: string;
|
||||
useCommonConfig: boolean;
|
||||
onCommonConfigToggle: (checked: boolean) => void;
|
||||
onEditCommonConfig: () => void;
|
||||
commonConfigError?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -17,6 +21,10 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
|
||||
onChange,
|
||||
onBlur,
|
||||
error,
|
||||
useCommonConfig,
|
||||
onCommonConfigToggle,
|
||||
onEditCommonConfig,
|
||||
commonConfigError,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
@@ -45,12 +53,44 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="geminiEnv"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
{t("geminiConfig.envFile", { defaultValue: "环境变量 (.env)" })}
|
||||
</label>
|
||||
<div className="flex items-center justify-between">
|
||||
<label
|
||||
htmlFor="geminiEnv"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
{t("geminiConfig.envFile", { defaultValue: "环境变量 (.env)" })}
|
||||
</label>
|
||||
|
||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useCommonConfig}
|
||||
onChange={(e) => onCommonConfigToggle(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
||||
/>
|
||||
{t("geminiConfig.writeCommonConfig", {
|
||||
defaultValue: "写入通用配置",
|
||||
})}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditCommonConfig}
|
||||
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{t("geminiConfig.editCommonConfig", {
|
||||
defaultValue: "编辑通用配置",
|
||||
})}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{commonConfigError && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400 text-right">
|
||||
{commonConfigError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<JsonEditor
|
||||
value={value}
|
||||
|
||||
@@ -14,7 +14,6 @@ import type {
|
||||
ProviderTestConfig,
|
||||
ProviderProxyConfig,
|
||||
ClaudeApiFormat,
|
||||
ClaudeApiKeyField,
|
||||
} from "@/types";
|
||||
import {
|
||||
providerPresets,
|
||||
@@ -47,9 +46,9 @@ import {
|
||||
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
|
||||
import { getCodexCustomTemplate } from "@/config/codexTemplates";
|
||||
import CodexConfigEditor from "./CodexConfigEditor";
|
||||
import { CommonConfigEditor } from "./CommonConfigEditor";
|
||||
import GeminiConfigEditor from "./GeminiConfigEditor";
|
||||
import JsonEditor from "@/components/JsonEditor";
|
||||
import { ClaudeQuickToggles, jsonMergePatch } from "./ClaudeQuickToggles";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ProviderPresetSelector } from "./ProviderPresetSelector";
|
||||
import { BasicFormFields } from "./BasicFormFields";
|
||||
@@ -70,9 +69,12 @@ import {
|
||||
useCodexConfigState,
|
||||
useApiKeyLink,
|
||||
useTemplateValues,
|
||||
useCommonConfigSnippet,
|
||||
useCodexCommonConfig,
|
||||
useSpeedTestEndpoints,
|
||||
useCodexTomlValidation,
|
||||
useGeminiConfigState,
|
||||
useGeminiCommonConfig,
|
||||
useOmoModelSource,
|
||||
useOpencodeFormState,
|
||||
useOmoDraftState,
|
||||
@@ -116,7 +118,6 @@ interface ProviderFormProps {
|
||||
iconColor?: string;
|
||||
};
|
||||
showButtons?: boolean;
|
||||
isCurrent?: boolean;
|
||||
}
|
||||
|
||||
export function ProviderForm({
|
||||
@@ -129,7 +130,6 @@ export function ProviderForm({
|
||||
onManageUniversalProviders,
|
||||
initialData,
|
||||
showButtons = true,
|
||||
isCurrent = false,
|
||||
}: ProviderFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const isEditMode = Boolean(initialData);
|
||||
@@ -237,55 +237,6 @@ export function ProviderForm({
|
||||
mode: "onSubmit",
|
||||
});
|
||||
|
||||
const [localApiFormat, setLocalApiFormat] = useState<ClaudeApiFormat>(() => {
|
||||
if (appId !== "claude") return "anthropic";
|
||||
return initialData?.meta?.apiFormat ?? "anthropic";
|
||||
});
|
||||
|
||||
const handleApiFormatChange = useCallback((format: ClaudeApiFormat) => {
|
||||
setLocalApiFormat(format);
|
||||
}, []);
|
||||
|
||||
const [localApiKeyField, setLocalApiKeyField] = useState<ClaudeApiKeyField>(
|
||||
() => {
|
||||
if (appId !== "claude") return "ANTHROPIC_AUTH_TOKEN";
|
||||
if (initialData?.meta?.apiKeyField) return initialData.meta.apiKeyField;
|
||||
try {
|
||||
const config = initialData?.settingsConfig;
|
||||
if (
|
||||
config?.env &&
|
||||
(config.env as Record<string, unknown>).ANTHROPIC_API_KEY !==
|
||||
undefined
|
||||
)
|
||||
return "ANTHROPIC_API_KEY";
|
||||
} catch {}
|
||||
return "ANTHROPIC_AUTH_TOKEN";
|
||||
},
|
||||
);
|
||||
|
||||
const handleApiKeyFieldChange = useCallback(
|
||||
(field: ClaudeApiKeyField) => {
|
||||
setLocalApiKeyField(field);
|
||||
try {
|
||||
const config = JSON.parse(form.getValues("settingsConfig") || "{}") as {
|
||||
env?: Record<string, unknown>;
|
||||
};
|
||||
const env = (config.env ?? {}) as Record<string, unknown>;
|
||||
const oldField =
|
||||
field === "ANTHROPIC_API_KEY"
|
||||
? "ANTHROPIC_AUTH_TOKEN"
|
||||
: "ANTHROPIC_API_KEY";
|
||||
if (oldField in env) {
|
||||
env[field] = env[oldField];
|
||||
delete env[oldField];
|
||||
config.env = env;
|
||||
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
const {
|
||||
apiKey,
|
||||
handleApiKeyChange,
|
||||
@@ -296,7 +247,6 @@ export function ProviderForm({
|
||||
selectedPresetId,
|
||||
category,
|
||||
appType: appId,
|
||||
apiKeyField: appId === "claude" ? localApiKeyField : undefined,
|
||||
});
|
||||
|
||||
const { baseUrl, handleClaudeBaseUrlChange } = useBaseUrlState({
|
||||
@@ -320,6 +270,15 @@ export function ProviderForm({
|
||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||
});
|
||||
|
||||
const [localApiFormat, setLocalApiFormat] = useState<ClaudeApiFormat>(() => {
|
||||
if (appId !== "claude") return "anthropic";
|
||||
return initialData?.meta?.apiFormat ?? "anthropic";
|
||||
});
|
||||
|
||||
const handleApiFormatChange = useCallback((format: ClaudeApiFormat) => {
|
||||
setLocalApiFormat(format);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
codexAuth,
|
||||
codexConfig,
|
||||
@@ -417,6 +376,37 @@ export function ProviderForm({
|
||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||
});
|
||||
|
||||
const {
|
||||
useCommonConfig,
|
||||
commonConfigSnippet,
|
||||
commonConfigError,
|
||||
handleCommonConfigToggle,
|
||||
handleCommonConfigSnippetChange,
|
||||
isExtracting: isClaudeExtracting,
|
||||
handleExtract: handleClaudeExtract,
|
||||
} = useCommonConfigSnippet({
|
||||
settingsConfig: form.getValues("settingsConfig"),
|
||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||
initialData: appId === "claude" ? initialData : undefined,
|
||||
selectedPresetId: selectedPresetId ?? undefined,
|
||||
enabled: appId === "claude",
|
||||
});
|
||||
|
||||
const {
|
||||
useCommonConfig: useCodexCommonConfigFlag,
|
||||
commonConfigSnippet: codexCommonConfigSnippet,
|
||||
commonConfigError: codexCommonConfigError,
|
||||
handleCommonConfigToggle: handleCodexCommonConfigToggle,
|
||||
handleCommonConfigSnippetChange: handleCodexCommonConfigSnippetChange,
|
||||
isExtracting: isCodexExtracting,
|
||||
handleExtract: handleCodexExtract,
|
||||
} = useCodexCommonConfig({
|
||||
codexConfig,
|
||||
onConfigChange: handleCodexConfigChange,
|
||||
initialData: appId === "codex" ? initialData : undefined,
|
||||
selectedPresetId: selectedPresetId ?? undefined,
|
||||
});
|
||||
|
||||
const {
|
||||
geminiEnv,
|
||||
geminiConfig,
|
||||
@@ -432,6 +422,7 @@ export function ProviderForm({
|
||||
handleGeminiConfigChange,
|
||||
resetGeminiConfig,
|
||||
envStringToObj,
|
||||
envObjToString,
|
||||
} = useGeminiConfigState({
|
||||
initialData: appId === "gemini" ? initialData : undefined,
|
||||
});
|
||||
@@ -482,6 +473,23 @@ export function ProviderForm({
|
||||
[originalHandleGeminiModelChange, updateGeminiEnvField],
|
||||
);
|
||||
|
||||
const {
|
||||
useCommonConfig: useGeminiCommonConfigFlag,
|
||||
commonConfigSnippet: geminiCommonConfigSnippet,
|
||||
commonConfigError: geminiCommonConfigError,
|
||||
handleCommonConfigToggle: handleGeminiCommonConfigToggle,
|
||||
handleCommonConfigSnippetChange: handleGeminiCommonConfigSnippetChange,
|
||||
isExtracting: isGeminiExtracting,
|
||||
handleExtract: handleGeminiExtract,
|
||||
} = useGeminiCommonConfig({
|
||||
envValue: geminiEnv,
|
||||
onEnvChange: handleGeminiEnvChange,
|
||||
envStringToObj,
|
||||
envObjToString,
|
||||
initialData: appId === "gemini" ? initialData : undefined,
|
||||
selectedPresetId: selectedPresetId ?? undefined,
|
||||
});
|
||||
|
||||
// ── Extracted hooks: OpenCode / OMO / OpenClaw ─────────────────────
|
||||
|
||||
const {
|
||||
@@ -520,6 +528,8 @@ export function ProviderForm({
|
||||
getSettingsConfig: () => form.getValues("settingsConfig"),
|
||||
});
|
||||
|
||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||
|
||||
const handleSubmit = (values: ProviderFormData) => {
|
||||
if (appId === "claude" && templateValueEntries.length > 0) {
|
||||
const validation = validateTemplateValues();
|
||||
@@ -813,10 +823,6 @@ export function ProviderForm({
|
||||
appId === "claude" && category !== "official"
|
||||
? localApiFormat
|
||||
: undefined,
|
||||
apiKeyField:
|
||||
appId === "claude" && category !== "official"
|
||||
? localApiKeyField
|
||||
: undefined,
|
||||
};
|
||||
|
||||
onSubmit(payload);
|
||||
@@ -1055,12 +1061,6 @@ export function ProviderForm({
|
||||
setLocalApiFormat("anthropic");
|
||||
}
|
||||
|
||||
if (preset.apiKeyField) {
|
||||
setLocalApiKeyField(preset.apiKeyField);
|
||||
} else {
|
||||
setLocalApiKeyField("ANTHROPIC_AUTH_TOKEN");
|
||||
}
|
||||
|
||||
form.reset({
|
||||
name: preset.name,
|
||||
websiteUrl: preset.websiteUrl ?? "",
|
||||
@@ -1266,8 +1266,6 @@ export function ProviderForm({
|
||||
speedTestEndpoints={speedTestEndpoints}
|
||||
apiFormat={localApiFormat}
|
||||
onApiFormatChange={handleApiFormatChange}
|
||||
apiKeyField={localApiKeyField}
|
||||
onApiKeyFieldChange={handleApiKeyFieldChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1394,8 +1392,15 @@ export function ProviderForm({
|
||||
configValue={codexConfig}
|
||||
onAuthChange={setCodexAuth}
|
||||
onConfigChange={handleCodexConfigChange}
|
||||
useCommonConfig={useCodexCommonConfigFlag}
|
||||
onCommonConfigToggle={handleCodexCommonConfigToggle}
|
||||
commonConfigSnippet={codexCommonConfigSnippet}
|
||||
onCommonConfigSnippetChange={handleCodexCommonConfigSnippetChange}
|
||||
commonConfigError={codexCommonConfigError}
|
||||
authError={codexAuthError}
|
||||
configError={codexConfigError}
|
||||
onExtract={handleCodexExtract}
|
||||
isExtracting={isCodexExtracting}
|
||||
/>
|
||||
{settingsConfigErrorField}
|
||||
</>
|
||||
@@ -1406,8 +1411,17 @@ export function ProviderForm({
|
||||
configValue={geminiConfig}
|
||||
onEnvChange={handleGeminiEnvChange}
|
||||
onConfigChange={handleGeminiConfigChange}
|
||||
useCommonConfig={useGeminiCommonConfigFlag}
|
||||
onCommonConfigToggle={handleGeminiCommonConfigToggle}
|
||||
commonConfigSnippet={geminiCommonConfigSnippet}
|
||||
onCommonConfigSnippetChange={
|
||||
handleGeminiCommonConfigSnippetChange
|
||||
}
|
||||
commonConfigError={geminiCommonConfigError}
|
||||
envError={envError}
|
||||
configError={geminiConfigError}
|
||||
onExtract={handleGeminiExtract}
|
||||
isExtracting={isGeminiExtracting}
|
||||
/>
|
||||
{settingsConfigErrorField}
|
||||
</>
|
||||
@@ -1477,48 +1491,20 @@ export function ProviderForm({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settingsConfig">
|
||||
{t("claudeConfig.configLabel")}
|
||||
</Label>
|
||||
{isEditMode && isCurrent && (
|
||||
<ClaudeQuickToggles
|
||||
onPatchApplied={(patch) => {
|
||||
try {
|
||||
const cfg = JSON.parse(
|
||||
form.getValues("settingsConfig") || "{}",
|
||||
);
|
||||
jsonMergePatch(cfg, patch);
|
||||
form.setValue(
|
||||
"settingsConfig",
|
||||
JSON.stringify(cfg, null, 2),
|
||||
);
|
||||
} catch {
|
||||
// invalid JSON in editor — skip mirror
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<JsonEditor
|
||||
value={form.watch("settingsConfig")}
|
||||
onChange={(value) => form.setValue("settingsConfig", value)}
|
||||
placeholder={`{
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": "your-api-key-here"
|
||||
}
|
||||
}`}
|
||||
rows={14}
|
||||
showValidation={true}
|
||||
language="json"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
isCurrent
|
||||
? "claudeConfig.fullSettingsHint"
|
||||
: "claudeConfig.fragmentSettingsHint",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<CommonConfigEditor
|
||||
value={form.getValues("settingsConfig")}
|
||||
onChange={(value) => form.setValue("settingsConfig", value)}
|
||||
useCommonConfig={useCommonConfig}
|
||||
onCommonConfigToggle={handleCommonConfigToggle}
|
||||
commonConfigSnippet={commonConfigSnippet}
|
||||
onCommonConfigSnippetChange={handleCommonConfigSnippetChange}
|
||||
commonConfigError={commonConfigError}
|
||||
onEditClick={() => setIsCommonConfigModalOpen(true)}
|
||||
isModalOpen={isCommonConfigModalOpen}
|
||||
onModalClose={() => setIsCommonConfigModalOpen(false)}
|
||||
onExtract={handleClaudeExtract}
|
||||
isExtracting={isClaudeExtracting}
|
||||
/>
|
||||
{settingsConfigErrorField}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -6,9 +6,12 @@ export { useCodexConfigState } from "./useCodexConfigState";
|
||||
export { useApiKeyLink } from "./useApiKeyLink";
|
||||
export { useCustomEndpoints } from "./useCustomEndpoints";
|
||||
export { useTemplateValues } from "./useTemplateValues";
|
||||
export { useCommonConfigSnippet } from "./useCommonConfigSnippet";
|
||||
export { useCodexCommonConfig } from "./useCodexCommonConfig";
|
||||
export { useSpeedTestEndpoints } from "./useSpeedTestEndpoints";
|
||||
export { useCodexTomlValidation } from "./useCodexTomlValidation";
|
||||
export { useGeminiConfigState } from "./useGeminiConfigState";
|
||||
export { useGeminiCommonConfig } from "./useGeminiCommonConfig";
|
||||
export { useOmoModelSource } from "./useOmoModelSource";
|
||||
export { useOpencodeFormState } from "./useOpencodeFormState";
|
||||
export { useOmoDraftState } from "./useOmoDraftState";
|
||||
|
||||
@@ -12,7 +12,6 @@ interface UseApiKeyStateProps {
|
||||
selectedPresetId: string | null;
|
||||
category?: ProviderCategory;
|
||||
appType?: string;
|
||||
apiKeyField?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,7 +24,6 @@ export function useApiKeyState({
|
||||
selectedPresetId,
|
||||
category,
|
||||
appType,
|
||||
apiKeyField,
|
||||
}: UseApiKeyStateProps) {
|
||||
const [apiKey, setApiKey] = useState(() => {
|
||||
if (initialConfig) {
|
||||
@@ -60,7 +58,7 @@ export function useApiKeyState({
|
||||
initialConfig || "{}",
|
||||
key.trim(),
|
||||
{
|
||||
// 最佳实践:仅在"新增模式"且"非官方类别"时补齐缺失字段
|
||||
// 最佳实践:仅在“新增模式”且“非官方类别”时补齐缺失字段
|
||||
// - 新增模式:selectedPresetId !== null
|
||||
// - 非官方类别:category !== undefined && category !== "official"
|
||||
// - 官方类别:不创建字段(UI 也会禁用输入框)
|
||||
@@ -70,20 +68,12 @@ export function useApiKeyState({
|
||||
category !== undefined &&
|
||||
category !== "official",
|
||||
appType,
|
||||
apiKeyField,
|
||||
},
|
||||
);
|
||||
|
||||
onConfigChange(configString);
|
||||
},
|
||||
[
|
||||
initialConfig,
|
||||
selectedPresetId,
|
||||
category,
|
||||
appType,
|
||||
apiKeyField,
|
||||
onConfigChange,
|
||||
],
|
||||
[initialConfig, selectedPresetId, category, appType, onConfigChange],
|
||||
);
|
||||
|
||||
const showApiKey = useCallback(
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
updateTomlCommonConfigSnippet,
|
||||
hasTomlCommonConfigSnippet,
|
||||
} from "@/utils/providerConfigUtils";
|
||||
import { configApi } from "@/lib/api";
|
||||
|
||||
const LEGACY_STORAGE_KEY = "cc-switch:codex-common-config-snippet";
|
||||
const DEFAULT_CODEX_COMMON_CONFIG_SNIPPET = `# Common Codex config
|
||||
# Add your common TOML configuration here`;
|
||||
|
||||
interface UseCodexCommonConfigProps {
|
||||
codexConfig: string;
|
||||
onConfigChange: (config: string) => void;
|
||||
initialData?: {
|
||||
settingsConfig?: Record<string, unknown>;
|
||||
};
|
||||
selectedPresetId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理 Codex 通用配置片段 (TOML 格式)
|
||||
* 从 config.json 读取和保存,支持从 localStorage 平滑迁移
|
||||
*/
|
||||
export function useCodexCommonConfig({
|
||||
codexConfig,
|
||||
onConfigChange,
|
||||
initialData,
|
||||
selectedPresetId,
|
||||
}: UseCodexCommonConfigProps) {
|
||||
const { t } = useTranslation();
|
||||
const [useCommonConfig, setUseCommonConfig] = useState(false);
|
||||
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
|
||||
DEFAULT_CODEX_COMMON_CONFIG_SNIPPET,
|
||||
);
|
||||
const [commonConfigError, setCommonConfigError] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isExtracting, setIsExtracting] = useState(false);
|
||||
|
||||
// 用于跟踪是否正在通过通用配置更新
|
||||
const isUpdatingFromCommonConfig = useRef(false);
|
||||
// 用于跟踪新建模式是否已初始化默认勾选
|
||||
const hasInitializedNewMode = useRef(false);
|
||||
|
||||
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
|
||||
useEffect(() => {
|
||||
hasInitializedNewMode.current = false;
|
||||
}, [selectedPresetId]);
|
||||
|
||||
// 初始化:从 config.json 加载,支持从 localStorage 迁移
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const loadSnippet = async () => {
|
||||
try {
|
||||
// 使用统一 API 加载
|
||||
const snippet = await configApi.getCommonConfigSnippet("codex");
|
||||
|
||||
if (snippet && snippet.trim()) {
|
||||
if (mounted) {
|
||||
setCommonConfigSnippetState(snippet);
|
||||
}
|
||||
} else {
|
||||
// 如果 config.json 中没有,尝试从 localStorage 迁移
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
const legacySnippet =
|
||||
window.localStorage.getItem(LEGACY_STORAGE_KEY);
|
||||
if (legacySnippet && legacySnippet.trim()) {
|
||||
// 迁移到 config.json
|
||||
await configApi.setCommonConfigSnippet("codex", legacySnippet);
|
||||
if (mounted) {
|
||||
setCommonConfigSnippetState(legacySnippet);
|
||||
}
|
||||
// 清理 localStorage
|
||||
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
|
||||
console.log(
|
||||
"[迁移] Codex 通用配置已从 localStorage 迁移到 config.json",
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[迁移] 从 localStorage 迁移失败:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("加载 Codex 通用配置失败:", error);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadSnippet();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 初始化时检查通用配置片段(编辑模式)
|
||||
useEffect(() => {
|
||||
if (initialData?.settingsConfig && !isLoading) {
|
||||
const config =
|
||||
typeof initialData.settingsConfig.config === "string"
|
||||
? initialData.settingsConfig.config
|
||||
: "";
|
||||
const hasCommon = hasTomlCommonConfigSnippet(config, commonConfigSnippet);
|
||||
setUseCommonConfig(hasCommon);
|
||||
}
|
||||
}, [initialData, commonConfigSnippet, isLoading]);
|
||||
|
||||
// 新建模式:如果通用配置片段存在且有效,默认启用
|
||||
useEffect(() => {
|
||||
// 仅新建模式、加载完成、尚未初始化过
|
||||
if (!initialData && !isLoading && !hasInitializedNewMode.current) {
|
||||
hasInitializedNewMode.current = true;
|
||||
|
||||
// 检查 TOML 片段是否有实质内容(不只是注释和空行)
|
||||
const lines = commonConfigSnippet.split("\n");
|
||||
const hasContent = lines.some((line) => {
|
||||
const trimmed = line.trim();
|
||||
return trimmed && !trimmed.startsWith("#");
|
||||
});
|
||||
|
||||
if (hasContent) {
|
||||
setUseCommonConfig(true);
|
||||
// 合并通用配置到当前配置
|
||||
const { updatedConfig, error } = updateTomlCommonConfigSnippet(
|
||||
codexConfig,
|
||||
commonConfigSnippet,
|
||||
true,
|
||||
);
|
||||
if (!error) {
|
||||
isUpdatingFromCommonConfig.current = true;
|
||||
onConfigChange(updatedConfig);
|
||||
setTimeout(() => {
|
||||
isUpdatingFromCommonConfig.current = false;
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
initialData,
|
||||
commonConfigSnippet,
|
||||
isLoading,
|
||||
codexConfig,
|
||||
onConfigChange,
|
||||
]);
|
||||
|
||||
// 处理通用配置开关
|
||||
const handleCommonConfigToggle = useCallback(
|
||||
(checked: boolean) => {
|
||||
const { updatedConfig, error: snippetError } =
|
||||
updateTomlCommonConfigSnippet(
|
||||
codexConfig,
|
||||
commonConfigSnippet,
|
||||
checked,
|
||||
);
|
||||
|
||||
if (snippetError) {
|
||||
setCommonConfigError(snippetError);
|
||||
setUseCommonConfig(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setCommonConfigError("");
|
||||
setUseCommonConfig(checked);
|
||||
// 标记正在通过通用配置更新
|
||||
isUpdatingFromCommonConfig.current = true;
|
||||
onConfigChange(updatedConfig);
|
||||
// 在下一个事件循环中重置标记
|
||||
setTimeout(() => {
|
||||
isUpdatingFromCommonConfig.current = false;
|
||||
}, 0);
|
||||
},
|
||||
[codexConfig, commonConfigSnippet, onConfigChange],
|
||||
);
|
||||
|
||||
// 处理通用配置片段变化
|
||||
const handleCommonConfigSnippetChange = useCallback(
|
||||
(value: string) => {
|
||||
const previousSnippet = commonConfigSnippet;
|
||||
setCommonConfigSnippetState(value);
|
||||
|
||||
if (!value.trim()) {
|
||||
setCommonConfigError("");
|
||||
// 保存到 config.json(清空)
|
||||
configApi
|
||||
.setCommonConfigSnippet("codex", "")
|
||||
.catch((error: unknown) => {
|
||||
console.error("保存 Codex 通用配置失败:", error);
|
||||
setCommonConfigError(
|
||||
t("codexConfig.saveFailed", { error: String(error) }),
|
||||
);
|
||||
});
|
||||
|
||||
if (useCommonConfig) {
|
||||
const { updatedConfig } = updateTomlCommonConfigSnippet(
|
||||
codexConfig,
|
||||
previousSnippet,
|
||||
false,
|
||||
);
|
||||
onConfigChange(updatedConfig);
|
||||
setUseCommonConfig(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// TOML 格式校验较为复杂,暂时不做校验,直接清空错误
|
||||
setCommonConfigError("");
|
||||
// 保存到 config.json
|
||||
configApi
|
||||
.setCommonConfigSnippet("codex", value)
|
||||
.catch((error: unknown) => {
|
||||
console.error("保存 Codex 通用配置失败:", error);
|
||||
setCommonConfigError(
|
||||
t("codexConfig.saveFailed", { error: String(error) }),
|
||||
);
|
||||
});
|
||||
|
||||
// 若当前启用通用配置,需要替换为最新片段
|
||||
if (useCommonConfig) {
|
||||
const removeResult = updateTomlCommonConfigSnippet(
|
||||
codexConfig,
|
||||
previousSnippet,
|
||||
false,
|
||||
);
|
||||
if (removeResult.error) {
|
||||
setCommonConfigError(removeResult.error);
|
||||
return;
|
||||
}
|
||||
const addResult = updateTomlCommonConfigSnippet(
|
||||
removeResult.updatedConfig,
|
||||
value,
|
||||
true,
|
||||
);
|
||||
|
||||
if (addResult.error) {
|
||||
setCommonConfigError(addResult.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// 标记正在通过通用配置更新,避免触发状态检查
|
||||
isUpdatingFromCommonConfig.current = true;
|
||||
onConfigChange(addResult.updatedConfig);
|
||||
// 在下一个事件循环中重置标记
|
||||
setTimeout(() => {
|
||||
isUpdatingFromCommonConfig.current = false;
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
[commonConfigSnippet, codexConfig, useCommonConfig, onConfigChange],
|
||||
);
|
||||
|
||||
// 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
|
||||
useEffect(() => {
|
||||
if (isUpdatingFromCommonConfig.current || isLoading) {
|
||||
return;
|
||||
}
|
||||
const hasCommon = hasTomlCommonConfigSnippet(
|
||||
codexConfig,
|
||||
commonConfigSnippet,
|
||||
);
|
||||
setUseCommonConfig(hasCommon);
|
||||
}, [codexConfig, commonConfigSnippet, isLoading]);
|
||||
|
||||
// 从编辑器当前内容提取通用配置片段
|
||||
const handleExtract = useCallback(async () => {
|
||||
setIsExtracting(true);
|
||||
setCommonConfigError("");
|
||||
|
||||
try {
|
||||
const extracted = await configApi.extractCommonConfigSnippet("codex", {
|
||||
settingsConfig: JSON.stringify({
|
||||
config: codexConfig ?? "",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!extracted || !extracted.trim()) {
|
||||
setCommonConfigError(t("codexConfig.extractNoCommonConfig"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新片段状态
|
||||
setCommonConfigSnippetState(extracted);
|
||||
|
||||
// 保存到后端
|
||||
await configApi.setCommonConfigSnippet("codex", extracted);
|
||||
} catch (error) {
|
||||
console.error("提取 Codex 通用配置失败:", error);
|
||||
setCommonConfigError(
|
||||
t("codexConfig.extractFailed", { error: String(error) }),
|
||||
);
|
||||
} finally {
|
||||
setIsExtracting(false);
|
||||
}
|
||||
}, [codexConfig, t]);
|
||||
|
||||
return {
|
||||
useCommonConfig,
|
||||
commonConfigSnippet,
|
||||
commonConfigError,
|
||||
isLoading,
|
||||
isExtracting,
|
||||
handleCommonConfigToggle,
|
||||
handleCommonConfigSnippetChange,
|
||||
handleExtract,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
updateCommonConfigSnippet,
|
||||
hasCommonConfigSnippet,
|
||||
validateJsonConfig,
|
||||
} from "@/utils/providerConfigUtils";
|
||||
import { configApi } from "@/lib/api";
|
||||
|
||||
const LEGACY_STORAGE_KEY = "cc-switch:common-config-snippet";
|
||||
const DEFAULT_COMMON_CONFIG_SNIPPET = `{
|
||||
"includeCoAuthoredBy": false
|
||||
}`;
|
||||
|
||||
interface UseCommonConfigSnippetProps {
|
||||
settingsConfig: string;
|
||||
onConfigChange: (config: string) => void;
|
||||
initialData?: {
|
||||
settingsConfig?: Record<string, unknown>;
|
||||
};
|
||||
selectedPresetId?: string;
|
||||
/** When false, the hook skips all logic and returns disabled state. Default: true */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理 Claude 通用配置片段
|
||||
* 从 config.json 读取和保存,支持从 localStorage 平滑迁移
|
||||
*/
|
||||
export function useCommonConfigSnippet({
|
||||
settingsConfig,
|
||||
onConfigChange,
|
||||
initialData,
|
||||
selectedPresetId,
|
||||
enabled = true,
|
||||
}: UseCommonConfigSnippetProps) {
|
||||
const { t } = useTranslation();
|
||||
const [useCommonConfig, setUseCommonConfig] = useState(false);
|
||||
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
|
||||
DEFAULT_COMMON_CONFIG_SNIPPET,
|
||||
);
|
||||
const [commonConfigError, setCommonConfigError] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isExtracting, setIsExtracting] = useState(false);
|
||||
|
||||
// 用于跟踪是否正在通过通用配置更新
|
||||
const isUpdatingFromCommonConfig = useRef(false);
|
||||
// 用于跟踪新建模式是否已初始化默认勾选
|
||||
const hasInitializedNewMode = useRef(false);
|
||||
|
||||
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
hasInitializedNewMode.current = false;
|
||||
}, [selectedPresetId, enabled]);
|
||||
|
||||
// 初始化:从 config.json 加载,支持从 localStorage 迁移
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
let mounted = true;
|
||||
|
||||
const loadSnippet = async () => {
|
||||
try {
|
||||
// 使用统一 API 加载
|
||||
const snippet = await configApi.getCommonConfigSnippet("claude");
|
||||
|
||||
if (snippet && snippet.trim()) {
|
||||
if (mounted) {
|
||||
setCommonConfigSnippetState(snippet);
|
||||
}
|
||||
} else {
|
||||
// 如果 config.json 中没有,尝试从 localStorage 迁移
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
const legacySnippet =
|
||||
window.localStorage.getItem(LEGACY_STORAGE_KEY);
|
||||
if (legacySnippet && legacySnippet.trim()) {
|
||||
// 迁移到 config.json
|
||||
await configApi.setCommonConfigSnippet("claude", legacySnippet);
|
||||
if (mounted) {
|
||||
setCommonConfigSnippetState(legacySnippet);
|
||||
}
|
||||
// 清理 localStorage
|
||||
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
|
||||
console.log(
|
||||
"[迁移] Claude 通用配置已从 localStorage 迁移到 config.json",
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[迁移] 从 localStorage 迁移失败:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("加载通用配置失败:", error);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadSnippet();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
// 初始化时检查通用配置片段(编辑模式)
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (initialData && !isLoading) {
|
||||
const configString = JSON.stringify(initialData.settingsConfig, null, 2);
|
||||
const hasCommon = hasCommonConfigSnippet(
|
||||
configString,
|
||||
commonConfigSnippet,
|
||||
);
|
||||
setUseCommonConfig(hasCommon);
|
||||
}
|
||||
}, [enabled, initialData, commonConfigSnippet, isLoading]);
|
||||
|
||||
// 新建模式:如果通用配置片段存在且有效,默认启用
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
// 仅新建模式、加载完成、尚未初始化过
|
||||
if (!initialData && !isLoading && !hasInitializedNewMode.current) {
|
||||
hasInitializedNewMode.current = true;
|
||||
|
||||
// 检查片段是否有实质内容
|
||||
try {
|
||||
const snippetObj = JSON.parse(commonConfigSnippet);
|
||||
const hasContent = Object.keys(snippetObj).length > 0;
|
||||
if (hasContent) {
|
||||
setUseCommonConfig(true);
|
||||
// 合并通用配置到当前配置
|
||||
const { updatedConfig, error } = updateCommonConfigSnippet(
|
||||
settingsConfig,
|
||||
commonConfigSnippet,
|
||||
true,
|
||||
);
|
||||
if (!error) {
|
||||
isUpdatingFromCommonConfig.current = true;
|
||||
onConfigChange(updatedConfig);
|
||||
setTimeout(() => {
|
||||
isUpdatingFromCommonConfig.current = false;
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore parse error
|
||||
}
|
||||
}
|
||||
}, [
|
||||
enabled,
|
||||
initialData,
|
||||
commonConfigSnippet,
|
||||
isLoading,
|
||||
settingsConfig,
|
||||
onConfigChange,
|
||||
]);
|
||||
|
||||
// 处理通用配置开关
|
||||
const handleCommonConfigToggle = useCallback(
|
||||
(checked: boolean) => {
|
||||
const { updatedConfig, error: snippetError } = updateCommonConfigSnippet(
|
||||
settingsConfig,
|
||||
commonConfigSnippet,
|
||||
checked,
|
||||
);
|
||||
|
||||
if (snippetError) {
|
||||
setCommonConfigError(snippetError);
|
||||
setUseCommonConfig(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setCommonConfigError("");
|
||||
setUseCommonConfig(checked);
|
||||
// 标记正在通过通用配置更新
|
||||
isUpdatingFromCommonConfig.current = true;
|
||||
onConfigChange(updatedConfig);
|
||||
// 在下一个事件循环中重置标记
|
||||
setTimeout(() => {
|
||||
isUpdatingFromCommonConfig.current = false;
|
||||
}, 0);
|
||||
},
|
||||
[settingsConfig, commonConfigSnippet, onConfigChange],
|
||||
);
|
||||
|
||||
// 处理通用配置片段变化
|
||||
const handleCommonConfigSnippetChange = useCallback(
|
||||
(value: string) => {
|
||||
const previousSnippet = commonConfigSnippet;
|
||||
setCommonConfigSnippetState(value);
|
||||
|
||||
if (!value.trim()) {
|
||||
setCommonConfigError("");
|
||||
// 保存到 config.json(清空)
|
||||
configApi
|
||||
.setCommonConfigSnippet("claude", "")
|
||||
.catch((error: unknown) => {
|
||||
console.error("保存通用配置失败:", error);
|
||||
setCommonConfigError(
|
||||
t("claudeConfig.saveFailed", { error: String(error) }),
|
||||
);
|
||||
});
|
||||
|
||||
if (useCommonConfig) {
|
||||
const { updatedConfig } = updateCommonConfigSnippet(
|
||||
settingsConfig,
|
||||
previousSnippet,
|
||||
false,
|
||||
);
|
||||
onConfigChange(updatedConfig);
|
||||
setUseCommonConfig(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证JSON格式
|
||||
const validationError = validateJsonConfig(value, "通用配置片段");
|
||||
if (validationError) {
|
||||
setCommonConfigError(validationError);
|
||||
} else {
|
||||
setCommonConfigError("");
|
||||
// 保存到 config.json
|
||||
configApi
|
||||
.setCommonConfigSnippet("claude", value)
|
||||
.catch((error: unknown) => {
|
||||
console.error("保存通用配置失败:", error);
|
||||
setCommonConfigError(
|
||||
t("claudeConfig.saveFailed", { error: String(error) }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// 若当前启用通用配置且格式正确,需要替换为最新片段
|
||||
if (useCommonConfig && !validationError) {
|
||||
const removeResult = updateCommonConfigSnippet(
|
||||
settingsConfig,
|
||||
previousSnippet,
|
||||
false,
|
||||
);
|
||||
if (removeResult.error) {
|
||||
setCommonConfigError(removeResult.error);
|
||||
return;
|
||||
}
|
||||
const addResult = updateCommonConfigSnippet(
|
||||
removeResult.updatedConfig,
|
||||
value,
|
||||
true,
|
||||
);
|
||||
|
||||
if (addResult.error) {
|
||||
setCommonConfigError(addResult.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// 标记正在通过通用配置更新,避免触发状态检查
|
||||
isUpdatingFromCommonConfig.current = true;
|
||||
onConfigChange(addResult.updatedConfig);
|
||||
// 在下一个事件循环中重置标记
|
||||
setTimeout(() => {
|
||||
isUpdatingFromCommonConfig.current = false;
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
[commonConfigSnippet, settingsConfig, useCommonConfig, onConfigChange],
|
||||
);
|
||||
|
||||
// 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (isUpdatingFromCommonConfig.current || isLoading) {
|
||||
return;
|
||||
}
|
||||
const hasCommon = hasCommonConfigSnippet(
|
||||
settingsConfig,
|
||||
commonConfigSnippet,
|
||||
);
|
||||
setUseCommonConfig(hasCommon);
|
||||
}, [enabled, settingsConfig, commonConfigSnippet, isLoading]);
|
||||
|
||||
// 从编辑器当前内容提取通用配置片段
|
||||
const handleExtract = useCallback(async () => {
|
||||
setIsExtracting(true);
|
||||
setCommonConfigError("");
|
||||
|
||||
try {
|
||||
const extracted = await configApi.extractCommonConfigSnippet("claude", {
|
||||
settingsConfig,
|
||||
});
|
||||
|
||||
if (!extracted || extracted === "{}") {
|
||||
setCommonConfigError(t("claudeConfig.extractNoCommonConfig"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证 JSON 格式
|
||||
const validationError = validateJsonConfig(extracted, "提取的配置");
|
||||
if (validationError) {
|
||||
setCommonConfigError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新片段状态
|
||||
setCommonConfigSnippetState(extracted);
|
||||
|
||||
// 保存到后端
|
||||
await configApi.setCommonConfigSnippet("claude", extracted);
|
||||
} catch (error) {
|
||||
console.error("提取通用配置失败:", error);
|
||||
setCommonConfigError(
|
||||
t("claudeConfig.extractFailed", { error: String(error) }),
|
||||
);
|
||||
} finally {
|
||||
setIsExtracting(false);
|
||||
}
|
||||
}, [settingsConfig, t]);
|
||||
|
||||
return {
|
||||
useCommonConfig,
|
||||
commonConfigSnippet,
|
||||
commonConfigError,
|
||||
isLoading,
|
||||
isExtracting,
|
||||
handleCommonConfigToggle,
|
||||
handleCommonConfigSnippetChange,
|
||||
handleExtract,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { configApi } from "@/lib/api";
|
||||
|
||||
const LEGACY_STORAGE_KEY = "cc-switch:gemini-common-config-snippet";
|
||||
const DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET = "{}";
|
||||
|
||||
const GEMINI_COMMON_ENV_FORBIDDEN_KEYS = [
|
||||
"GOOGLE_GEMINI_BASE_URL",
|
||||
"GEMINI_API_KEY",
|
||||
] as const;
|
||||
type GeminiForbiddenEnvKey = (typeof GEMINI_COMMON_ENV_FORBIDDEN_KEYS)[number];
|
||||
|
||||
interface UseGeminiCommonConfigProps {
|
||||
envValue: string;
|
||||
onEnvChange: (env: string) => void;
|
||||
envStringToObj: (envString: string) => Record<string, string>;
|
||||
envObjToString: (envObj: Record<string, unknown>) => string;
|
||||
initialData?: {
|
||||
settingsConfig?: Record<string, unknown>;
|
||||
};
|
||||
selectedPresetId?: string;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
Object.prototype.toString.call(value) === "[object Object]"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理 Gemini 通用配置片段 (JSON 格式)
|
||||
* 写入 Gemini 的 .env,但会排除以下敏感字段:
|
||||
* - GOOGLE_GEMINI_BASE_URL
|
||||
* - GEMINI_API_KEY
|
||||
*/
|
||||
export function useGeminiCommonConfig({
|
||||
envValue,
|
||||
onEnvChange,
|
||||
envStringToObj,
|
||||
envObjToString,
|
||||
initialData,
|
||||
selectedPresetId,
|
||||
}: UseGeminiCommonConfigProps) {
|
||||
const { t } = useTranslation();
|
||||
const [useCommonConfig, setUseCommonConfig] = useState(false);
|
||||
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
|
||||
DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET,
|
||||
);
|
||||
const [commonConfigError, setCommonConfigError] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isExtracting, setIsExtracting] = useState(false);
|
||||
|
||||
// 用于跟踪是否正在通过通用配置更新
|
||||
const isUpdatingFromCommonConfig = useRef(false);
|
||||
// 用于跟踪新建模式是否已初始化默认勾选
|
||||
const hasInitializedNewMode = useRef(false);
|
||||
|
||||
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
|
||||
useEffect(() => {
|
||||
hasInitializedNewMode.current = false;
|
||||
}, [selectedPresetId]);
|
||||
|
||||
const parseSnippetEnv = useCallback(
|
||||
(
|
||||
snippetString: string,
|
||||
): { env: Record<string, string>; error?: string } => {
|
||||
const trimmed = snippetString.trim();
|
||||
if (!trimmed) {
|
||||
return { env: {} };
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
return { env: {}, error: t("geminiConfig.invalidJsonFormat") };
|
||||
}
|
||||
|
||||
if (!isPlainObject(parsed)) {
|
||||
return { env: {}, error: t("geminiConfig.invalidJsonFormat") };
|
||||
}
|
||||
|
||||
const keys = Object.keys(parsed);
|
||||
const forbiddenKeys = keys.filter((key) =>
|
||||
GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes(key as GeminiForbiddenEnvKey),
|
||||
);
|
||||
if (forbiddenKeys.length > 0) {
|
||||
return {
|
||||
env: {},
|
||||
error: t("geminiConfig.commonConfigInvalidKeys", {
|
||||
keys: forbiddenKeys.join(", "),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (typeof value !== "string") {
|
||||
return {
|
||||
env: {},
|
||||
error: t("geminiConfig.commonConfigInvalidValues"),
|
||||
};
|
||||
}
|
||||
const normalized = value.trim();
|
||||
if (!normalized) continue;
|
||||
env[key] = normalized;
|
||||
}
|
||||
|
||||
return { env };
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const hasEnvCommonConfigSnippet = useCallback(
|
||||
(envObj: Record<string, string>, snippetEnv: Record<string, string>) => {
|
||||
const entries = Object.entries(snippetEnv);
|
||||
if (entries.length === 0) return false;
|
||||
return entries.every(([key, value]) => envObj[key] === value);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const applySnippetToEnv = useCallback(
|
||||
(envObj: Record<string, string>, snippetEnv: Record<string, string>) => {
|
||||
const updated = { ...envObj };
|
||||
for (const [key, value] of Object.entries(snippetEnv)) {
|
||||
if (typeof value === "string") {
|
||||
updated[key] = value;
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const removeSnippetFromEnv = useCallback(
|
||||
(envObj: Record<string, string>, snippetEnv: Record<string, string>) => {
|
||||
const updated = { ...envObj };
|
||||
for (const [key, value] of Object.entries(snippetEnv)) {
|
||||
if (typeof value === "string" && updated[key] === value) {
|
||||
delete updated[key];
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 初始化:从 config.json 加载,支持从 localStorage 迁移
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const loadSnippet = async () => {
|
||||
try {
|
||||
// 使用统一 API 加载
|
||||
const snippet = await configApi.getCommonConfigSnippet("gemini");
|
||||
|
||||
if (snippet && snippet.trim()) {
|
||||
if (mounted) {
|
||||
setCommonConfigSnippetState(snippet);
|
||||
}
|
||||
} else {
|
||||
// 如果 config.json 中没有,尝试从 localStorage 迁移
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
const legacySnippet =
|
||||
window.localStorage.getItem(LEGACY_STORAGE_KEY);
|
||||
if (legacySnippet && legacySnippet.trim()) {
|
||||
const parsed = parseSnippetEnv(legacySnippet);
|
||||
if (parsed.error) {
|
||||
console.warn(
|
||||
"[迁移] legacy Gemini 通用配置片段格式不符合当前规则,跳过迁移",
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 迁移到 config.json
|
||||
await configApi.setCommonConfigSnippet("gemini", legacySnippet);
|
||||
if (mounted) {
|
||||
setCommonConfigSnippetState(legacySnippet);
|
||||
}
|
||||
// 清理 localStorage
|
||||
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
|
||||
console.log(
|
||||
"[迁移] Gemini 通用配置已从 localStorage 迁移到 config.json",
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[迁移] 从 localStorage 迁移失败:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("加载 Gemini 通用配置失败:", error);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadSnippet();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [parseSnippetEnv]);
|
||||
|
||||
// 初始化时检查通用配置片段(编辑模式)
|
||||
useEffect(() => {
|
||||
if (initialData?.settingsConfig && !isLoading) {
|
||||
try {
|
||||
const env =
|
||||
isPlainObject(initialData.settingsConfig.env) &&
|
||||
Object.keys(initialData.settingsConfig.env).length > 0
|
||||
? (initialData.settingsConfig.env as Record<string, string>)
|
||||
: {};
|
||||
const parsed = parseSnippetEnv(commonConfigSnippet);
|
||||
if (parsed.error) return;
|
||||
const hasCommon = hasEnvCommonConfigSnippet(
|
||||
env,
|
||||
parsed.env as Record<string, string>,
|
||||
);
|
||||
setUseCommonConfig(hasCommon);
|
||||
} catch {
|
||||
// ignore parse error
|
||||
}
|
||||
}
|
||||
}, [
|
||||
commonConfigSnippet,
|
||||
hasEnvCommonConfigSnippet,
|
||||
initialData,
|
||||
isLoading,
|
||||
parseSnippetEnv,
|
||||
]);
|
||||
|
||||
// 新建模式:如果通用配置片段存在且有效,默认启用
|
||||
useEffect(() => {
|
||||
// 仅新建模式、加载完成、尚未初始化过
|
||||
if (!initialData && !isLoading && !hasInitializedNewMode.current) {
|
||||
hasInitializedNewMode.current = true;
|
||||
|
||||
const parsed = parseSnippetEnv(commonConfigSnippet);
|
||||
if (parsed.error) return;
|
||||
const hasContent = Object.keys(parsed.env).length > 0;
|
||||
if (!hasContent) return;
|
||||
|
||||
setUseCommonConfig(true);
|
||||
const currentEnv = envStringToObj(envValue);
|
||||
const merged = applySnippetToEnv(currentEnv, parsed.env);
|
||||
const nextEnvString = envObjToString(merged);
|
||||
|
||||
isUpdatingFromCommonConfig.current = true;
|
||||
onEnvChange(nextEnvString);
|
||||
setTimeout(() => {
|
||||
isUpdatingFromCommonConfig.current = false;
|
||||
}, 0);
|
||||
}
|
||||
}, [
|
||||
initialData,
|
||||
isLoading,
|
||||
commonConfigSnippet,
|
||||
envValue,
|
||||
envStringToObj,
|
||||
envObjToString,
|
||||
applySnippetToEnv,
|
||||
onEnvChange,
|
||||
parseSnippetEnv,
|
||||
]);
|
||||
|
||||
// 处理通用配置开关
|
||||
const handleCommonConfigToggle = useCallback(
|
||||
(checked: boolean) => {
|
||||
const parsed = parseSnippetEnv(commonConfigSnippet);
|
||||
if (parsed.error) {
|
||||
setCommonConfigError(parsed.error);
|
||||
setUseCommonConfig(false);
|
||||
return;
|
||||
}
|
||||
if (Object.keys(parsed.env).length === 0) {
|
||||
setCommonConfigError(t("geminiConfig.noCommonConfigToApply"));
|
||||
setUseCommonConfig(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentEnv = envStringToObj(envValue);
|
||||
const updatedEnvObj = checked
|
||||
? applySnippetToEnv(currentEnv, parsed.env)
|
||||
: removeSnippetFromEnv(currentEnv, parsed.env);
|
||||
|
||||
setCommonConfigError("");
|
||||
setUseCommonConfig(checked);
|
||||
|
||||
isUpdatingFromCommonConfig.current = true;
|
||||
onEnvChange(envObjToString(updatedEnvObj));
|
||||
setTimeout(() => {
|
||||
isUpdatingFromCommonConfig.current = false;
|
||||
}, 0);
|
||||
},
|
||||
[
|
||||
applySnippetToEnv,
|
||||
commonConfigSnippet,
|
||||
envObjToString,
|
||||
envStringToObj,
|
||||
envValue,
|
||||
onEnvChange,
|
||||
parseSnippetEnv,
|
||||
removeSnippetFromEnv,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
// 处理通用配置片段变化
|
||||
const handleCommonConfigSnippetChange = useCallback(
|
||||
(value: string) => {
|
||||
const previousSnippet = commonConfigSnippet;
|
||||
setCommonConfigSnippetState(value);
|
||||
|
||||
if (!value.trim()) {
|
||||
setCommonConfigError("");
|
||||
// 保存到 config.json(清空)
|
||||
configApi
|
||||
.setCommonConfigSnippet("gemini", "")
|
||||
.catch((error: unknown) => {
|
||||
console.error("保存 Gemini 通用配置失败:", error);
|
||||
setCommonConfigError(
|
||||
t("geminiConfig.saveFailed", { error: String(error) }),
|
||||
);
|
||||
});
|
||||
|
||||
if (useCommonConfig) {
|
||||
const parsed = parseSnippetEnv(previousSnippet);
|
||||
if (!parsed.error && Object.keys(parsed.env).length > 0) {
|
||||
const currentEnv = envStringToObj(envValue);
|
||||
const updatedEnv = removeSnippetFromEnv(currentEnv, parsed.env);
|
||||
onEnvChange(envObjToString(updatedEnv));
|
||||
}
|
||||
setUseCommonConfig(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 校验 JSON 格式
|
||||
const parsed = parseSnippetEnv(value);
|
||||
if (parsed.error) {
|
||||
setCommonConfigError(parsed.error);
|
||||
return;
|
||||
}
|
||||
|
||||
setCommonConfigError("");
|
||||
configApi
|
||||
.setCommonConfigSnippet("gemini", value)
|
||||
.catch((error: unknown) => {
|
||||
console.error("保存 Gemini 通用配置失败:", error);
|
||||
setCommonConfigError(
|
||||
t("geminiConfig.saveFailed", { error: String(error) }),
|
||||
);
|
||||
});
|
||||
|
||||
// 若当前启用通用配置,需要替换为最新片段
|
||||
if (useCommonConfig) {
|
||||
const prevParsed = parseSnippetEnv(previousSnippet);
|
||||
const prevEnv = prevParsed.error ? {} : prevParsed.env;
|
||||
const nextEnv = parsed.env;
|
||||
const currentEnv = envStringToObj(envValue);
|
||||
|
||||
const withoutOld =
|
||||
Object.keys(prevEnv).length > 0
|
||||
? removeSnippetFromEnv(currentEnv, prevEnv)
|
||||
: currentEnv;
|
||||
const withNew =
|
||||
Object.keys(nextEnv).length > 0
|
||||
? applySnippetToEnv(withoutOld, nextEnv)
|
||||
: withoutOld;
|
||||
|
||||
isUpdatingFromCommonConfig.current = true;
|
||||
onEnvChange(envObjToString(withNew));
|
||||
setTimeout(() => {
|
||||
isUpdatingFromCommonConfig.current = false;
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
[
|
||||
applySnippetToEnv,
|
||||
commonConfigSnippet,
|
||||
envObjToString,
|
||||
envStringToObj,
|
||||
envValue,
|
||||
onEnvChange,
|
||||
parseSnippetEnv,
|
||||
removeSnippetFromEnv,
|
||||
t,
|
||||
useCommonConfig,
|
||||
],
|
||||
);
|
||||
|
||||
// 当 env 变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
|
||||
useEffect(() => {
|
||||
if (isUpdatingFromCommonConfig.current || isLoading) {
|
||||
return;
|
||||
}
|
||||
const parsed = parseSnippetEnv(commonConfigSnippet);
|
||||
if (parsed.error) return;
|
||||
const envObj = envStringToObj(envValue);
|
||||
setUseCommonConfig(
|
||||
hasEnvCommonConfigSnippet(envObj, parsed.env as Record<string, string>),
|
||||
);
|
||||
}, [
|
||||
envValue,
|
||||
commonConfigSnippet,
|
||||
envStringToObj,
|
||||
hasEnvCommonConfigSnippet,
|
||||
isLoading,
|
||||
parseSnippetEnv,
|
||||
]);
|
||||
|
||||
// 从编辑器当前内容提取通用配置片段
|
||||
const handleExtract = useCallback(async () => {
|
||||
setIsExtracting(true);
|
||||
setCommonConfigError("");
|
||||
|
||||
try {
|
||||
const extracted = await configApi.extractCommonConfigSnippet("gemini", {
|
||||
settingsConfig: JSON.stringify({
|
||||
env: envStringToObj(envValue),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!extracted || extracted === "{}") {
|
||||
setCommonConfigError(t("geminiConfig.extractNoCommonConfig"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证 JSON 格式
|
||||
const parsed = parseSnippetEnv(extracted);
|
||||
if (parsed.error) {
|
||||
setCommonConfigError(t("geminiConfig.extractedConfigInvalid"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新片段状态
|
||||
setCommonConfigSnippetState(extracted);
|
||||
|
||||
// 保存到后端
|
||||
await configApi.setCommonConfigSnippet("gemini", extracted);
|
||||
} catch (error) {
|
||||
console.error("提取 Gemini 通用配置失败:", error);
|
||||
setCommonConfigError(
|
||||
t("geminiConfig.extractFailed", { error: String(error) }),
|
||||
);
|
||||
} finally {
|
||||
setIsExtracting(false);
|
||||
}
|
||||
}, [envStringToObj, envValue, parseSnippetEnv, t]);
|
||||
|
||||
return {
|
||||
useCommonConfig,
|
||||
commonConfigSnippet,
|
||||
commonConfigError,
|
||||
isLoading,
|
||||
isExtracting,
|
||||
handleCommonConfigToggle,
|
||||
handleCommonConfigSnippetChange,
|
||||
handleExtract,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user