revert: restore full config overwrite + Common Config Snippet (revert 992dda5c)

Revert the partial key-field merging refactoring introduced in 992dda5c,
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:
Jason
2026-02-27 23:12:34 +08:00
parent b2b20dadd7
commit 859f413756
39 changed files with 3285 additions and 857 deletions
@@ -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}
+94 -108
View File
@@ -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,
};
}
+3 -1
View File
@@ -316,10 +316,12 @@ export const providerPresets: ProviderPreset[] = [
name: "AiHubMix",
websiteUrl: "https://aihubmix.com",
apiKeyUrl: "https://aihubmix.com",
// 说明:该供应商使用 ANTHROPIC_API_KEY(而非 ANTHROPIC_AUTH_TOKEN
apiKeyField: "ANTHROPIC_API_KEY",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://aihubmix.com",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_API_KEY: "",
},
},
// 请求地址候选(用于地址管理/测速),用户可自行选择/覆盖
+31 -6
View File
@@ -51,8 +51,15 @@
},
"claudeConfig": {
"configLabel": "Claude Code settings.json (JSON) *",
"writeCommonConfig": "Write Common Config",
"editCommonConfig": "Edit Common Config",
"editCommonConfigTitle": "Edit Common Config Snippet",
"commonConfigHint": "This snippet will be merged into settings.json when 'Write Common Config' is checked",
"fullSettingsHint": "Full Claude Code settings.json content",
"fragmentSettingsHint": "Config snippet for this provider; will be written to settings.json when activated",
"extractFromCurrent": "Extract from Editor",
"extractNoCommonConfig": "No common config available to extract from editor",
"extractFailed": "Extract failed: {{error}}",
"saveFailed": "Save failed: {{error}}",
"hideAttribution": "Hide AI Attribution",
"alwaysThinking": "Extended Thinking",
"enableTeammates": "Teammates Mode"
@@ -118,7 +125,11 @@
"notes": "Notes",
"notesPlaceholder": "e.g., Company dedicated account",
"configJson": "Config JSON",
"writeCommonConfig": "Write common config",
"editCommonConfigButton": "Edit common config",
"configJsonHint": "Please fill in complete Claude Code configuration",
"editCommonConfigTitle": "Edit common config snippet",
"editCommonConfigHint": "Common config snippet will be merged into all providers that enable it",
"addProvider": "Add Provider",
"sortUpdated": "Sort order updated",
"usageSaved": "Usage query configuration saved",
@@ -669,10 +680,6 @@
"apiFormatHint": "Select the input format for the provider's API",
"apiFormatAnthropic": "Anthropic Messages (Native)",
"apiFormatOpenAIChat": "OpenAI Chat Completions (Requires proxy)",
"authField": "Auth Field",
"authFieldAuthToken": "Auth Token (Default)",
"authFieldApiKey": "API Key",
"authFieldHint": "Most third-party providers use Auth Token; a few require API Key",
"anthropicDefaultHaikuModel": "Default Haiku Model",
"anthropicDefaultSonnetModel": "Default Sonnet Model",
"anthropicDefaultOpusModel": "Default Opus Model",
@@ -744,15 +751,33 @@
"authJsonHint": "Codex auth.json configuration content",
"configToml": "config.toml (TOML)",
"configTomlHint": "Codex config.toml configuration content",
"apiUrlLabel": "API Request URL"
"writeCommonConfig": "Write Common Config",
"editCommonConfig": "Edit Common Config",
"editCommonConfigTitle": "Edit Codex Common Config Snippet",
"commonConfigHint": "This snippet will be appended to the end of config.toml when 'Write Common Config' is checked",
"apiUrlLabel": "API Request URL",
"extractFromCurrent": "Extract from Editor",
"extractNoCommonConfig": "No common config available to extract from editor",
"extractFailed": "Extract failed: {{error}}",
"saveFailed": "Save failed: {{error}}"
},
"geminiConfig": {
"envFile": "Environment Variables (.env)",
"envFileHint": "Configure Gemini environment variables in .env format",
"configJson": "Configuration File (config.json)",
"configJsonHint": "Configure Gemini extended parameters in JSON format (optional)",
"writeCommonConfig": "Write Common Config",
"editCommonConfig": "Edit Common Config",
"editCommonConfigTitle": "Edit Gemini Common Config Snippet",
"commonConfigHint": "This snippet writes to Gemini .env (GOOGLE_GEMINI_BASE_URL and GEMINI_API_KEY are not allowed)",
"extractFromCurrent": "Extract from Editor",
"extractNoCommonConfig": "No common config available to extract from editor",
"extractFailed": "Extract failed: {{error}}",
"saveFailed": "Save failed: {{error}}",
"extractedConfigInvalid": "Extracted config format is invalid",
"invalidJsonFormat": "Common config snippet format error (must be valid JSON)",
"commonConfigInvalidKeys": "Common config snippet must not include GOOGLE_GEMINI_BASE_URL or GEMINI_API_KEY (found: {{keys}})",
"commonConfigInvalidValues": "Common config snippet values must be strings",
"noCommonConfigToApply": "Common config snippet is empty or has no applicable entries",
"configMergeFailed": "Config merge failed: {{error}}",
"configReplaceFailed": "Config replace failed: {{error}}"
+31 -6
View File
@@ -51,8 +51,15 @@
},
"claudeConfig": {
"configLabel": "Claude Code settings.json (JSON) *",
"writeCommonConfig": "共通設定を書き込む",
"editCommonConfig": "共通設定を編集",
"editCommonConfigTitle": "共通設定スニペットを編集",
"commonConfigHint": "「共通設定を書き込む」がオンのとき settings.json にマージされます",
"fullSettingsHint": "Claude Code の settings.json 全文",
"fragmentSettingsHint": "このプロバイダーの設定スニペット。有効化時に settings.json に書き込まれます",
"extractFromCurrent": "編集内容から抽出",
"extractNoCommonConfig": "編集内容から抽出できる共通設定がありません",
"extractFailed": "抽出に失敗しました: {{error}}",
"saveFailed": "保存に失敗しました: {{error}}",
"hideAttribution": "AI署名を非表示",
"alwaysThinking": "拡張思考",
"enableTeammates": "Teammates モード"
@@ -118,7 +125,11 @@
"notes": "メモ",
"notesPlaceholder": "例: 会社用アカウント",
"configJson": "Config JSON",
"writeCommonConfig": "共通設定を書き込む",
"editCommonConfigButton": "共通設定を編集",
"configJsonHint": "Claude Code の設定をすべて入力してください",
"editCommonConfigTitle": "共通設定スニペットを編集",
"editCommonConfigHint": "共通設定スニペットは、この機能をオンにしたすべてのプロバイダーへマージされます",
"addProvider": "プロバイダーを追加",
"sortUpdated": "並び順を更新しました",
"usageSaved": "利用状況の設定を保存しました",
@@ -669,10 +680,6 @@
"apiFormatHint": "プロバイダー API の入力フォーマットを選択",
"apiFormatAnthropic": "Anthropic Messages(ネイティブ)",
"apiFormatOpenAIChat": "OpenAI Chat Completions(プロキシが必要)",
"authField": "認証フィールド",
"authFieldAuthToken": "Auth Token(デフォルト)",
"authFieldApiKey": "API Key",
"authFieldHint": "ほとんどのサードパーティプロバイダーは Auth Token を使用します。一部は API Key が必要です",
"anthropicDefaultHaikuModel": "既定 Haiku モデル",
"anthropicDefaultSonnetModel": "既定 Sonnet モデル",
"anthropicDefaultOpusModel": "既定 Opus モデル",
@@ -744,15 +751,33 @@
"authJsonHint": "Codex の auth.json 設定内容",
"configToml": "config.toml (TOML)",
"configTomlHint": "Codex の config.toml 設定内容",
"apiUrlLabel": "API リクエスト URL"
"writeCommonConfig": "共通設定を書き込む",
"editCommonConfig": "共通設定を編集",
"editCommonConfigTitle": "Codex 共通設定スニペットを編集",
"commonConfigHint": "「共通設定を書き込む」がオンの場合、config.toml の末尾に追記されます",
"apiUrlLabel": "API リクエスト URL",
"extractFromCurrent": "編集内容から抽出",
"extractNoCommonConfig": "編集内容から抽出できる共通設定がありません",
"extractFailed": "抽出に失敗しました: {{error}}",
"saveFailed": "保存に失敗しました: {{error}}"
},
"geminiConfig": {
"envFile": "環境変数 (.env)",
"envFileHint": ".env 形式で Gemini の環境変数を設定",
"configJson": "設定ファイル (config.json)",
"configJsonHint": "Gemini 拡張パラメーターを JSON 形式で設定(任意)",
"writeCommonConfig": "共通設定を書き込む",
"editCommonConfig": "共通設定を編集",
"editCommonConfigTitle": "Gemini 共通設定スニペットを編集",
"commonConfigHint": "このスニペットは Gemini の .env に書き込みます(GOOGLE_GEMINI_BASE_URL、GEMINI_API_KEY は使用できません)",
"extractFromCurrent": "編集内容から抽出",
"extractNoCommonConfig": "編集内容から抽出できる共通設定がありません",
"extractFailed": "抽出に失敗しました: {{error}}",
"saveFailed": "保存に失敗しました: {{error}}",
"extractedConfigInvalid": "抽出した設定のフォーマットが不正です",
"invalidJsonFormat": "共通設定スニペットの形式が不正です(有効な JSON でなければなりません)",
"commonConfigInvalidKeys": "共通設定スニペットに GOOGLE_GEMINI_BASE_URL または GEMINI_API_KEY を含めることはできません(検出: {{keys}}",
"commonConfigInvalidValues": "共通設定スニペットの値は文字列である必要があります",
"noCommonConfigToApply": "共通設定スニペットが空、または適用できる項目がありません",
"configMergeFailed": "設定のマージに失敗しました: {{error}}",
"configReplaceFailed": "設定の置換に失敗しました: {{error}}"
+31 -6
View File
@@ -51,8 +51,15 @@
},
"claudeConfig": {
"configLabel": "Claude Code 配置 (JSON) *",
"writeCommonConfig": "写入通用配置",
"editCommonConfig": "编辑通用配置",
"editCommonConfigTitle": "编辑通用配置片段",
"commonConfigHint": "该片段会在勾选\"写入通用配置\"时合并到 settings.json 中",
"fullSettingsHint": "完整的 Claude Code settings.json 配置内容",
"fragmentSettingsHint": "此供应商的配置片段,激活后将写入 settings.json",
"extractFromCurrent": "从编辑内容提取",
"extractNoCommonConfig": "当前编辑内容没有可提取的通用配置",
"extractFailed": "提取失败: {{error}}",
"saveFailed": "保存失败: {{error}}",
"hideAttribution": "隐藏 AI 署名",
"alwaysThinking": "扩展思考",
"enableTeammates": "Teammates 模式"
@@ -118,7 +125,11 @@
"notes": "备注",
"notesPlaceholder": "例如:公司专用账号",
"configJson": "配置 JSON",
"writeCommonConfig": "写入通用配置",
"editCommonConfigButton": "编辑通用配置",
"configJsonHint": "请填写完整的 Claude Code 配置",
"editCommonConfigTitle": "编辑通用配置片段",
"editCommonConfigHint": "通用配置片段将合并到所有启用它的供应商配置中",
"addProvider": "添加供应商",
"sortUpdated": "排序已更新",
"usageSaved": "用量查询配置已保存",
@@ -669,10 +680,6 @@
"apiFormatHint": "选择供应商 API 的输入格式",
"apiFormatAnthropic": "Anthropic Messages (原生)",
"apiFormatOpenAIChat": "OpenAI Chat Completions (需开启代理)",
"authField": "认证字段",
"authFieldAuthToken": "Auth Token (默认)",
"authFieldApiKey": "API Key",
"authFieldHint": "大多数第三方供应商使用 Auth Token;少数供应商需要 API Key",
"anthropicDefaultHaikuModel": "Haiku 默认模型",
"anthropicDefaultSonnetModel": "Sonnet 默认模型",
"anthropicDefaultOpusModel": "Opus 默认模型",
@@ -744,15 +751,33 @@
"authJsonHint": "Codex auth.json 配置内容",
"configToml": "config.toml (TOML)",
"configTomlHint": "Codex config.toml 配置内容",
"apiUrlLabel": "API 请求地址"
"writeCommonConfig": "写入通用配置",
"editCommonConfig": "编辑通用配置",
"editCommonConfigTitle": "编辑 Codex 通用配置片段",
"commonConfigHint": "该片段会在勾选'写入通用配置'时追加到 config.toml 末尾",
"apiUrlLabel": "API 请求地址",
"extractFromCurrent": "从编辑内容提取",
"extractNoCommonConfig": "当前编辑内容没有可提取的通用配置",
"extractFailed": "提取失败: {{error}}",
"saveFailed": "保存失败: {{error}}"
},
"geminiConfig": {
"envFile": "环境变量 (.env)",
"envFileHint": "使用 .env 格式配置 Gemini 环境变量",
"configJson": "配置文件 (config.json)",
"configJsonHint": "使用 JSON 格式配置 Gemini 扩展参数(可选)",
"writeCommonConfig": "写入通用配置",
"editCommonConfig": "编辑通用配置",
"editCommonConfigTitle": "编辑 Gemini 通用配置片段",
"commonConfigHint": "该片段会写入 Gemini 的 .env(不允许包含 GOOGLE_GEMINI_BASE_URL、GEMINI_API_KEY",
"extractFromCurrent": "从编辑内容提取",
"extractNoCommonConfig": "当前编辑内容没有可提取的通用配置",
"extractFailed": "提取失败: {{error}}",
"saveFailed": "保存失败: {{error}}",
"extractedConfigInvalid": "提取的配置格式错误",
"invalidJsonFormat": "通用配置片段格式错误(必须是有效的 JSON)",
"commonConfigInvalidKeys": "通用配置片段不能包含 GOOGLE_GEMINI_BASE_URL 或 GEMINI_API_KEY(发现:{{keys}}",
"commonConfigInvalidValues": "通用配置片段的值必须是字符串",
"noCommonConfigToApply": "通用配置片段为空或没有可写入的内容",
"configMergeFailed": "配置合并失败: {{error}}",
"configReplaceFailed": "配置替换失败: {{error}}"
+77
View File
@@ -0,0 +1,77 @@
// 配置相关 API
import { invoke } from "@tauri-apps/api/core";
export type AppType = "claude" | "codex" | "gemini" | "omo" | "omo_slim";
/**
* 获取 Claude 通用配置片段(已废弃,使用 getCommonConfigSnippet
* @returns 通用配置片段(JSON 字符串),如果不存在则返回 null
* @deprecated 使用 getCommonConfigSnippet('claude') 替代
*/
export async function getClaudeCommonConfigSnippet(): Promise<string | null> {
return invoke<string | null>("get_claude_common_config_snippet");
}
/**
* 设置 Claude 通用配置片段(已废弃,使用 setCommonConfigSnippet
* @param snippet - 通用配置片段(JSON 字符串)
* @throws 如果 JSON 格式无效
* @deprecated 使用 setCommonConfigSnippet('claude', snippet) 替代
*/
export async function setClaudeCommonConfigSnippet(
snippet: string,
): Promise<void> {
return invoke("set_claude_common_config_snippet", { snippet });
}
/**
* 获取通用配置片段(统一接口)
* @param appType - 应用类型(claude/codex/gemini
* @returns 通用配置片段(原始字符串),如果不存在则返回 null
*/
export async function getCommonConfigSnippet(
appType: AppType,
): Promise<string | null> {
return invoke<string | null>("get_common_config_snippet", { appType });
}
/**
* 设置通用配置片段(统一接口)
* @param appType - 应用类型(claude/codex/gemini
* @param snippet - 通用配置片段(原始字符串)
* @throws 如果格式无效(Claude/Gemini 验证 JSONCodex 暂不验证)
*/
export async function setCommonConfigSnippet(
appType: AppType,
snippet: string,
): Promise<void> {
return invoke("set_common_config_snippet", { appType, snippet });
}
/**
* 提取通用配置片段
*
* 默认读取当前激活供应商的配置;若传入 `options.settingsConfig`,则从编辑器当前内容提取。
* 会自动排除差异化字段(API Key、模型配置、端点等),返回可复用的通用配置片段。
*
* @param appType - 应用类型(claude/codex/gemini
* @param options - 可选:提取来源
* @returns 提取的通用配置片段(JSON/TOML 字符串)
*/
export type ExtractCommonConfigSnippetOptions = {
settingsConfig?: string;
};
export async function extractCommonConfigSnippet(
appType: Exclude<AppType, "omo">,
options?: ExtractCommonConfigSnippetOptions,
): Promise<string> {
const args: Record<string, unknown> = { appType };
const settingsConfig = options?.settingsConfig;
if (typeof settingsConfig === "string" && settingsConfig.trim()) {
args.settingsConfig = settingsConfig;
}
return invoke<string>("extract_common_config_snippet", args);
}
+1
View File
@@ -11,5 +11,6 @@ export { proxyApi } from "./proxy";
export { openclawApi } from "./openclaw";
export { sessionsApi } from "./sessions";
export { workspaceApi } from "./workspace";
export * as configApi from "./config";
export type { ProviderSwitchEvent } from "./providers";
export type { Prompt } from "./prompts";
-9
View File
@@ -146,10 +146,6 @@ export interface ProviderMeta {
// - "anthropic": 原生 Anthropic Messages API 格式,直接透传
// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
apiFormat?: "anthropic" | "openai_chat";
// Claude 认证字段名(仅 Claude 供应商使用)
// - "ANTHROPIC_AUTH_TOKEN" (默认): 大多数第三方/聚合供应商
// - "ANTHROPIC_API_KEY": 少数供应商需要原生 API Key
apiKeyField?: "ANTHROPIC_AUTH_TOKEN" | "ANTHROPIC_API_KEY";
}
// Skill 同步方式
@@ -160,11 +156,6 @@ export type SkillSyncMethod = "auto" | "symlink" | "copy";
// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
export type ClaudeApiFormat = "anthropic" | "openai_chat";
// Claude 认证字段类型
// - "ANTHROPIC_AUTH_TOKEN": 大多数第三方/聚合供应商使用(默认)
// - "ANTHROPIC_API_KEY": 少数供应商需要原生 API Key
export type ClaudeApiKeyField = "ANTHROPIC_AUTH_TOKEN" | "ANTHROPIC_API_KEY";
// 主页面显示的应用配置
export interface VisibleApps {
claude: boolean;
+226 -8
View File
@@ -3,6 +3,86 @@
import type { TemplateValueConfig } from "../config/claudeProviderPresets";
import { normalizeQuotes } from "@/utils/textNormalization";
const isPlainObject = (value: unknown): value is Record<string, any> => {
return Object.prototype.toString.call(value) === "[object Object]";
};
const deepMerge = (
target: Record<string, any>,
source: Record<string, any>,
): Record<string, any> => {
Object.entries(source).forEach(([key, value]) => {
if (isPlainObject(value)) {
if (!isPlainObject(target[key])) {
target[key] = {};
}
deepMerge(target[key], value);
} else {
// 直接覆盖非对象字段(数组/基础类型)
target[key] = value;
}
});
return target;
};
const deepRemove = (
target: Record<string, any>,
source: Record<string, any>,
) => {
Object.entries(source).forEach(([key, value]) => {
if (!(key in target)) return;
if (isPlainObject(value) && isPlainObject(target[key])) {
// 只移除完全匹配的嵌套属性
deepRemove(target[key], value);
if (Object.keys(target[key]).length === 0) {
delete target[key];
}
} else if (isSubset(target[key], value)) {
// 只有当值完全匹配时才删除
delete target[key];
}
});
};
const isSubset = (target: any, source: any): boolean => {
if (isPlainObject(source)) {
if (!isPlainObject(target)) return false;
return Object.entries(source).every(([key, value]) =>
isSubset(target[key], value),
);
}
if (Array.isArray(source)) {
if (!Array.isArray(target) || target.length !== source.length) return false;
return source.every((item, index) => isSubset(target[index], item));
}
return target === source;
};
// 深拷贝函数
const deepClone = <T>(obj: T): T => {
if (obj === null || typeof obj !== "object") return obj;
if (obj instanceof Date) return new Date(obj.getTime()) as T;
if (obj instanceof Array) return obj.map((item) => deepClone(item)) as T;
if (obj instanceof Object) {
const clonedObj = {} as T;
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
clonedObj[key] = deepClone(obj[key]);
}
}
return clonedObj;
}
return obj;
};
export interface UpdateCommonConfigResult {
updatedConfig: string;
error?: string;
}
// 验证JSON配置格式
export const validateJsonConfig = (
value: string,
@@ -22,6 +102,69 @@ export const validateJsonConfig = (
}
};
// 将通用配置片段写入/移除 settingsConfig
export const updateCommonConfigSnippet = (
jsonString: string,
snippetString: string,
enabled: boolean,
): UpdateCommonConfigResult => {
let config: Record<string, any>;
try {
config = jsonString ? JSON.parse(jsonString) : {};
} catch (err) {
return {
updatedConfig: jsonString,
error: "配置 JSON 解析失败,无法写入通用配置",
};
}
if (!snippetString.trim()) {
return {
updatedConfig: JSON.stringify(config, null, 2),
};
}
// 使用统一的验证函数
const snippetError = validateJsonConfig(snippetString, "通用配置片段");
if (snippetError) {
return {
updatedConfig: JSON.stringify(config, null, 2),
error: snippetError,
};
}
const snippet = JSON.parse(snippetString) as Record<string, any>;
if (enabled) {
const merged = deepMerge(deepClone(config), snippet);
return {
updatedConfig: JSON.stringify(merged, null, 2),
};
}
const cloned = deepClone(config);
deepRemove(cloned, snippet);
return {
updatedConfig: JSON.stringify(cloned, null, 2),
};
};
// 检查当前配置是否已包含通用配置片段
export const hasCommonConfigSnippet = (
jsonString: string,
snippetString: string,
): boolean => {
try {
if (!snippetString.trim()) return false;
const config = jsonString ? JSON.parse(jsonString) : {};
const snippet = JSON.parse(snippetString);
if (!isPlainObject(snippet)) return false;
return isSubset(config, snippet);
} catch (err) {
return false;
}
};
// 读取配置中的 API Key(支持 Claude, Codex, Gemini
export const getApiKeyFromConfig = (
jsonString: string,
@@ -151,13 +294,9 @@ export const hasApiKeyField = (
export const setApiKeyInConfig = (
jsonString: string,
apiKey: string,
options: {
createIfMissing?: boolean;
appType?: string;
apiKeyField?: string;
} = {},
options: { createIfMissing?: boolean; appType?: string } = {},
): string => {
const { createIfMissing = false, appType, apiKeyField } = options;
const { createIfMissing = false, appType } = options;
try {
const config = JSON.parse(jsonString);
@@ -197,13 +336,13 @@ export const setApiKeyInConfig = (
return JSON.stringify(config, null, 2);
}
// Claude API Key (优先写入已存在的字段;若两者均不存在且允许创建,则使用 apiKeyField 指定的字段)
// Claude API Key (优先写入已存在的字段;若两者均不存在且允许创建,则默认创建 AUTH_TOKEN 字段)
if ("ANTHROPIC_AUTH_TOKEN" in env) {
env.ANTHROPIC_AUTH_TOKEN = apiKey;
} else if ("ANTHROPIC_API_KEY" in env) {
env.ANTHROPIC_API_KEY = apiKey;
} else if (createIfMissing) {
env[apiKeyField ?? "ANTHROPIC_AUTH_TOKEN"] = apiKey;
env.ANTHROPIC_AUTH_TOKEN = apiKey;
} else {
return jsonString;
}
@@ -213,6 +352,85 @@ export const setApiKeyInConfig = (
}
};
// ========== TOML Config Utilities ==========
export interface UpdateTomlCommonConfigResult {
updatedConfig: string;
error?: string;
}
// 保存之前的通用配置片段,用于替换操作
let previousCommonSnippet = "";
// 将通用配置片段写入/移除 TOML 配置
export const updateTomlCommonConfigSnippet = (
tomlString: string,
snippetString: string,
enabled: boolean,
): UpdateTomlCommonConfigResult => {
if (!snippetString.trim()) {
// 如果片段为空,直接返回原始配置
return {
updatedConfig: tomlString,
};
}
if (enabled) {
// 添加通用配置
// 先移除旧的通用配置(如果有)
let updatedConfig = tomlString;
if (previousCommonSnippet && tomlString.includes(previousCommonSnippet)) {
updatedConfig = tomlString.replace(previousCommonSnippet, "");
}
// 在文件末尾添加新的通用配置
// 确保有适当的换行
const needsNewline = updatedConfig && !updatedConfig.endsWith("\n");
updatedConfig =
updatedConfig + (needsNewline ? "\n\n" : "\n") + snippetString;
// 保存当前通用配置片段
previousCommonSnippet = snippetString;
return {
updatedConfig: updatedConfig.trim() + "\n",
};
} else {
// 移除通用配置
if (tomlString.includes(snippetString)) {
const updatedConfig = tomlString.replace(snippetString, "");
// 清理多余的空行
const cleaned = updatedConfig.replace(/\n{3,}/g, "\n\n").trim();
// 清空保存的状态
previousCommonSnippet = "";
return {
updatedConfig: cleaned ? cleaned + "\n" : "",
};
}
return {
updatedConfig: tomlString,
};
}
};
// 检查 TOML 配置是否已包含通用配置片段
export const hasTomlCommonConfigSnippet = (
tomlString: string,
snippetString: string,
): boolean => {
if (!snippetString.trim()) return false;
// 简单检查配置是否包含片段内容
// 去除空白字符后比较,避免格式差异影响
const normalizeWhitespace = (str: string) => str.replace(/\s+/g, " ").trim();
return normalizeWhitespace(tomlString).includes(
normalizeWhitespace(snippetString),
);
};
// ========== Codex base_url utils ==========
// 从 Codex 的 TOML 配置文本中提取 base_url(支持单/双引号)