feat(providers): add provider categorization system

- Add ProviderCategory type with official, cn_official, aggregator, third_party, and custom categories
- Update Provider interface and Rust struct to include optional category field
- Enhance ProviderForm to automatically sync category when selecting presets
- Improve PresetSelector to show category-based styling and hints
- Add category classification to all provider presets
- Support differentiated interactions (e.g., hide API key input for official providers)
- Maintain backward compatibility with existing configurations
This commit is contained in:
Jason
2025-09-11 22:33:55 +08:00
parent 9fbce5d0cf
commit eca9c02147
7 changed files with 80 additions and 13 deletions

View File

@@ -14,6 +14,8 @@ pub struct Provider {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "websiteUrl")] #[serde(rename = "websiteUrl")]
pub website_url: Option<String>, pub website_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
} }
impl Provider { impl Provider {
@@ -29,6 +31,7 @@ impl Provider {
name, name,
settings_config, settings_config,
website_url, website_url,
category: None,
} }
} }
} }

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Provider } from "../types"; import { Provider, ProviderCategory } from "../types";
import { AppType } from "../lib/tauri-api"; import { AppType } from "../lib/tauri-api";
import { import {
updateCoAuthoredSetting, updateCoAuthoredSetting,
@@ -16,6 +16,7 @@ import ClaudeConfigEditor from "./ProviderForm/ClaudeConfigEditor";
import CodexConfigEditor from "./ProviderForm/CodexConfigEditor"; import CodexConfigEditor from "./ProviderForm/CodexConfigEditor";
import KimiModelSelector from "./ProviderForm/KimiModelSelector"; import KimiModelSelector from "./ProviderForm/KimiModelSelector";
import { X, AlertCircle, Save } from "lucide-react"; import { X, AlertCircle, Save } from "lucide-react";
// 分类仅用于控制少量交互(如官方禁用 API Key不显示介绍组件
interface ProviderFormProps { interface ProviderFormProps {
appType?: AppType; appType?: AppType;
@@ -46,6 +47,9 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
? JSON.stringify(initialData.settingsConfig, null, 2) ? JSON.stringify(initialData.settingsConfig, null, 2)
: "", : "",
}); });
const [category, setCategory] = useState<ProviderCategory | undefined>(
initialData?.category,
);
// Codex 特有的状态 // Codex 特有的状态
const [codexAuth, setCodexAuth] = useState(""); const [codexAuth, setCodexAuth] = useState("");
@@ -113,6 +117,26 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
} }
}, [initialData]); }, [initialData]);
// 当选择预设变化时,同步类别
useEffect(() => {
if (!showPresets) return;
if (!isCodex) {
if (selectedPreset !== null && selectedPreset >= 0) {
const preset = providerPresets[selectedPreset];
setCategory(preset?.category || (preset?.isOfficial ? "official" : undefined));
} else if (selectedPreset === -1) {
setCategory("custom");
}
} else {
if (selectedCodexPreset !== null && selectedCodexPreset >= 0) {
const preset = codexProviderPresets[selectedCodexPreset];
setCategory(preset?.category || (preset?.isOfficial ? "official" : undefined));
} else if (selectedCodexPreset === -1) {
setCategory("custom");
}
}
}, [showPresets, isCodex, selectedPreset, selectedCodexPreset]);
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(""); setError("");
@@ -177,6 +201,8 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
name: formData.name, name: formData.name,
websiteUrl: formData.websiteUrl, websiteUrl: formData.websiteUrl,
settingsConfig, settingsConfig,
// 仅在用户选择了预设或手动选择“自定义”时持久化分类
...(category ? { category } : {}),
}); });
}; };
@@ -230,6 +256,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
websiteUrl: preset.websiteUrl, websiteUrl: preset.websiteUrl,
settingsConfig: configString, settingsConfig: configString,
}); });
setCategory(preset.category || (preset.isOfficial ? "official" : undefined));
// 设置选中的预设 // 设置选中的预设
setSelectedPreset(index); setSelectedPreset(index);
@@ -272,6 +299,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
setDisableCoAuthored(false); setDisableCoAuthored(false);
setKimiAnthropicModel(""); setKimiAnthropicModel("");
setKimiAnthropicSmallFastModel(""); setKimiAnthropicSmallFastModel("");
setCategory("custom");
}; };
// Codex: 应用预设 // Codex: 应用预设
@@ -290,6 +318,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
})); }));
setSelectedCodexPreset(index); setSelectedCodexPreset(index);
setCategory(preset.category || (preset.isOfficial ? "official" : undefined));
// 清空 API Key让用户重新输入 // 清空 API Key让用户重新输入
setCodexApiKey(""); setCodexApiKey("");
@@ -306,6 +335,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
setCodexAuth(""); setCodexAuth("");
setCodexConfig(""); setCodexConfig("");
setCodexApiKey(""); setCodexApiKey("");
setCategory("custom");
}; };
// 处理 API Key 输入并自动更新配置 // 处理 API Key 输入并自动更新配置
@@ -349,9 +379,11 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// 判断当前选中的预设是否是官方 // 判断当前选中的预设是否是官方
const isOfficialPreset = const isOfficialPreset =
selectedPreset !== null && (selectedPreset !== null &&
selectedPreset >= 0 && selectedPreset >= 0 &&
providerPresets[selectedPreset]?.isOfficial === true; (providerPresets[selectedPreset]?.isOfficial === true ||
providerPresets[selectedPreset]?.category === "official")) ||
category === "official";
// 判断当前选中的预设是否是 Kimi // 判断当前选中的预设是否是 Kimi
const isKimiPreset = const isKimiPreset =
@@ -385,10 +417,14 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
(selectedCodexPreset !== null && selectedCodexPreset !== -1) || (selectedCodexPreset !== null && selectedCodexPreset !== -1) ||
(!showPresets && getCodexAuthApiKey(codexAuth) !== ""); (!showPresets && getCodexAuthApiKey(codexAuth) !== "");
// 不再渲染分类介绍组件,避免造成干扰
const isCodexOfficialPreset = const isCodexOfficialPreset =
selectedCodexPreset !== null && (selectedCodexPreset !== null &&
selectedCodexPreset >= 0 && selectedCodexPreset >= 0 &&
codexProviderPresets[selectedCodexPreset]?.isOfficial === true; (codexProviderPresets[selectedCodexPreset]?.isOfficial === true ||
codexProviderPresets[selectedCodexPreset]?.category === "official")) ||
category === "official";
// Kimi 模型选择处理函数 // Kimi 模型选择处理函数
const handleKimiModelChange = ( const handleKimiModelChange = (

View File

@@ -1,9 +1,11 @@
import React from "react"; import React from "react";
import { Zap } from "lucide-react"; import { Zap } from "lucide-react";
import { ProviderCategory } from "../../types";
interface Preset { interface Preset {
name: string; name: string;
isOfficial?: boolean; isOfficial?: boolean;
category?: ProviderCategory;
} }
interface PresetSelectorProps { interface PresetSelectorProps {
@@ -23,13 +25,13 @@ const PresetSelector: React.FC<PresetSelectorProps> = ({
onCustomClick, onCustomClick,
customLabel = "自定义", customLabel = "自定义",
}) => { }) => {
const getButtonClass = (index: number, isOfficial?: boolean) => { const getButtonClass = (index: number, isOfficial?: boolean, category?: ProviderCategory) => {
const isSelected = selectedIndex === index; const isSelected = selectedIndex === index;
const baseClass = const baseClass =
"inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors"; "inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors";
if (isSelected) { if (isSelected) {
return isOfficial return isOfficial || category === "official"
? `${baseClass} bg-amber-500 text-white` ? `${baseClass} bg-amber-500 text-white`
: `${baseClass} bg-blue-500 text-white`; : `${baseClass} bg-blue-500 text-white`;
} }
@@ -44,8 +46,8 @@ const PresetSelector: React.FC<PresetSelectorProps> = ({
if (selectedIndex !== null && selectedIndex >= 0) { if (selectedIndex !== null && selectedIndex >= 0) {
const preset = presets[selectedIndex]; const preset = presets[selectedIndex];
return preset?.isOfficial return preset?.isOfficial || preset?.category === "official"
? "Claude 官方登录,不需要填写 API Key" ? "官方登录,不需要填写 API Key"
: "使用预设配置,只需填写 API Key"; : "使用预设配置,只需填写 API Key";
} }
@@ -70,10 +72,10 @@ const PresetSelector: React.FC<PresetSelectorProps> = ({
<button <button
key={index} key={index}
type="button" type="button"
className={getButtonClass(index, preset.isOfficial)} className={getButtonClass(index, preset.isOfficial, preset.category)}
onClick={() => onSelectPreset(index)} onClick={() => onSelectPreset(index)}
> >
{preset.isOfficial && <Zap size={14} />} {(preset.isOfficial || preset.category === "official") && <Zap size={14} />}
{preset.name} {preset.name}
</button> </button>
))} ))}

View File

@@ -2,6 +2,7 @@ import React from "react";
import { Provider } from "../types"; import { Provider } from "../types";
import { Play, Edit3, Trash2, CheckCircle2, Users } from "lucide-react"; import { Play, Edit3, Trash2, CheckCircle2, Users } from "lucide-react";
import { buttonStyles, cardStyles, badgeStyles, cn } from "../lib/styles"; import { buttonStyles, cardStyles, badgeStyles, cn } from "../lib/styles";
// 不再在列表中显示分类徽章,避免造成困惑
interface ProviderListProps { interface ProviderListProps {
providers: Record<string, Provider>; providers: Record<string, Provider>;
@@ -99,6 +100,7 @@ const ProviderList: React.FC<ProviderListProps> = ({
<h3 className="font-medium text-gray-900 dark:text-gray-100"> <h3 className="font-medium text-gray-900 dark:text-gray-100">
{provider.name} {provider.name}
</h3> </h3>
{/* 分类徽章已移除 */}
{isCurrent && ( {isCurrent && (
<div className={badgeStyles.success}> <div className={badgeStyles.success}>
<CheckCircle2 size={12} /> <CheckCircle2 size={12} />

View File

@@ -1,12 +1,15 @@
/** /**
* Codex 预设供应商配置模板 * Codex 预设供应商配置模板
*/ */
import { ProviderCategory } from "../types";
export interface CodexProviderPreset { export interface CodexProviderPreset {
name: string; name: string;
websiteUrl: string; websiteUrl: string;
auth: Record<string, any>; // 将写入 ~/.codex/auth.json auth: Record<string, any>; // 将写入 ~/.codex/auth.json
config: string; // 将写入 ~/.codex/config.tomlTOML 字符串) config: string; // 将写入 ~/.codex/config.tomlTOML 字符串)
isOfficial?: boolean; // 标识是否为官方预设 isOfficial?: boolean; // 标识是否为官方预设
category?: ProviderCategory; // 新增:分类
} }
export const codexProviderPresets: CodexProviderPreset[] = [ export const codexProviderPresets: CodexProviderPreset[] = [
@@ -14,6 +17,7 @@ export const codexProviderPresets: CodexProviderPreset[] = [
name: "Codex官方", name: "Codex官方",
websiteUrl: "https://chatgpt.com/codex", websiteUrl: "https://chatgpt.com/codex",
isOfficial: true, isOfficial: true,
category: "official",
// 官方的 key 为null // 官方的 key 为null
auth: { auth: {
OPENAI_API_KEY: null, OPENAI_API_KEY: null,
@@ -23,6 +27,7 @@ export const codexProviderPresets: CodexProviderPreset[] = [
{ {
name: "PackyCode", name: "PackyCode",
websiteUrl: "https://codex.packycode.com/", websiteUrl: "https://codex.packycode.com/",
category: "third_party",
// PackyCode 一般通过 API Key请将占位符替换为你的实际 key // PackyCode 一般通过 API Key请将占位符替换为你的实际 key
auth: { auth: {
OPENAI_API_KEY: "sk-your-api-key-here", OPENAI_API_KEY: "sk-your-api-key-here",

View File

@@ -1,11 +1,14 @@
/** /**
* 预设供应商配置模板 * 预设供应商配置模板
*/ */
import { ProviderCategory } from "../types";
export interface ProviderPreset { export interface ProviderPreset {
name: string; name: string;
websiteUrl: string; websiteUrl: string;
settingsConfig: object; settingsConfig: object;
isOfficial?: boolean; // 标识是否为官方预设 isOfficial?: boolean; // 标识是否为官方预设
category?: ProviderCategory; // 新增:分类
} }
export const providerPresets: ProviderPreset[] = [ export const providerPresets: ProviderPreset[] = [
@@ -16,6 +19,7 @@ export const providerPresets: ProviderPreset[] = [
env: {}, env: {},
}, },
isOfficial: true, // 明确标识为官方预设 isOfficial: true, // 明确标识为官方预设
category: "official",
}, },
{ {
name: "DeepSeek v3.1", name: "DeepSeek v3.1",
@@ -28,6 +32,7 @@ export const providerPresets: ProviderPreset[] = [
ANTHROPIC_SMALL_FAST_MODEL: "deepseek-chat", ANTHROPIC_SMALL_FAST_MODEL: "deepseek-chat",
}, },
}, },
category: "cn_official",
}, },
{ {
name: "智谱GLM", name: "智谱GLM",
@@ -38,6 +43,7 @@ export const providerPresets: ProviderPreset[] = [
ANTHROPIC_AUTH_TOKEN: "", ANTHROPIC_AUTH_TOKEN: "",
}, },
}, },
category: "cn_official",
}, },
{ {
name: "千问Qwen-Coder", name: "千问Qwen-Coder",
@@ -49,6 +55,7 @@ export const providerPresets: ProviderPreset[] = [
ANTHROPIC_AUTH_TOKEN: "", ANTHROPIC_AUTH_TOKEN: "",
}, },
}, },
category: "cn_official",
}, },
{ {
name: "Kimi k2", name: "Kimi k2",
@@ -61,6 +68,7 @@ export const providerPresets: ProviderPreset[] = [
ANTHROPIC_SMALL_FAST_MODEL: "kimi-k2-turbo-preview", ANTHROPIC_SMALL_FAST_MODEL: "kimi-k2-turbo-preview",
}, },
}, },
category: "cn_official",
}, },
{ {
name: "魔搭", name: "魔搭",
@@ -73,6 +81,7 @@ export const providerPresets: ProviderPreset[] = [
ANTHROPIC_SMALL_FAST_MODEL: "ZhipuAI/GLM-4.5", ANTHROPIC_SMALL_FAST_MODEL: "ZhipuAI/GLM-4.5",
}, },
}, },
category: "aggregator",
}, },
{ {
name: "PackyCode", name: "PackyCode",
@@ -83,5 +92,6 @@ export const providerPresets: ProviderPreset[] = [
ANTHROPIC_AUTH_TOKEN: "", ANTHROPIC_AUTH_TOKEN: "",
}, },
}, },
category: "third_party",
}, },
]; ];

View File

@@ -1,8 +1,17 @@
export type ProviderCategory =
| "official" // 官方
| "cn_official" // 国产官方
| "aggregator" // 聚合网站
| "third_party" // 第三方供应商
| "custom"; // 自定义
export interface Provider { export interface Provider {
id: string; id: string;
name: string; name: string;
settingsConfig: Record<string, any>; // 应用配置对象Claude 为 settings.jsonCodex 为 { auth, config } settingsConfig: Record<string, any>; // 应用配置对象Claude 为 settings.jsonCodex 为 { auth, config }
websiteUrl?: string; websiteUrl?: string;
// 新增:供应商分类(用于差异化提示/能力开关)
category?: ProviderCategory;
createdAt?: number; // 添加时间戳(毫秒) createdAt?: number; // 添加时间戳(毫秒)
} }