Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f35924e15c |
No files matched your search
@@ -0,0 +1 @@
|
||||
13
|
||||
@@ -0,0 +1,144 @@
|
||||
# 用户自定义预设方案(User Presets)
|
||||
|
||||
## 动机
|
||||
|
||||
当前内置预设(`providerPresets.ts`)硬编码在源码中,用户无法自行添加自定义供应商预设或修改已有预设的 `apiKeyUrl`(获取 Key / 推广链接)。每次新增或修改都需要修改源码、重新编译、重新打包,缺乏灵活性。
|
||||
|
||||
## 目标
|
||||
|
||||
用户可通过外部配置文件**零代码**添加/修改预设供应商,包含:
|
||||
- 自定义供应商预设(名称、Base URL、API 格式、图标、套餐类型)
|
||||
- 自定义获取 API Key 入口 / 推广链接(`apiKeyUrl`,可直接放带 ref 的链接)
|
||||
- 自定义模型列表(模型 ID、显示名称、上下文大小、能力)
|
||||
- 覆盖内置预设(同一 `id` 时用户预设优先)
|
||||
|
||||
## 方案设计
|
||||
|
||||
### 1. 配置文件位置
|
||||
|
||||
```
|
||||
Windows: %APPDATA%/KimiSwitch/user-presets.json
|
||||
macOS: ~/Library/Application Support/KimiSwitch/user-presets.json
|
||||
Linux: ~/.config/KimiSwitch/user-presets.json
|
||||
```
|
||||
|
||||
统一使用应用数据目录(与 `app_config_dir` 一致),不扫描项目根目录——发行版是编译后的二进制,终端用户没有"项目根目录"。
|
||||
|
||||
首次启动时若文件不存在,自动创建一个空 `[]` 模板文件,方便用户直接编辑。
|
||||
PresetPickerModal / 设置页提供一个"打开预设文件夹"按钮,让用户能找到文件位置。
|
||||
|
||||
### 2. 文件格式
|
||||
|
||||
> 支持 `//` 行注释(jsonc 风格):加载时先剥离 `//` 注释再 `JSON.parse`,方便用户写备注。
|
||||
|
||||
```jsonc
|
||||
[
|
||||
{
|
||||
// 唯一标识,与内置预设 id 重名则覆盖内置预设
|
||||
"id": "my-custom-vendor",
|
||||
// 显示名称(fallback 英文)
|
||||
"name": "My Custom Vendor",
|
||||
// i18n key(可选,有则优先用 t(nameKey))
|
||||
"nameKey": "presetNameMyCustom",
|
||||
// 官网链接
|
||||
"websiteUrl": "https://example.com",
|
||||
// 获取 API Key 入口 / 推广链接(可直接放带 ref 的链接,如 ?ref=CODE)
|
||||
"apiKeyUrl": "https://example.com?ref=MYCODE",
|
||||
// 分类:official | cn_official | third_party | aggregator | custom
|
||||
"category": "third_party",
|
||||
// API 格式:openai | anthropic | google-genai | vertexai | kimi | openai_responses
|
||||
"providerType": "openai",
|
||||
// Base URL(null 表示使用默认)
|
||||
"baseUrl": "https://api.example.com/v1",
|
||||
// 图标 key(从 src/icons/extracted 中选,或空字符串用首字母)
|
||||
"icon": "",
|
||||
// 图标颜色(可选,hex 格式)
|
||||
"iconColor": "",
|
||||
// 计费模式:subscription | pay_as_you_go
|
||||
"billingMode": "pay_as_you_go",
|
||||
// 用量查询类型(可选,参考内置预设)
|
||||
"usageKinds": [],
|
||||
// 模型列表
|
||||
"models": [
|
||||
{
|
||||
"model": "my-model-1",
|
||||
"displayName": "My Model 1",
|
||||
"maxContextSize": 128000,
|
||||
"capabilities": ["thinking"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 3. 加载逻辑(伪代码)
|
||||
|
||||
```
|
||||
function loadAllPresets(): ProviderPreset[] {
|
||||
const builtin = providerPresets; // 内置预设
|
||||
const user = readUserPresetsFile(); // 读取用户预设文件
|
||||
if (!user || user.length === 0) return builtin;
|
||||
|
||||
const merged = new Map<string, ProviderPreset>();
|
||||
for (const p of builtin) merged.set(p.id, p); // 内置先进
|
||||
for (const p of user) merged.set(p.id, p); // 用户覆盖
|
||||
return Array.from(merged.values());
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 需要修改的代码文件
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|----------|
|
||||
| `src/config/providerPresets.ts` | 导出 `loadAllPresets()` 函数替代直接使用 `providerPresets` 数组 |
|
||||
| `src/config/userPresets.ts`(新建) | 实现 `readUserPresetsFile()`:通过 Tauri 命令(Rust 侧 `read_text_file`)读取 + 剥离注释 + `JSON.parse` + 校验结构。用 Rust 命令而非前端 FS 插件,避免 scope 配置且跨平台路径由 Rust `app_config_dir` 统一解析 |
|
||||
| `src/components/PresetPickerModal.tsx` | 将 `providerPresets` 引用改为 `loadAllPresets()` |
|
||||
| `src/config/providerPresets.ts`(`findPresetForProvider`) | 同样需要从合并后的预设中查找 |
|
||||
|
||||
### 5. 校验逻辑
|
||||
|
||||
用户 JSON 文件可能写错,需要做基础校验:
|
||||
|
||||
```typescript
|
||||
interface UserPresetValidation {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
presets: ProviderPreset[];
|
||||
}
|
||||
|
||||
function validateUserPreset(raw: unknown): UserPresetValidation {
|
||||
// 1. 必须是数组
|
||||
// 2. 每项必须有 id、name、providerType、billingMode
|
||||
// 3. providerType 必须是合法值之一(openai | openai_responses | anthropic | google-genai | vertexai | kimi)
|
||||
// 4. billingMode 必须是 "subscription" | "pay_as_you_go"
|
||||
// 5. models 数组每项必须有 model 字段
|
||||
// 6. usageKinds 若提供,每项必须在 SUPPORTED_USAGE_KINDS 内,否则丢弃并警告
|
||||
// (Rust 侧运行时也会静默丢弃未知 kind,这里提前拦截给用户明确反馈)
|
||||
// 7. 忽略未知字段不报错(仅警告)
|
||||
// 8. 单条严重错误时跳过该条,不阻塞其他预设
|
||||
}
|
||||
```
|
||||
|
||||
### 6. 用户交互
|
||||
|
||||
- PresetPickerModal 中用户预设顶部显示,与内置预设分栏展示(或加"自定义"标签)
|
||||
- 若 JSON 文件解析失败,界面上显示一个黄色警告条 + 错误详情
|
||||
- 提供一个"刷新用户预设"按钮,不必重启应用
|
||||
|
||||
### 7. 安全问题
|
||||
|
||||
- 不读取系统敏感路径,仅限于应用数据目录
|
||||
- 不执行 JSON 中的任何代码;`JSON.parse` 产出的是纯对象,无原型污染风险,校验只需关注字段类型与取值
|
||||
- 对 URL 字段(websiteUrl / apiKeyUrl)做基本 `http(s)://` 协议校验,`openUrl` 调用前过滤非法 scheme
|
||||
|
||||
### 8. 向后兼容
|
||||
|
||||
- 没有 `user-presets.json` 时行为完全不变
|
||||
- 用户预设只影响 PresetPickerModal 中的选项,不影响已保存的 Provider
|
||||
- 已保存的 Provider 不受用户预设删除影响
|
||||
|
||||
## 未解决的问题
|
||||
|
||||
1. **图标来源**:用户用内置图标集不存在的 key(含空字符串/null)时,统一降级为首字母显示。是否支持用户自定义 SVG 图标?—— 当前首字母降级足够,先不做。
|
||||
2. **热加载**:文件修改后是否需要自动检测并刷新?—— 初期手动刷新按钮即可,后续可加 `fs::watch`。
|
||||
3. **i18n**:用户预设的 `nameKey` 如果对应内置 i18n key 则显示翻译,否则 fallback 到 `name`。用户不能添加自定义 i18n key(涉及翻译文件修改,违背零代码原则)。
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "kimiswitch",
|
||||
"private": true,
|
||||
"version": "0.6.2",
|
||||
"version": "0.6.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "kimiswitch"
|
||||
version = "0.6.2"
|
||||
version = "0.6.3"
|
||||
description = "Kimi Switch - model config manager"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -856,4 +856,15 @@ pub fn open_installer(app: tauri::AppHandle, path: String) -> Result<(), String>
|
||||
app.opener()
|
||||
.open_path(&path, None::<&str>)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Open an external https/http URL in the system default browser.
|
||||
/// Uses the Rust opener directly (bypassing the JS plugin scope) so it
|
||||
/// works regardless of capability scope configuration.
|
||||
#[tauri::command]
|
||||
pub fn open_external_url(app: tauri::AppHandle, url: String) -> Result<(), String> {
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
app.opener()
|
||||
.open_url(url, None::<&str>)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -104,6 +104,7 @@ pub fn run() {
|
||||
commands::check_for_update,
|
||||
commands::download_update,
|
||||
commands::open_installer,
|
||||
commands::open_external_url,
|
||||
dashboard::get_paths,
|
||||
dashboard::get_prices,
|
||||
dashboard::get_summary,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"productName": "Kimi Switch",
|
||||
"version": "0.6.2",
|
||||
"version": "0.6.3",
|
||||
"identifier": "com.kimiswitch.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createPortal } from "react-dom";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useTranslation } from "../i18n";
|
||||
import type { TranslationKey } from "../i18n/zh";
|
||||
import { findPresetForProvider } from "../config/providerPresets";
|
||||
import { getDefaultMaxContextSize } from "../lib/model-defaults";
|
||||
import { capabilitiesFromRef, getModelRef } from "../lib/models-dev";
|
||||
import { getIconMetadata } from "../icons/extracted/metadata";
|
||||
@@ -117,6 +118,10 @@ export function ProviderEdit({
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [showIconPicker, setShowIconPicker] = useState(false);
|
||||
|
||||
// Preset this provider was created from (if any) — provides the
|
||||
// "Get API Key" / referral links shown under the key input.
|
||||
const preset = findPresetForProvider(provider);
|
||||
|
||||
useEffect(() => {
|
||||
const def = defaultBaseUrl(agent, provider.provider_type);
|
||||
if (!def) return;
|
||||
@@ -215,7 +220,7 @@ export function ProviderEdit({
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor={nameId} className="block text-sm text-content-muted mb-1.5">
|
||||
{t("providerName")}
|
||||
{t("providerName")} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id={nameId}
|
||||
@@ -296,7 +301,7 @@ export function ProviderEdit({
|
||||
{!provider.managed && (
|
||||
<div>
|
||||
<label htmlFor={apiKeyId} className="block text-sm text-content-muted mb-1.5">
|
||||
{t("apiKey")}
|
||||
{t("apiKey")} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
@@ -316,12 +321,29 @@ export function ProviderEdit({
|
||||
{showApiKey ? t("hide") : t("show")}
|
||||
</button>
|
||||
</div>
|
||||
{(() => {
|
||||
const linkUrl = preset?.apiKeyUrl ?? provider.official_url ?? null;
|
||||
if (!linkUrl) return null;
|
||||
return (
|
||||
<div className="mt-1.5 flex items-center gap-4 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
invoke("open_external_url", { url: linkUrl }).catch(() => {})
|
||||
}
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{preset?.apiKeyUrl ? t("getApiKeyLink") : t("officialUrl")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor={baseUrlId} className="block text-sm text-content-muted mb-1.5">
|
||||
{t("requestUrl")}
|
||||
{t("requestUrl")} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id={baseUrlId}
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface ProviderPreset {
|
||||
nameKey?: string;
|
||||
/** Official website / console URL. */
|
||||
websiteUrl?: string;
|
||||
/** Where to create / fetch an API key; shown as a link under the key input. */
|
||||
apiKeyUrl?: string;
|
||||
/** Category: sorting & badge. */
|
||||
category: PresetCategory;
|
||||
@@ -111,6 +112,7 @@ export const providerPresets: ProviderPreset[] = [
|
||||
name: "Kimi For Coding",
|
||||
nameKey: "presetNameKimiCoding",
|
||||
websiteUrl: "https://www.kimi.com/code",
|
||||
apiKeyUrl: "https://kimi-bot.com/activities/zh-cn/invite/share?scenario=invite&from=share_poster&invitation_code=6UJX7J",
|
||||
category: "official",
|
||||
providerType: "kimi",
|
||||
baseUrl: "https://api.kimi.com/coding/v1",
|
||||
@@ -248,6 +250,7 @@ export const providerPresets: ProviderPreset[] = [
|
||||
name: "MiniMax Token Plan",
|
||||
nameKey: "presetNameMinimaxTokenPlan",
|
||||
websiteUrl: "https://platform.minimaxi.com",
|
||||
apiKeyUrl: "https://platform.minimaxi.com/subscribe/token-plan?code=GmmZA629b5&source=link",
|
||||
category: "cn_official",
|
||||
providerType: "openai",
|
||||
// Token Plan is sold against the same platform endpoint as the
|
||||
@@ -274,6 +277,21 @@ export const providerPresets: ProviderPreset[] = [
|
||||
billingMode: "pay_as_you_go",
|
||||
usageKinds: ["balance:stepfun"],
|
||||
},
|
||||
{
|
||||
id: "stepfun-plan",
|
||||
name: "StepFun Plan",
|
||||
nameKey: "presetNameStepfunPlan",
|
||||
websiteUrl: "https://platform.stepfun.com/step-plan",
|
||||
apiKeyUrl: "https://platform.stepfun.com/?invite_code=HSIGHTPS",
|
||||
category: "cn_official",
|
||||
providerType: "openai",
|
||||
// Step Plan endpoint — same host, different path prefix than the
|
||||
// pay-as-you-go API (/v1). Serves both OpenAI and Anthropic protocols.
|
||||
baseUrl: "https://api.stepfun.com/step_plan/v1",
|
||||
icon: "stepfun",
|
||||
models: [{ model: "step-3.7-flash" }, { model: "step-3.5-flash" }],
|
||||
billingMode: "subscription",
|
||||
},
|
||||
{
|
||||
id: "siliconflow",
|
||||
name: "SiliconFlow",
|
||||
@@ -401,7 +419,7 @@ export const providerPresets: ProviderPreset[] = [
|
||||
nameKey: "presetNameOpencodeGo",
|
||||
// Referral link — supports the OpenCode project
|
||||
websiteUrl: "https://opencode.ai/go?ref=DFCNADQCEM",
|
||||
apiKeyUrl: "https://opencode.ai/zen/",
|
||||
apiKeyUrl: "https://opencode.ai/go?ref=DFCNADQCEM",
|
||||
category: "third_party",
|
||||
providerType: "openai",
|
||||
baseUrl: "https://opencode.ai/zen/go/v1",
|
||||
@@ -521,6 +539,21 @@ export function presetToProviderAndModels(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort reverse lookup: which preset (if any) a Provider was created
|
||||
* from. Matches the provider name with any "-2"/"-3" dedup suffix stripped,
|
||||
* falling back to an exact official_url match. Used to surface preset-only
|
||||
* metadata (apiKeyUrl / referralUrl) in the edit form without persisting
|
||||
* extra fields on Provider.
|
||||
*/
|
||||
export function findPresetForProvider(provider: Provider): ProviderPreset | undefined {
|
||||
const base = provider.name.replace(/-\d+$/, "");
|
||||
return (
|
||||
providerPresets.find((p) => p.id === base) ??
|
||||
providerPresets.find((p) => p.websiteUrl != null && p.websiteUrl === provider.official_url)
|
||||
);
|
||||
}
|
||||
|
||||
// Dev-only cross-end drift check: assert that every usage kind referenced by
|
||||
// any preset is one the Rust `UsageKind` enum knows how to handle. The
|
||||
// runtime filter in `query_provider_usage` silently drops unknown kinds,
|
||||
|
||||
@@ -60,6 +60,7 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
apiFormat: "API Format",
|
||||
authField: "Auth Field",
|
||||
apiKey: "API Key",
|
||||
getApiKeyLink: "Get API Key",
|
||||
show: "Show",
|
||||
hide: "Hide",
|
||||
requestUrl: "Request URL",
|
||||
@@ -349,6 +350,7 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
presetNameMinimax: "MiniMax",
|
||||
presetNameMinimaxTokenPlan: "MiniMax Token Plan",
|
||||
presetNameStepfun: "StepFun",
|
||||
presetNameStepfunPlan: "StepFun Plan",
|
||||
presetNameSiliconflow: "SiliconFlow",
|
||||
presetNameNovita: "Novita AI",
|
||||
presetNameOpenrouter: "OpenRouter",
|
||||
|
||||
@@ -58,6 +58,7 @@ export const zhTranslations = {
|
||||
apiFormat: "API 格式",
|
||||
authField: "认证字段",
|
||||
apiKey: "API Key",
|
||||
getApiKeyLink: "获取 API Key",
|
||||
show: "显示",
|
||||
hide: "隐藏",
|
||||
requestUrl: "请求地址",
|
||||
@@ -344,6 +345,7 @@ export const zhTranslations = {
|
||||
presetNameMinimax: "MiniMax",
|
||||
presetNameMinimaxTokenPlan: "MiniMax Token Plan",
|
||||
presetNameStepfun: "阶跃星辰",
|
||||
presetNameStepfunPlan: "阶跃星辰 Step Plan",
|
||||
presetNameSiliconflow: "硅基流动",
|
||||
presetNameNovita: "Novita AI",
|
||||
presetNameOpenrouter: "OpenRouter",
|
||||
|
||||
Reference in new issue
Block a user