feat: support kat-coder & template value (#77)

This commit is contained in:
Lakr
2025-10-02 22:14:35 +08:00
committed by GitHub
parent 94e93137a2
commit d86994eb7e
4 changed files with 341 additions and 3 deletions

1
.gitignore vendored
View File

@@ -9,3 +9,4 @@ release/
.npmrc .npmrc
CLAUDE.md CLAUDE.md
AGENTS.md AGENTS.md
/.claude

View File

@@ -10,8 +10,10 @@ import {
updateTomlCommonConfigSnippet, updateTomlCommonConfigSnippet,
hasTomlCommonConfigSnippet, hasTomlCommonConfigSnippet,
validateJsonConfig, validateJsonConfig,
applyTemplateValues,
} from "../utils/providerConfigUtils"; } from "../utils/providerConfigUtils";
import { providerPresets } from "../config/providerPresets"; import { providerPresets } from "../config/providerPresets";
import type { TemplateValueConfig } from "../config/providerPresets";
import { import {
codexProviderPresets, codexProviderPresets,
generateThirdPartyAuth, generateThirdPartyAuth,
@@ -26,6 +28,136 @@ import { X, AlertCircle, Save } from "lucide-react";
import { isLinux } from "../lib/platform"; import { isLinux } from "../lib/platform";
// 分类仅用于控制少量交互(如官方禁用 API Key不显示介绍组件 // 分类仅用于控制少量交互(如官方禁用 API Key不显示介绍组件
type TemplateValueMap = Record<string, TemplateValueConfig>;
type TemplatePath = Array<string | number>;
const collectTemplatePaths = (
source: unknown,
templateKeys: string[],
currentPath: TemplatePath = [],
acc: TemplatePath[] = [],
): TemplatePath[] => {
if (typeof source === "string") {
const hasPlaceholder = templateKeys.some((key) =>
source.includes(`\${${key}}`),
);
if (hasPlaceholder) {
acc.push([...currentPath]);
}
return acc;
}
if (Array.isArray(source)) {
source.forEach((item, index) =>
collectTemplatePaths(item, templateKeys, [...currentPath, index], acc),
);
return acc;
}
if (source && typeof source === "object") {
Object.entries(source).forEach(([key, value]) =>
collectTemplatePaths(value, templateKeys, [...currentPath, key], acc),
);
}
return acc;
};
const getValueAtPath = (source: any, path: TemplatePath) => {
return path.reduce<any>((acc, key) => {
if (acc === undefined || acc === null) {
return undefined;
}
return acc[key as keyof typeof acc];
}, source);
};
const setValueAtPath = (
target: any,
path: TemplatePath,
value: unknown,
): any => {
if (path.length === 0) {
return value;
}
let current = target;
for (let i = 0; i < path.length - 1; i++) {
const key = path[i];
const nextKey = path[i + 1];
const isNextIndex = typeof nextKey === "number";
if (current[key as keyof typeof current] === undefined) {
current[key as keyof typeof current] = isNextIndex ? [] : {};
} else {
const currentValue = current[key as keyof typeof current];
if (isNextIndex && !Array.isArray(currentValue)) {
current[key as keyof typeof current] = [];
} else if (
!isNextIndex &&
(typeof currentValue !== "object" || currentValue === null)
) {
current[key as keyof typeof current] = {};
}
}
current = current[key as keyof typeof current];
}
const finalKey = path[path.length - 1];
current[finalKey as keyof typeof current] = value;
return target;
};
const applyTemplateValuesToConfigString = (
presetConfig: any,
currentConfigString: string,
values: TemplateValueMap,
) => {
const replacedConfig = applyTemplateValues(presetConfig, values);
const templateKeys = Object.keys(values);
if (templateKeys.length === 0) {
return JSON.stringify(replacedConfig, null, 2);
}
const placeholderPaths = collectTemplatePaths(presetConfig, templateKeys);
try {
const parsedConfig = currentConfigString.trim()
? JSON.parse(currentConfigString)
: {};
let targetConfig: any;
if (Array.isArray(parsedConfig)) {
targetConfig = [...parsedConfig];
} else if (parsedConfig && typeof parsedConfig === "object") {
targetConfig = JSON.parse(JSON.stringify(parsedConfig));
} else {
targetConfig = {};
}
if (placeholderPaths.length === 0) {
return JSON.stringify(targetConfig, null, 2);
}
let mutatedConfig = targetConfig;
for (const path of placeholderPaths) {
const nextValue = getValueAtPath(replacedConfig, path);
if (path.length === 0) {
mutatedConfig = nextValue;
} else {
setValueAtPath(mutatedConfig, path, nextValue);
}
}
return JSON.stringify(mutatedConfig, null, 2);
} catch {
return JSON.stringify(replacedConfig, null, 2);
}
};
const COMMON_CONFIG_STORAGE_KEY = "cc-switch:common-config-snippet"; const COMMON_CONFIG_STORAGE_KEY = "cc-switch:common-config-snippet";
const CODEX_COMMON_CONFIG_STORAGE_KEY = "cc-switch:codex-common-config-snippet"; const CODEX_COMMON_CONFIG_STORAGE_KEY = "cc-switch:codex-common-config-snippet";
const DEFAULT_COMMON_CONFIG_SNIPPET = `{ const DEFAULT_COMMON_CONFIG_SNIPPET = `{
@@ -71,6 +203,9 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
const [claudeModel, setClaudeModel] = useState(""); const [claudeModel, setClaudeModel] = useState("");
const [claudeSmallFastModel, setClaudeSmallFastModel] = useState(""); const [claudeSmallFastModel, setClaudeSmallFastModel] = useState("");
const [baseUrl, setBaseUrl] = useState(""); // 新增:基础 URL 状态 const [baseUrl, setBaseUrl] = useState(""); // 新增:基础 URL 状态
// 模板变量状态
const [templateValues, setTemplateValues] =
useState<Record<string, TemplateValueConfig>>({});
// Codex 特有的状态 // Codex 特有的状态
const [codexAuth, setCodexAuthState] = useState(""); const [codexAuth, setCodexAuthState] = useState("");
@@ -157,6 +292,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
}); });
const [codexCommonConfigError, setCodexCommonConfigError] = useState(""); const [codexCommonConfigError, setCodexCommonConfigError] = useState("");
const isUpdatingFromCodexCommonConfig = useRef(false); const isUpdatingFromCodexCommonConfig = useRef(false);
// -1 表示自定义null 表示未选择,>= 0 表示预设索引 // -1 表示自定义null 表示未选择,>= 0 表示预设索引
const [selectedPreset, setSelectedPreset] = useState<number | null>( const [selectedPreset, setSelectedPreset] = useState<number | null>(
showPresets ? -1 : null showPresets ? -1 : null
@@ -377,6 +513,22 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
setError(currentSettingsError); setError(currentSettingsError);
return; return;
} }
if (selectedTemplatePreset && templateValueEntries.length > 0) {
for (const [key, config] of templateValueEntries) {
const entry = templateValues[key];
const resolvedValue = (
entry?.editorValue ??
entry?.defaultValue ??
config.defaultValue ??
""
).trim();
if (!resolvedValue) {
setError(`请填写 ${config.label}`);
return;
}
}
}
// Claude: 原有逻辑 // Claude: 原有逻辑
if (!formData.settingsConfig.trim()) { if (!formData.settingsConfig.trim()) {
setError("请填写配置内容"); setError("请填写配置内容");
@@ -529,7 +681,30 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
}; };
const applyPreset = (preset: (typeof providerPresets)[0], index: number) => { const applyPreset = (preset: (typeof providerPresets)[0], index: number) => {
const configString = JSON.stringify(preset.settingsConfig, null, 2); let appliedSettingsConfig = preset.settingsConfig;
let initialTemplateValues: TemplateValueMap = {};
if (preset.templateValues) {
initialTemplateValues = Object.fromEntries(
Object.entries(preset.templateValues).map(([key, config]) => [
key,
{
...config,
editorValue: config.editorValue
? config.editorValue
: config.defaultValue ?? "",
},
])
);
appliedSettingsConfig = applyTemplateValues(
preset.settingsConfig,
initialTemplateValues
);
}
setTemplateValues(initialTemplateValues);
const configString = JSON.stringify(appliedSettingsConfig, null, 2);
setFormData({ setFormData({
name: preset.name, name: preset.name,
@@ -554,8 +729,8 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
setCommonConfigError(""); setCommonConfigError("");
// 如果预设包含模型配置,初始化模型输入框 // 如果预设包含模型配置,初始化模型输入框
if (preset.settingsConfig && typeof preset.settingsConfig === "object") { if (appliedSettingsConfig && typeof appliedSettingsConfig === "object") {
const config = preset.settingsConfig as { env?: Record<string, any> }; const config = appliedSettingsConfig as { env?: Record<string, any> };
if (config.env) { if (config.env) {
setClaudeModel(config.env.ANTHROPIC_MODEL || ""); setClaudeModel(config.env.ANTHROPIC_MODEL || "");
setClaudeSmallFastModel(config.env.ANTHROPIC_SMALL_FAST_MODEL || ""); setClaudeSmallFastModel(config.env.ANTHROPIC_SMALL_FAST_MODEL || "");
@@ -577,6 +752,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// 处理点击自定义按钮 // 处理点击自定义按钮
const handleCustomClick = () => { const handleCustomClick = () => {
setSelectedPreset(-1); setSelectedPreset(-1);
setTemplateValues({});
// 设置自定义模板 // 设置自定义模板
const customTemplate = { const customTemplate = {
@@ -803,6 +979,21 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
selectedPreset !== null || selectedPreset !== null ||
(!showPresets && hasApiKeyField(formData.settingsConfig)); (!showPresets && hasApiKeyField(formData.settingsConfig));
const selectedTemplatePreset =
!isCodex &&
selectedPreset !== null &&
selectedPreset >= 0 &&
selectedPreset < providerPresets.length
? providerPresets[selectedPreset]
: null;
const templateValueEntries: Array<[string, TemplateValueConfig]> =
selectedTemplatePreset?.templateValues
? (Object.entries(
selectedTemplatePreset.templateValues
) as Array<[string, TemplateValueConfig]>)
: [];
// 判断当前选中的预设是否是官方 // 判断当前选中的预设是否是官方
const isOfficialPreset = const isOfficialPreset =
(selectedPreset !== null && (selectedPreset !== null &&
@@ -1133,6 +1324,74 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
</div> </div>
)} )}
{!isCodex && selectedTemplatePreset && templateValueEntries.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
- {selectedTemplatePreset.name.trim()} *
</h3>
<div className="space-y-4">
{templateValueEntries.map(([key, config]) => (
<div key={key} className="space-y-2">
<label className="sr-only" htmlFor={`template-${key}`}>
{config.label}
</label>
<input
id={`template-${key}`}
type="text"
required
placeholder={`${config.label} *`}
value={
templateValues[key]?.editorValue ??
config.editorValue ??
config.defaultValue ??
""
}
onChange={(e) => {
const newValue = e.target.value;
setTemplateValues((prev) => {
const prevEntry = prev[key];
const nextEntry: TemplateValueConfig = {
...config,
...(prevEntry ?? {}),
editorValue: newValue,
};
const nextValues: TemplateValueMap = {
...prev,
[key]: nextEntry,
};
if (selectedTemplatePreset) {
try {
const configString = applyTemplateValuesToConfigString(
selectedTemplatePreset.settingsConfig,
formData.settingsConfig,
nextValues
);
setFormData((prevForm) => ({
...prevForm,
settingsConfig: configString,
}));
setSettingsConfigError(
validateSettingsConfig(configString)
);
} catch (err) {
console.error("更新模板值失败:", err);
}
}
return nextValues;
});
}}
aria-label={config.label}
autoComplete="off"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
))}
</div>
</div>
)}
{/* 基础 URL 输入框 - 仅在自定义模式下显示 */} {/* 基础 URL 输入框 - 仅在自定义模式下显示 */}
{!isCodex && showBaseUrlInput && ( {!isCodex && showBaseUrlInput && (
<div className="space-y-2"> <div className="space-y-2">

View File

@@ -3,6 +3,13 @@
*/ */
import { ProviderCategory } from "../types"; import { ProviderCategory } from "../types";
export interface TemplateValueConfig {
label: string;
placeholder: string;
defaultValue?: string;
editorValue: string;
}
export interface ProviderPreset { export interface ProviderPreset {
name: string; name: string;
websiteUrl: string; websiteUrl: string;
@@ -11,6 +18,8 @@ export interface ProviderPreset {
settingsConfig: object; settingsConfig: object;
isOfficial?: boolean; // 标识是否为官方预设 isOfficial?: boolean; // 标识是否为官方预设
category?: ProviderCategory; // 新增:分类 category?: ProviderCategory; // 新增:分类
// 新增:模板变量定义,用于动态替换配置中的值
templateValues?: Record<string, TemplateValueConfig>; // editorValue 存储编辑器中的实时输入值
} }
export const providerPresets: ProviderPreset[] = [ export const providerPresets: ProviderPreset[] = [
@@ -101,4 +110,26 @@ export const providerPresets: ProviderPreset[] = [
}, },
category: "third_party", category: "third_party",
}, },
{
name: "KAT-Coder 官方",
websiteUrl: "https://console.streamlake.ai/wanqing/",
apiKeyUrl: "https://console.streamlake.ai/console/wanqing/api-key",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://vanchin.streamlake.ai/api/gateway/v1/endpoints/${ENDPOINT_ID}/claude-code-proxy",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "KAT-Coder",
ANTHROPIC_SMALL_FAST_MODEL: "KAT-Coder",
},
},
category: "cn_official",
templateValues: {
ENDPOINT_ID: {
label: "Vanchin Endpoint ID",
placeholder: "ep-xxx-xxx",
defaultValue: "",
editorValue: "",
},
},
},
]; ];

View File

@@ -1,5 +1,7 @@
// 供应商配置处理工具函数 // 供应商配置处理工具函数
import type { TemplateValueConfig } from "../config/providerPresets";
const isPlainObject = (value: unknown): value is Record<string, any> => { const isPlainObject = (value: unknown): value is Record<string, any> => {
return Object.prototype.toString.call(value) === "[object Object]"; return Object.prototype.toString.call(value) === "[object Object]";
}; };
@@ -173,6 +175,51 @@ export const getApiKeyFromConfig = (jsonString: string): string => {
} }
}; };
// 模板变量替换
export const applyTemplateValues = (
config: any,
templateValues: Record<string, TemplateValueConfig> | undefined
): any => {
const resolvedValues = Object.fromEntries(
Object.entries(templateValues ?? {}).map(([key, value]) => {
const resolvedValue =
value.editorValue !== undefined
? value.editorValue
: value.defaultValue ?? "";
return [key, resolvedValue];
})
);
const replaceInString = (str: string): string => {
return Object.entries(resolvedValues).reduce((acc, [key, value]) => {
const placeholder = `\${${key}}`;
if (!acc.includes(placeholder)) {
return acc;
}
return acc.split(placeholder).join(value ?? "");
}, str);
};
const traverse = (obj: any): any => {
if (typeof obj === "string") {
return replaceInString(obj);
}
if (Array.isArray(obj)) {
return obj.map(traverse);
}
if (obj && typeof obj === "object") {
const result: any = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = traverse(value);
}
return result;
}
return obj;
};
return traverse(config);
};
// 判断配置中是否存在 API Key 字段 // 判断配置中是否存在 API Key 字段
export const hasApiKeyField = (jsonString: string): boolean => { export const hasApiKeyField = (jsonString: string): boolean => {
try { try {