feat: v0.3.0 - fix provider data loss, oauth corruption & switch UX
Bug fixes: - Fix config.toml wiped to single provider on switch (now writes all providers) - Fix managed:kimi-code oauth key corruption (oauth/kimi-code was rewritten to oauth/managed-kimi-code, breaking the official subscription) - Fix default_model not switching due to React 18 deferred state update (updateConfig now sets configRef synchronously before setConfig) - Strip KimiSwitch-private default_model from config.toml provider output Architecture: - config.toml is now the authoritative source for Kimi Code provider data - SQLite stores only metadata (notes, official URLs, remembered models) - Load reconciles config.toml with SQLite for external /provider changes - Activate is a no-op for KimiCode (save already writes config.toml) Features: - Selected provider moves to top of list on switch - Add models-dev context size lookup - Add tauri-plugin-single-instance Tests: - 3 new regression tests: oauth key round-trip, all-providers write, private default_model strip
This commit is contained in:
1 parent
5806fac056
commit
139af18826
15 files changed
+2386
-85
No files matched your search
@@ -40,7 +40,7 @@
|
|||||||
Kimi Switch 提供统一的图形界面:
|
Kimi Switch 提供统一的图形界面:
|
||||||
|
|
||||||
- 统一图形界面,分别管理 Kimi Code 和 Pi 两套 Agent 配置,顶部 Tab 切换
|
- 统一图形界面,分别管理 Kimi Code 和 Pi 两套 Agent 配置,顶部 Tab 切换
|
||||||
- 一键切换供应商,自动写入对应 Agent 的原生配置
|
- 一键切换供应商,更新对应 Agent 原生配置的 `default_model`,保留全部供应商
|
||||||
- 一键拉取模型列表(OpenAI / Anthropic / Google GenAI)
|
- 一键拉取模型列表(OpenAI / Anthropic / Google GenAI)
|
||||||
- 切换后给出 `/reload` 提示,确保 Kimi Code 会话立即生效
|
- 切换后给出 `/reload` 提示,确保 Kimi Code 会话立即生效
|
||||||
|
|
||||||
@@ -96,10 +96,11 @@ Kimi Switch 提供统一的图形界面:
|
|||||||
|
|
||||||
**关键设计**:
|
**关键设计**:
|
||||||
|
|
||||||
- Kimi Switch 维护自己的 SQLite 数据库存**完整配置**(所有供应商 + 所有模型,含未激活的)
|
- 对 Kimi Code,`config.toml` 是供应商/模型数据的**权威来源**:所有供应商和模型始终全量保留,`default_model` 决定哪个生效(与 CLI 原生 `/provider` 行为一致)
|
||||||
- 点「切换使用」时只把**当前激活的供应商**写入 Agent 原生配置,其他不写
|
- SQLite 只存 Kimi Switch 专有元数据(备注、官网、每个供应商记住的默认模型),并在 `config.toml` 不完整时兜底
|
||||||
- 这样 Kimi Code / Pi 始终只看到一个活跃供应商,不会被托管/OAuth 默认值干扰
|
- 加载时以 `config.toml` 为准,因此通过 `/provider` 在 CLI 端增/改/删的供应商都会被正确反映,切换不再覆盖丢失
|
||||||
- `raw_other` 字段透传未知键,保证前后往返不丢字段
|
- `raw_other` 字段透传未知键(含 `[oauth]` 段),保证前后往返不丢字段
|
||||||
|
- Pi 的行为不变:切换时仍只写活跃供应商到 `models.json`
|
||||||
|
|
||||||
## 支持的供应商类型
|
## 支持的供应商类型
|
||||||
|
|
||||||
@@ -118,8 +119,8 @@ Kimi Switch 提供统一的图形界面:
|
|||||||
|
|
||||||
| 文件 | 用途 | 备份 |
|
| 文件 | 用途 | 备份 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `%USERPROFILE%\.kimi-switch\kimi-switch.db` | Kimi Switch 自己的 SQLite 数据库,存全量配置 | — |
|
| `%USERPROFILE%\.kimi-switch\kimi-switch.db` | Kimi Switch 自己的 SQLite 数据库,存元数据(备注/官网/记住的模型)+ 迁移兜底 | — |
|
||||||
| `%USERPROFILE%\.kimi-code\config.toml` | Kimi Code CLI 的 TOML 配置(**切换时写入**) | 同目录下 `backups/config.toml.bak.{YYYYMMDD_HHMMSS}`,保留 7 天 |
|
| `%USERPROFILE%\.kimi-code\config.toml` | Kimi Code CLI 的 TOML 配置(**权威数据源,切换/保存时写入**) | 同目录下 `backups/config.toml.bak.{YYYYMMDD_HHMMSS}`,保留 7 天 |
|
||||||
| `%USERPROFILE%\.pi\agent\models.json` | Pi 的供应商+模型配置(**切换时写入**) | 同目录下 `backups/models.json.bak.{YYYYMMDD_HHMMSS}`,保留 7 天 |
|
| `%USERPROFILE%\.pi\agent\models.json` | Pi 的供应商+模型配置(**切换时写入**) | 同目录下 `backups/models.json.bak.{YYYYMMDD_HHMMSS}`,保留 7 天 |
|
||||||
| `%USERPROFILE%\.pi\agent\settings.json` | Pi 的默认供应商/模型(**切换时写入**) | 同目录下 `backups/settings.json.bak.{YYYYMMDD_HHMMSS}`,保留 7 天 |
|
| `%USERPROFILE%\.pi\agent\settings.json` | Pi 的默认供应商/模型(**切换时写入**) | 同目录下 `backups/settings.json.bak.{YYYYMMDD_HHMMSS}`,保留 7 天 |
|
||||||
| `localStorage[kimi-switch-agent]` | 前端记住上次选中的 Agent(kimi_code / pi) | — |
|
| `localStorage[kimi-switch-agent]` | 前端记住上次选中的 Agent(kimi_code / pi) | — |
|
||||||
@@ -213,9 +214,9 @@ npm run dev
|
|||||||
|
|
||||||
| 命令 | 说明 |
|
| 命令 | 说明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `load_agent_config_command(agent)` | 加载配置:优先 SQLite,为空则从 Agent 原生配置导入 |
|
| `load_agent_config_command(agent)` | 加载配置:Kimi Code 以 `config.toml` 为权威数据源,SQLite 补充元数据;Pi 优先 SQLite |
|
||||||
| `save_agent_config_command(agent, config)` | 保存全量配置到 SQLite |
|
| `save_agent_config_command(agent, config)` | 保存到 SQLite;Kimi Code 同时写入 `config.toml` |
|
||||||
| `activate_agent_config_command(agent)` | 把当前激活的供应商写入 Agent 原生配置 |
|
| `activate_agent_config_command(agent)` | 全量写入 Agent 原生配置(Kimi Code 写全部供应商,`default_model` 决定生效项;Pi 仅写活跃供应商) |
|
||||||
| `open_agent_config_dir(agent)` | 用系统资源管理器打开 Agent 配置目录 |
|
| `open_agent_config_dir(agent)` | 用系统资源管理器打开 Agent 配置目录 |
|
||||||
| `get_app_version()` | 返回 `Cargo.toml` 版本号 |
|
| `get_app_version()` | 返回 `Cargo.toml` 版本号 |
|
||||||
| `list_provider_models(provider)` | 调供应商 API 拉取模型列表(异步) |
|
| `list_provider_models(provider)` | 调供应商 API 拉取模型列表(异步) |
|
||||||
@@ -241,7 +242,7 @@ npm run dev
|
|||||||
|
|
||||||
| 快捷键 | 作用 |
|
| 快捷键 | 作用 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `Ctrl + S` | 保存当前修改到 SQLite |
|
| `Ctrl + S` | 保存当前修改到 SQLite + config.toml(Kimi Code) |
|
||||||
| `Ctrl + R` | 重新读取配置(未保存时会提示) |
|
| `Ctrl + R` | 重新读取配置(未保存时会提示) |
|
||||||
| `Ctrl + O` | 打开当前 Agent 的配置目录 |
|
| `Ctrl + O` | 打开当前 Agent 的配置目录 |
|
||||||
|
|
||||||
@@ -303,7 +304,7 @@ A: 在 Kimi Code 会话里执行 `/reload`(Kimi Code CLI 才会重新读取 `~
|
|||||||
A: 关闭窗口前会弹原生 `beforeunload` 提示,标题栏也会显示 `*` 前缀。
|
A: 关闭窗口前会弹原生 `beforeunload` 提示,标题栏也会显示 `*` 前缀。
|
||||||
|
|
||||||
**Q: 怎么备份/迁移我的配置?**
|
**Q: 怎么备份/迁移我的配置?**
|
||||||
A: 备份 `%USERPROFILE%\.kimi-switch\kimi-switch.db` 即可,里面存了全量配置(含未激活的供应商)。
|
A: 对 Kimi Code,`config.toml` 本身就是全量配置的权威来源,直接备份它即可;SQLite 存额外的备注/官网等元数据,需要时一并备份 `kimi-switch.db`。对 Pi,备份 `kimi-switch.db`。
|
||||||
|
|
||||||
**Q: Vertex AI 为什么不能拉取模型列表?**
|
**Q: Vertex AI 为什么不能拉取模型列表?**
|
||||||
A: Vertex 需要 GCP project/location 凭证,当前实现留了 TODO,等 GCP SDK 集成后再补。
|
A: Vertex 需要 GCP project/location 凭证,当前实现留了 TODO,等 GCP SDK 集成后再补。
|
||||||
|
|||||||
+13
-12
@@ -40,7 +40,7 @@
|
|||||||
Kimi Switch provides a unified GUI:
|
Kimi Switch provides a unified GUI:
|
||||||
|
|
||||||
- One unified interface to manage two separate agent configs (Kimi Code and Pi), switched via a top tab
|
- One unified interface to manage two separate agent configs (Kimi Code and Pi), switched via a top tab
|
||||||
- One-click provider switching that writes the active provider into the target agent's native config
|
- One-click provider switching that updates `default_model` in the target agent's native config while preserving all providers
|
||||||
- One-click model-list fetching (OpenAI / Anthropic / Google GenAI)
|
- One-click model-list fetching (OpenAI / Anthropic / Google GenAI)
|
||||||
- A `/reload` hint after switching so Kimi Code sessions take effect immediately
|
- A `/reload` hint after switching so Kimi Code sessions take effect immediately
|
||||||
|
|
||||||
@@ -96,10 +96,11 @@ Kimi Switch provides a unified GUI:
|
|||||||
|
|
||||||
**Key design points**:
|
**Key design points**:
|
||||||
|
|
||||||
- Kimi Switch keeps its own SQLite database for the **full config** (all providers + all models, including inactive ones)
|
- For Kimi Code, `config.toml` is the **authoritative source** for provider/model data: all providers and models are always written in full, and `default_model` selects the active one (matching the CLI's native `/provider` behaviour)
|
||||||
- Clicking "Switch to" writes **only the active provider** into the agent's native config; the others are not written
|
- SQLite stores only Kimi Switch-private metadata (notes, official URLs, per-provider remembered default model) and acts as a fallback when `config.toml` is incomplete
|
||||||
- This way Kimi Code / Pi always see exactly one active provider and are never confused by managed/OAuth defaults
|
- On load, `config.toml` takes precedence, so providers added/edited/deleted via the CLI's `/provider` are correctly reflected — switching no longer overwrites or loses them
|
||||||
- The `raw_other` field passes unknown keys through untouched, so round-trips never lose fields
|
- The `raw_other` field passes unknown keys through untouched (including `[oauth]` blocks), so round-trips never lose fields
|
||||||
|
- Pi behaviour is unchanged: switching still writes only the active provider to `models.json`
|
||||||
|
|
||||||
## Supported Provider Types
|
## Supported Provider Types
|
||||||
|
|
||||||
@@ -118,8 +119,8 @@ Credential precedence: the `api_key` field wins over the same-named key in the `
|
|||||||
|
|
||||||
| File | Purpose | Backup |
|
| File | Purpose | Backup |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `%USERPROFILE%\.kimi-switch\kimi-switch.db` | Kimi Switch's own SQLite database holding the full config | — |
|
| `%USERPROFILE%\.kimi-switch\kimi-switch.db` | Kimi Switch's own SQLite database, holding metadata (notes/official URLs/remembered models) + migration fallback | — |
|
||||||
| `%USERPROFILE%\.kimi-code\config.toml` | Kimi Code CLI's TOML config (**written on switch**) | `backups/config.toml.bak.{YYYYMMDD_HHMMSS}` next to it, kept 7 days |
|
| `%USERPROFILE%\.kimi-code\config.toml` | Kimi Code CLI's TOML config (**authoritative source, written on switch/save**) | `backups/config.toml.bak.{YYYYMMDD_HHMMSS}` next to it, kept 7 days |
|
||||||
| `%USERPROFILE%\.pi\agent\models.json` | Pi's provider+model config (**written on switch**) | `backups/models.json.bak.{YYYYMMDD_HHMMSS}` next to it, kept 7 days |
|
| `%USERPROFILE%\.pi\agent\models.json` | Pi's provider+model config (**written on switch**) | `backups/models.json.bak.{YYYYMMDD_HHMMSS}` next to it, kept 7 days |
|
||||||
| `%USERPROFILE%\.pi\agent\settings.json` | Pi's default provider/model (**written on switch**) | `backups/settings.json.bak.{YYYYMMDD_HHMMSS}` next to it, kept 7 days |
|
| `%USERPROFILE%\.pi\agent\settings.json` | Pi's default provider/model (**written on switch**) | `backups/settings.json.bak.{YYYYMMDD_HHMMSS}` next to it, kept 7 days |
|
||||||
| `localStorage[kimi-switch-agent]` | Frontend remembers the last selected agent (kimi_code / pi) | — |
|
| `localStorage[kimi-switch-agent]` | Frontend remembers the last selected agent (kimi_code / pi) | — |
|
||||||
@@ -213,9 +214,9 @@ Good for pure UI debugging.
|
|||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `load_agent_config_command(agent)` | Load config: SQLite first, falling back to the agent's native config on first use |
|
| `load_agent_config_command(agent)` | Load config: Kimi Code reads `config.toml` as the authoritative source, enriched with SQLite metadata; Pi reads SQLite first |
|
||||||
| `save_agent_config_command(agent, config)` | Save the full config to SQLite |
|
| `save_agent_config_command(agent, config)` | Save to SQLite; for Kimi Code also writes `config.toml` |
|
||||||
| `activate_agent_config_command(agent)` | Write the active provider into the agent's native config |
|
| `activate_agent_config_command(agent)` | Write to the agent's native config (Kimi Code writes all providers — `default_model` picks the active one; Pi writes only the active provider) |
|
||||||
| `open_agent_config_dir(agent)` | Open the agent's config dir in the system file manager |
|
| `open_agent_config_dir(agent)` | Open the agent's config dir in the system file manager |
|
||||||
| `get_app_version()` | Return the `Cargo.toml` version |
|
| `get_app_version()` | Return the `Cargo.toml` version |
|
||||||
| `list_provider_models(provider)` | Fetch model list from the provider API (async) |
|
| `list_provider_models(provider)` | Fetch model list from the provider API (async) |
|
||||||
@@ -241,7 +242,7 @@ Good for pure UI debugging.
|
|||||||
|
|
||||||
| Shortcut | Action |
|
| Shortcut | Action |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `Ctrl + S` | Save current changes to SQLite |
|
| `Ctrl + S` | Save current changes to SQLite + config.toml (Kimi Code) |
|
||||||
| `Ctrl + R` | Reload config (prompts if unsaved) |
|
| `Ctrl + R` | Reload config (prompts if unsaved) |
|
||||||
| `Ctrl + O` | Open the current agent's config dir |
|
| `Ctrl + O` | Open the current agent's config dir |
|
||||||
|
|
||||||
@@ -303,7 +304,7 @@ A: Run `/reload` inside the Kimi Code session (the CLI only re-reads `~/.kimi-co
|
|||||||
A: A native `beforeunload` prompt appears before closing, and the title bar shows a `*` prefix.
|
A: A native `beforeunload` prompt appears before closing, and the title bar shows a `*` prefix.
|
||||||
|
|
||||||
**Q: How do I back up / migrate my config?**
|
**Q: How do I back up / migrate my config?**
|
||||||
A: Back up `%USERPROFILE%\.kimi-switch\kimi-switch.db`; it holds the full config (including inactive providers).
|
A: For Kimi Code, `config.toml` itself is the authoritative full config — back it up directly. SQLite holds extra metadata (notes, official URLs); back up `kimi-switch.db` too if you need those. For Pi, back up `kimi-switch.db`.
|
||||||
|
|
||||||
**Q: Why can't Vertex AI fetch the model list?**
|
**Q: Why can't Vertex AI fetch the model list?**
|
||||||
A: Vertex requires GCP project/location credentials. The current implementation leaves a TODO pending GCP SDK integration.
|
A: Vertex requires GCP project/location credentials. The current implementation leaves a TODO pending GCP SDK integration.
|
||||||
|
|||||||
+3
-1
@@ -1,11 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "kimiswitch",
|
"name": "kimiswitch",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"prebuild": "node scripts/fetch-models-dev.mjs || echo fetch-models-dev failed, using existing snapshot",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
|
"fetch-models-dev": "node scripts/fetch-models-dev.mjs",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"tauri-dev": "tauri dev",
|
"tauri-dev": "tauri dev",
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Fetch the latest model reference data from models.dev and write a compact
|
||||||
|
* snapshot to src/lib/models-dev.json.
|
||||||
|
*
|
||||||
|
* Run manually (`npm run fetch-models-dev`) or automatically before builds
|
||||||
|
* (`prebuild`). Requires network access to models.dev.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { writeFile, mkdir } from "node:fs/promises";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const SOURCE_URL = "https://models.dev/models.json";
|
||||||
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const OUTPUT = join(ROOT, "src", "lib", "models-dev.json");
|
||||||
|
|
||||||
|
const res = await fetch(SOURCE_URL, {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`GET ${SOURCE_URL} returned HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {Record<string, any>} */
|
||||||
|
const raw = await res.json();
|
||||||
|
|
||||||
|
// Keep only the fields the app needs; the raw file carries benchmarks,
|
||||||
|
// descriptions, etc. that would bloat the bundle.
|
||||||
|
const snapshot = {};
|
||||||
|
for (const [key, m] of Object.entries(raw)) {
|
||||||
|
if (!m || typeof m !== "object") continue;
|
||||||
|
const entry = {};
|
||||||
|
if (typeof m.name === "string") entry.name = m.name;
|
||||||
|
// context 0 means "not applicable" (image/audio models) — treat as missing
|
||||||
|
// so callers fall back to regex defaults instead of storing 0.
|
||||||
|
if (typeof m.limit?.context === "number" && m.limit.context > 0) {
|
||||||
|
entry.context = m.limit.context;
|
||||||
|
}
|
||||||
|
if (m.reasoning === true) entry.reasoning = true;
|
||||||
|
if (m.tool_call === true) entry.tool_call = true;
|
||||||
|
if (m.structured_output === true) entry.structured_output = true;
|
||||||
|
const input = m.modalities?.input;
|
||||||
|
if (Array.isArray(input)) {
|
||||||
|
if (input.includes("image")) entry.image = true;
|
||||||
|
if (input.includes("video")) entry.video = true;
|
||||||
|
}
|
||||||
|
snapshot[key] = entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(dirname(OUTPUT), { recursive: true });
|
||||||
|
await writeFile(OUTPUT, JSON.stringify(snapshot, null, 2) + "\n", "utf8");
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`models-dev snapshot: ${Object.keys(snapshot).length} models -> ${OUTPUT}`,
|
||||||
|
);
|
||||||
Generated
+93
-2
@@ -1952,7 +1952,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kimiswitch"
|
name = "kimiswitch"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -1965,6 +1965,7 @@ dependencies = [
|
|||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
|
"tauri-plugin-single-instance",
|
||||||
"toml 0.8.23",
|
"toml 0.8.23",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3773,6 +3774,22 @@ dependencies = [
|
|||||||
"zbus",
|
"zbus",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-single-instance"
|
||||||
|
version = "2.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b3214becf9ef5783c0ae99a3bb25adf5353a7a16ebf53e74b909e29205735c6c"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
|
"zbus",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-runtime"
|
name = "tauri-runtime"
|
||||||
version = "2.11.3"
|
version = "2.11.3"
|
||||||
@@ -4883,6 +4900,15 @@ dependencies = [
|
|||||||
"windows-targets 0.52.6",
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.60.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.53.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
@@ -4931,13 +4957,30 @@ dependencies = [
|
|||||||
"windows_aarch64_gnullvm 0.52.6",
|
"windows_aarch64_gnullvm 0.52.6",
|
||||||
"windows_aarch64_msvc 0.52.6",
|
"windows_aarch64_msvc 0.52.6",
|
||||||
"windows_i686_gnu 0.52.6",
|
"windows_i686_gnu 0.52.6",
|
||||||
"windows_i686_gnullvm",
|
"windows_i686_gnullvm 0.52.6",
|
||||||
"windows_i686_msvc 0.52.6",
|
"windows_i686_msvc 0.52.6",
|
||||||
"windows_x86_64_gnu 0.52.6",
|
"windows_x86_64_gnu 0.52.6",
|
||||||
"windows_x86_64_gnullvm 0.52.6",
|
"windows_x86_64_gnullvm 0.52.6",
|
||||||
"windows_x86_64_msvc 0.52.6",
|
"windows_x86_64_msvc 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.53.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link 0.2.1",
|
||||||
|
"windows_aarch64_gnullvm 0.53.1",
|
||||||
|
"windows_aarch64_msvc 0.53.1",
|
||||||
|
"windows_i686_gnu 0.53.1",
|
||||||
|
"windows_i686_gnullvm 0.53.1",
|
||||||
|
"windows_i686_msvc 0.53.1",
|
||||||
|
"windows_x86_64_gnu 0.53.1",
|
||||||
|
"windows_x86_64_gnullvm 0.53.1",
|
||||||
|
"windows_x86_64_msvc 0.53.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-threading"
|
name = "windows-threading"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -4974,6 +5017,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_msvc"
|
name = "windows_aarch64_msvc"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -4992,6 +5041,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnu"
|
name = "windows_i686_gnu"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -5010,12 +5065,24 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnullvm"
|
name = "windows_i686_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnullvm"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_msvc"
|
name = "windows_i686_msvc"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -5034,6 +5101,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnu"
|
name = "windows_x86_64_gnu"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -5052,6 +5125,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnullvm"
|
name = "windows_x86_64_gnullvm"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -5070,6 +5149,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_msvc"
|
name = "windows_x86_64_msvc"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -5088,6 +5173,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winnow"
|
name = "winnow"
|
||||||
version = "0.5.40"
|
version = "0.5.40"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "kimiswitch"
|
name = "kimiswitch"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
description = "Kimi Switch - model config manager"
|
description = "Kimi Switch - model config manager"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -11,6 +11,7 @@ tauri-build = { version = "2.0.0", features = [] }
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2.0.0", features = ["tray-icon"] }
|
tauri = { version = "2.0.0", features = ["tray-icon"] }
|
||||||
tauri-plugin-opener = "2.0.0"
|
tauri-plugin-opener = "2.0.0"
|
||||||
|
tauri-plugin-single-instance = "2.0.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
indexmap = { version = "2.2", features = ["serde"] }
|
indexmap = { version = "2.2", features = ["serde"] }
|
||||||
|
|||||||
+75
-19
@@ -18,15 +18,61 @@ pub fn debug_log(message: String) {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn load_agent_config_command(agent: Agent) -> Result<Config, String> {
|
pub fn load_agent_config_command(agent: Agent) -> Result<Config, String> {
|
||||||
// Load from Kimi Switch's own SQLite database first.
|
// Load Kimi Switch's own SQLite database (metadata + migration fallback).
|
||||||
match db::load_config(&agent) {
|
let db_config = db::load_config(&agent).ok();
|
||||||
Ok(config) if !config.providers.is_empty() => Ok(config),
|
|
||||||
_ => {
|
|
||||||
// Fallback to the agent's native config on first use.
|
|
||||||
match agent {
|
match agent {
|
||||||
Agent::KimiCode => crate::kimi_code_io::load_kimi_code_config_as_config()
|
Agent::KimiCode => {
|
||||||
.map_err(fmt_anyhow),
|
// config.toml is the authoritative source for provider/model data
|
||||||
|
// because the user can add or edit providers at any time via the
|
||||||
|
// CLI's /provider command. SQLite only enriches with Kimi
|
||||||
|
// Switch-private metadata (note, official_url, remembered default
|
||||||
|
// model) and fills gaps when config.toml is incomplete.
|
||||||
|
let mut config = crate::kimi_code_io::load_kimi_code_config_as_config()
|
||||||
|
.map_err(fmt_anyhow)?;
|
||||||
|
|
||||||
|
if let Some(db) = &db_config {
|
||||||
|
// Enrich config.toml providers with SQLite metadata.
|
||||||
|
for (name, p) in config.providers.iter_mut() {
|
||||||
|
if let Some(db_p) = db.providers.get(name) {
|
||||||
|
p.note = db_p.note.clone();
|
||||||
|
p.official_url = db_p.official_url.clone();
|
||||||
|
// Restore the remembered per-provider default model
|
||||||
|
// (Kimi-Switch-private, stored in raw_other).
|
||||||
|
if let Some(dm) = db_p.raw_other.get("default_model") {
|
||||||
|
match &mut p.raw_other {
|
||||||
|
serde_json::Value::Object(obj) => {
|
||||||
|
obj.insert("default_model".to_string(), dm.clone());
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let mut obj = serde_json::Map::new();
|
||||||
|
obj.insert("default_model".to_string(), dm.clone());
|
||||||
|
p.raw_other = serde_json::Value::Object(obj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Migration safety: include providers/models that exist in
|
||||||
|
// SQLite but not in config.toml (e.g. after upgrading from the
|
||||||
|
// old single-provider-write behaviour).
|
||||||
|
for (name, p) in &db.providers {
|
||||||
|
config.providers.entry(name.clone()).or_insert_with(|| p.clone());
|
||||||
|
}
|
||||||
|
for (alias, m) in &db.models {
|
||||||
|
config.models.entry(alias.clone()).or_insert_with(|| m.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
Agent::Pi => {
|
Agent::Pi => {
|
||||||
|
// Pi: SQLite first, fall back to native config on first use.
|
||||||
|
if let Some(config) = db_config {
|
||||||
|
if !config.providers.is_empty() {
|
||||||
|
return Ok(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
let file = pi_io::load_pi_models().map_err(fmt_anyhow)?;
|
let file = pi_io::load_pi_models().map_err(fmt_anyhow)?;
|
||||||
let mut config = pi_io::pi_file_to_config(&file);
|
let mut config = pi_io::pi_file_to_config(&file);
|
||||||
if config.default_model.is_none() {
|
if config.default_model.is_none() {
|
||||||
@@ -45,26 +91,35 @@ pub fn load_agent_config_command(agent: Agent) -> Result<Config, String> {
|
|||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn save_agent_config_command(agent: Agent, config: Config) -> Result<(), String> {
|
pub fn save_agent_config_command(agent: Agent, config: Config) -> Result<(), String> {
|
||||||
// Save the full Kimi Switch configuration to local SQLite.
|
// Save the full Kimi Switch configuration to local SQLite.
|
||||||
db::save_config(&agent, &config).map_err(fmt_anyhow)
|
db::save_config(&agent, &config).map_err(fmt_anyhow)?;
|
||||||
|
// For Kimi Code, config.toml is the authoritative provider store, so
|
||||||
|
// persist changes there immediately — not only on activation. This
|
||||||
|
// ensures edits (Ctrl+S) survive a restart even without switching.
|
||||||
|
if matches!(agent, Agent::KimiCode) {
|
||||||
|
crate::kimi_code_io::save_config_as_kimi_code(&config).map_err(fmt_anyhow)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn activate_agent_config_command(agent: Agent) -> Result<(), String> {
|
pub fn activate_agent_config_command(agent: Agent) -> Result<(), String> {
|
||||||
// Load the full config from SQLite and write only the active provider
|
match agent {
|
||||||
// to the agent's native config file.
|
Agent::KimiCode => {
|
||||||
|
// No-op: save_agent_config_command already writes config.toml for
|
||||||
|
// Kimi Code. Avoiding a second write here prevents a redundant disk
|
||||||
|
// write + backup on every switch.
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Agent::Pi => {
|
||||||
|
// Load the full config from SQLite and write only the active
|
||||||
|
// provider to Pi's native config files.
|
||||||
let config = db::load_config(&agent).map_err(fmt_anyhow)?;
|
let config = db::load_config(&agent).map_err(fmt_anyhow)?;
|
||||||
let active_config = build_active_config(&config);
|
let active_config = build_active_config(&config);
|
||||||
match agent {
|
|
||||||
Agent::KimiCode => crate::kimi_code_io::save_config_as_kimi_code(&active_config)
|
|
||||||
.map_err(fmt_anyhow),
|
|
||||||
Agent::Pi => {
|
|
||||||
let file = pi_io::config_to_pi_file(&active_config);
|
let file = pi_io::config_to_pi_file(&active_config);
|
||||||
pi_io::save_pi_models(&file).map_err(fmt_anyhow)?;
|
pi_io::save_pi_models(&file).map_err(fmt_anyhow)?;
|
||||||
|
|
||||||
@@ -87,9 +142,10 @@ fn active_provider_and_model(config: &Config) -> Option<(String, String)> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_active_config(config: &Config) -> Config {
|
fn build_active_config(config: &Config) -> Config {
|
||||||
// Only the provider explicitly marked as active is written to the agent's
|
// Used only by Pi: writes only the provider explicitly marked as active
|
||||||
// native config. This ensures Kimi Code / Pi follow Kimi Switch's choice
|
// to Pi's native config so Pi follows Kimi Switch's selection instead of
|
||||||
// instead of falling back to a managed/native provider.
|
// falling back to another provider. Kimi Code does not use this — it
|
||||||
|
// writes all providers and selects via default_model.
|
||||||
let providers: IndexMap<String, Provider> = config
|
let providers: IndexMap<String, Provider> = config
|
||||||
.providers
|
.providers
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
+152
-17
@@ -174,8 +174,13 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
|
|||||||
rest.remove("api_key");
|
rest.remove("api_key");
|
||||||
rest.remove("managed");
|
rest.remove("managed");
|
||||||
rest.remove("enabled");
|
rest.remove("enabled");
|
||||||
rest.remove("oauth");
|
|
||||||
rest.remove("env");
|
rest.remove("env");
|
||||||
|
// NOTE: `oauth` is intentionally kept in raw_other so that the
|
||||||
|
// exact `storage`/`key` block round-trips verbatim on export.
|
||||||
|
// Previously it was stripped here and regenerated on export
|
||||||
|
// with a key derived from the provider name, which corrupted
|
||||||
|
// the managed:kimi-code credential reference (e.g.
|
||||||
|
// "oauth/kimi-code" became "oauth/managed-kimi-code").
|
||||||
toml_value_to_json(&TomlValue::Table(rest))
|
toml_value_to_json(&TomlValue::Table(rest))
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -234,11 +239,10 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
|
|||||||
|
|
||||||
let mut providers_table = Table::new();
|
let mut providers_table = Table::new();
|
||||||
for (name, provider) in &config.providers {
|
for (name, provider) in &config.providers {
|
||||||
// Only write the active provider to Kimi Code config so the agent
|
// Write ALL providers to Kimi Code config. The active provider is
|
||||||
// follows Kimi Switch's selection.
|
// selected via `default_model`, so keeping the full list matches the
|
||||||
if !provider.active {
|
// CLI's native multi-provider behavior and prevents data loss when
|
||||||
continue;
|
// switching.
|
||||||
}
|
|
||||||
let mut pt = Table::new();
|
let mut pt = Table::new();
|
||||||
pt.insert("type".to_string(), TomlValue::String(provider.provider_type.as_str().to_string()));
|
pt.insert("type".to_string(), TomlValue::String(provider.provider_type.as_str().to_string()));
|
||||||
if let Some(base_url) = provider.base_url.clone().filter(|s| !s.is_empty()) {
|
if let Some(base_url) = provider.base_url.clone().filter(|s| !s.is_empty()) {
|
||||||
@@ -273,6 +277,10 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
|
|||||||
if let TomlValue::Table(mut extra) = json_to_toml(&provider.raw_other).unwrap_or(TomlValue::Table(Table::new())) {
|
if let TomlValue::Table(mut extra) = json_to_toml(&provider.raw_other).unwrap_or(TomlValue::Table(Table::new())) {
|
||||||
extra.remove("oauth");
|
extra.remove("oauth");
|
||||||
extra.remove("env");
|
extra.remove("env");
|
||||||
|
// Strip Kimi-Switch-private field: the remembered per-provider
|
||||||
|
// default model is stored in raw_other.default_model and must NOT
|
||||||
|
// leak into the agent's config.toml.
|
||||||
|
extra.remove("default_model");
|
||||||
for (k, v) in extra {
|
for (k, v) in extra {
|
||||||
pt.insert(k, v);
|
pt.insert(k, v);
|
||||||
}
|
}
|
||||||
@@ -281,19 +289,10 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
|
|||||||
}
|
}
|
||||||
root.insert("providers".to_string(), TomlValue::Table(providers_table));
|
root.insert("providers".to_string(), TomlValue::Table(providers_table));
|
||||||
|
|
||||||
// Collect provider names that survived the filter above.
|
// Write all models — each provider's models are kept so the CLI's
|
||||||
let active_provider_names: std::collections::HashSet<&str> = config
|
// /provider command can list and switch between them.
|
||||||
.providers
|
|
||||||
.values()
|
|
||||||
.filter(|p| p.active)
|
|
||||||
.map(|p| p.name.as_str())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let mut models_table = Table::new();
|
let mut models_table = Table::new();
|
||||||
for (alias, model) in &config.models {
|
for (alias, model) in &config.models {
|
||||||
if !active_provider_names.contains(model.provider.as_str()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let mut mt = Table::new();
|
let mut mt = Table::new();
|
||||||
mt.insert("provider".to_string(), TomlValue::String(model.provider.clone()));
|
mt.insert("provider".to_string(), TomlValue::String(model.provider.clone()));
|
||||||
mt.insert("model".to_string(), TomlValue::String(model.model.clone()));
|
mt.insert("model".to_string(), TomlValue::String(model.model.clone()));
|
||||||
@@ -587,4 +586,140 @@ api_key = ""
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kimi_code_oauth_key_roundtrip() {
|
||||||
|
// Regression: importing then exporting managed:kimi-code must preserve
|
||||||
|
// the exact oauth key "oauth/kimi-code". Previously the oauth block
|
||||||
|
// was dropped on import and regenerated on export as
|
||||||
|
// "oauth/managed-kimi-code", breaking the official subscription.
|
||||||
|
let toml_str = r#"
|
||||||
|
default_model = "kimi-code/k3"
|
||||||
|
|
||||||
|
[providers."managed:kimi-code"]
|
||||||
|
type = "kimi"
|
||||||
|
api_key = ""
|
||||||
|
base_url = "https://api.kimi.com/coding/v1"
|
||||||
|
|
||||||
|
[providers."managed:kimi-code".oauth]
|
||||||
|
storage = "file"
|
||||||
|
key = "oauth/kimi-code"
|
||||||
|
|
||||||
|
[models."kimi-code/k3"]
|
||||||
|
provider = "managed:kimi-code"
|
||||||
|
model = "k3"
|
||||||
|
max_context_size = 1048576
|
||||||
|
"#;
|
||||||
|
let value: TomlValue = toml_str.parse().unwrap();
|
||||||
|
let config = kimi_code_to_config(&value);
|
||||||
|
|
||||||
|
// Export back to TOML.
|
||||||
|
let exported = config_to_kimi_code(&config, None);
|
||||||
|
let root = exported.as_table().unwrap();
|
||||||
|
let providers = root.get("providers").unwrap().as_table().unwrap();
|
||||||
|
let managed = providers.get("managed:kimi-code").unwrap().as_table().unwrap();
|
||||||
|
let oauth = managed.get("oauth").unwrap().as_table().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
oauth.get("key").and_then(|v| v.as_str()),
|
||||||
|
Some("oauth/kimi-code"),
|
||||||
|
"oauth key must round-trip verbatim, not be regenerated"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
oauth.get("storage").and_then(|v| v.as_str()),
|
||||||
|
Some("file")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kimi_code_export_writes_all_providers() {
|
||||||
|
// Regression: inactive providers must still be written to config.toml
|
||||||
|
// so that switching does not wipe the provider list.
|
||||||
|
let mut providers = IndexMap::new();
|
||||||
|
providers.insert(
|
||||||
|
"active-one".to_string(),
|
||||||
|
Provider {
|
||||||
|
name: "active-one".to_string(),
|
||||||
|
provider_type: ProviderType::Anthropic,
|
||||||
|
base_url: Some("https://a.example.com".to_string()),
|
||||||
|
api_key: Some("sk-a".to_string()),
|
||||||
|
env: IndexMap::new(),
|
||||||
|
note: None,
|
||||||
|
official_url: None,
|
||||||
|
managed: false,
|
||||||
|
enabled: true,
|
||||||
|
active: true,
|
||||||
|
raw_other: Value::Null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
providers.insert(
|
||||||
|
"inactive-one".to_string(),
|
||||||
|
Provider {
|
||||||
|
name: "inactive-one".to_string(),
|
||||||
|
provider_type: ProviderType::Openai,
|
||||||
|
base_url: Some("https://b.example.com".to_string()),
|
||||||
|
api_key: Some("sk-b".to_string()),
|
||||||
|
env: IndexMap::new(),
|
||||||
|
note: None,
|
||||||
|
official_url: None,
|
||||||
|
managed: false,
|
||||||
|
enabled: true,
|
||||||
|
active: false,
|
||||||
|
raw_other: Value::Null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let config = Config {
|
||||||
|
default_model: None,
|
||||||
|
providers,
|
||||||
|
models: IndexMap::new(),
|
||||||
|
raw_other: Value::Null,
|
||||||
|
};
|
||||||
|
|
||||||
|
let exported = config_to_kimi_code(&config, None);
|
||||||
|
let root = exported.as_table().unwrap();
|
||||||
|
let providers_table = root.get("providers").unwrap().as_table().unwrap();
|
||||||
|
assert_eq!(providers_table.len(), 2, "both providers must be written");
|
||||||
|
assert!(providers_table.contains_key("active-one"));
|
||||||
|
assert!(providers_table.contains_key("inactive-one"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kimi_code_export_strips_private_default_model() {
|
||||||
|
// The remembered per-provider default model (raw_other.default_model)
|
||||||
|
// is a Kimi-Switch-private field and must NOT leak into config.toml.
|
||||||
|
let mut providers = IndexMap::new();
|
||||||
|
providers.insert(
|
||||||
|
"p".to_string(),
|
||||||
|
Provider {
|
||||||
|
name: "p".to_string(),
|
||||||
|
provider_type: ProviderType::Anthropic,
|
||||||
|
base_url: None,
|
||||||
|
api_key: Some("sk-x".to_string()),
|
||||||
|
env: IndexMap::new(),
|
||||||
|
note: None,
|
||||||
|
official_url: None,
|
||||||
|
managed: false,
|
||||||
|
enabled: true,
|
||||||
|
active: true,
|
||||||
|
raw_other: serde_json::json!({"default_model": "some-alias"}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let config = Config {
|
||||||
|
default_model: None,
|
||||||
|
providers,
|
||||||
|
models: IndexMap::new(),
|
||||||
|
raw_other: Value::Null,
|
||||||
|
};
|
||||||
|
|
||||||
|
let exported = config_to_kimi_code(&config, None);
|
||||||
|
let root = exported.as_table().unwrap();
|
||||||
|
let provider = root
|
||||||
|
.get("providers").unwrap()
|
||||||
|
.as_table().unwrap()
|
||||||
|
.get("p").unwrap()
|
||||||
|
.as_table().unwrap();
|
||||||
|
assert!(
|
||||||
|
!provider.contains_key("default_model"),
|
||||||
|
"private default_model must not leak into config.toml"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,16 @@ use tauri::Manager;
|
|||||||
|
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
// Single instance: must be registered before other plugins. When a
|
||||||
|
// second process is launched, this callback runs in the existing
|
||||||
|
// instance and simply brings its window back (including from tray).
|
||||||
|
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.unminimize();
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
}))
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
println!("[Tauri] Setup started");
|
println!("[Tauri] Setup started");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"productName": "Kimi Switch",
|
"productName": "Kimi Switch",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"identifier": "com.kimiswitch.app",
|
"identifier": "com.kimiswitch.app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
+22
-13
@@ -6,6 +6,7 @@ import { ProviderList } from "./components/ProviderList";
|
|||||||
import { ProviderEdit } from "./components/ProviderEdit";
|
import { ProviderEdit } from "./components/ProviderEdit";
|
||||||
import { useTranslation } from "./i18n";
|
import { useTranslation } from "./i18n";
|
||||||
import { getDefaultMaxContextSize } from "./lib/model-defaults";
|
import { getDefaultMaxContextSize } from "./lib/model-defaults";
|
||||||
|
import { getModelRef } from "./lib/models-dev";
|
||||||
import type { Agent, Model, Provider } from "./types";
|
import type { Agent, Model, Provider } from "./types";
|
||||||
|
|
||||||
const AGENT_STORAGE_KEY = "kimi-switch-agent";
|
const AGENT_STORAGE_KEY = "kimi-switch-agent";
|
||||||
@@ -242,26 +243,31 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only the selected provider is active. All other providers (including
|
// Rebuild providers with the selected one moved to the front so it is
|
||||||
// Kimi native/managed and custom) are deactivated so the target agent
|
// visually prioritised. All others are deactivated.
|
||||||
// follows Kimi Switch's choice exactly.
|
|
||||||
const providers: Record<string, Provider> = {};
|
const providers: Record<string, Provider> = {};
|
||||||
for (const [key, p] of Object.entries(cfg.providers)) {
|
providers[name] = { ...target, active: true };
|
||||||
providers[key] = { ...p, active: key === name };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remember the active provider's current default model (if it belongs to
|
// Remember the old active provider's current default model (if it
|
||||||
// that provider) so switching back later restores the user's choice.
|
// belongs to that provider) so switching back later restores the user's
|
||||||
if (activeProviderName && cfg.default_model) {
|
// choice.
|
||||||
const activeProvider = providers[activeProviderName];
|
if (activeProviderName && activeProviderName !== name && cfg.default_model) {
|
||||||
if (activeProvider && cfg.models[cfg.default_model]?.provider === activeProviderName) {
|
const oldActive = cfg.providers[activeProviderName];
|
||||||
|
if (oldActive && cfg.models[cfg.default_model]?.provider === activeProviderName) {
|
||||||
providers[activeProviderName] = setProviderDefaultModel(
|
providers[activeProviderName] = setProviderDefaultModel(
|
||||||
activeProvider,
|
{ ...oldActive, active: false },
|
||||||
cfg.default_model
|
cfg.default_model
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add remaining providers (skip selected and old-active if already added).
|
||||||
|
for (const [key, p] of Object.entries(cfg.providers)) {
|
||||||
|
if (key === name) continue;
|
||||||
|
if (providers[key]) continue;
|
||||||
|
providers[key] = { ...p, active: false };
|
||||||
|
}
|
||||||
|
|
||||||
// Prefer the target provider's remembered default model; fall back to
|
// Prefer the target provider's remembered default model; fall back to
|
||||||
// its first model when there is no saved preference or it is invalid.
|
// its first model when there is no saved preference or it is invalid.
|
||||||
let default_model = getProviderDefaultModel(target);
|
let default_model = getProviderDefaultModel(target);
|
||||||
@@ -315,7 +321,10 @@ export default function App() {
|
|||||||
...m,
|
...m,
|
||||||
alias,
|
alias,
|
||||||
provider: provider.name,
|
provider: provider.name,
|
||||||
max_context_size: m.max_context_size ?? getDefaultMaxContextSize(m.model),
|
max_context_size:
|
||||||
|
m.max_context_size ??
|
||||||
|
getModelRef(m.model)?.context ??
|
||||||
|
getDefaultMaxContextSize(m.model),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core";
|
|||||||
import { useTranslation } from "../i18n";
|
import { useTranslation } from "../i18n";
|
||||||
import type { TranslationKey } from "../i18n/zh";
|
import type { TranslationKey } from "../i18n/zh";
|
||||||
import { getDefaultMaxContextSize } from "../lib/model-defaults";
|
import { getDefaultMaxContextSize } from "../lib/model-defaults";
|
||||||
|
import { capabilitiesFromRef, getModelRef } from "../lib/models-dev";
|
||||||
import { AgentSettingsPanel } from "./AgentSettingsPanel";
|
import { AgentSettingsPanel } from "./AgentSettingsPanel";
|
||||||
import type { Agent, DiscoveredModel, Model, Provider, ProviderType } from "../types";
|
import type { Agent, DiscoveredModel, Model, Provider, ProviderType } from "../types";
|
||||||
|
|
||||||
@@ -400,15 +401,24 @@ function ModelMapping({
|
|||||||
for (const dm of discovered) {
|
for (const dm of discovered) {
|
||||||
if (!selected.has(dm.id)) continue;
|
if (!selected.has(dm.id)) continue;
|
||||||
const alias = dm.id.replace(/[^a-zA-Z0-9_-]/g, "-");
|
const alias = dm.id.replace(/[^a-zA-Z0-9_-]/g, "-");
|
||||||
const max_context_size = dm.max_context_size ?? getDefaultMaxContextSize(dm.id);
|
const ref = getModelRef(dm.id);
|
||||||
|
// Priority: API-provided value > models.dev reference > regex fallback
|
||||||
|
const max_context_size =
|
||||||
|
dm.max_context_size ?? ref?.context ?? getDefaultMaxContextSize(dm.id);
|
||||||
toAdd.push({
|
toAdd.push({
|
||||||
alias,
|
alias,
|
||||||
provider: provider.name,
|
provider: provider.name,
|
||||||
model: dm.id,
|
model: dm.id,
|
||||||
max_context_size,
|
max_context_size,
|
||||||
display_name: dm.display_name,
|
display_name: dm.display_name ?? ref?.name ?? null,
|
||||||
supports_1m: max_context_size >= 1_000_000,
|
supports_1m: max_context_size >= 1_000_000,
|
||||||
capabilities: fetchThinking ? ["thinking"] : [],
|
// Reference data wins when available; otherwise keep the old
|
||||||
|
// fetchThinking-checkbox behavior as the fallback.
|
||||||
|
capabilities: ref
|
||||||
|
? capabilitiesFromRef(ref)
|
||||||
|
: fetchThinking
|
||||||
|
? ["thinking"]
|
||||||
|
: [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
onBulkAdd(toAdd);
|
onBulkAdd(toAdd);
|
||||||
|
|||||||
@@ -48,11 +48,16 @@ export function useConfig(agent: Agent): UseConfigReturn {
|
|||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
const updateConfig = useCallback((updater: (config: Config) => Config) => {
|
const updateConfig = useCallback((updater: (config: Config) => Config) => {
|
||||||
setConfig((prev) => {
|
// Compute next state synchronously from the ref (always current) and set
|
||||||
const next = prev ? updater(prev) : prev;
|
// the ref BEFORE setConfig. Previously the ref was updated inside the
|
||||||
|
// setConfig updater, which React 18 defers — so save() reading the ref
|
||||||
|
// immediately after would get a stale value.
|
||||||
|
const prev = configRef.current;
|
||||||
|
if (prev) {
|
||||||
|
const next = updater(prev);
|
||||||
configRef.current = next;
|
configRef.current = next;
|
||||||
return next;
|
setConfig(next);
|
||||||
});
|
}
|
||||||
setDirty(true);
|
setDirty(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,57 @@
|
|||||||
|
import snapshot from "./models-dev.json";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact per-model reference data from models.dev
|
||||||
|
* (see scripts/fetch-models-dev.mjs).
|
||||||
|
*/
|
||||||
|
export interface ModelRef {
|
||||||
|
name?: string;
|
||||||
|
context?: number;
|
||||||
|
reasoning?: boolean;
|
||||||
|
tool_call?: boolean;
|
||||||
|
structured_output?: boolean;
|
||||||
|
image?: boolean;
|
||||||
|
video?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: Record<string, ModelRef> = snapshot;
|
||||||
|
|
||||||
|
// models.dev keys are "<lab>/<model>" and may be mixed-case (e.g.
|
||||||
|
// "minimax/MiniMax-M3"), while provider APIs return bare model ids
|
||||||
|
// (e.g. "kimi-k2.5"). Index lowercase for tolerant matching.
|
||||||
|
const byLowerKey: Record<string, ModelRef> = {};
|
||||||
|
for (const [key, value] of Object.entries(data)) {
|
||||||
|
byLowerKey[key.toLowerCase()] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up reference data for a provider model id.
|
||||||
|
* Matches exact key first, then any "<lab>/<id>" suffix.
|
||||||
|
* Returns undefined when nothing matches — callers fall back to
|
||||||
|
* getDefaultMaxContextSize / manual capabilities.
|
||||||
|
*/
|
||||||
|
export function getModelRef(modelId: string): ModelRef | undefined {
|
||||||
|
const id = modelId.trim().toLowerCase();
|
||||||
|
if (!id) return undefined;
|
||||||
|
const exact = byLowerKey[id];
|
||||||
|
if (exact) return exact;
|
||||||
|
const suffix = `/${id}`;
|
||||||
|
for (const [key, value] of Object.entries(byLowerKey)) {
|
||||||
|
if (key.endsWith(suffix)) return value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map reference data to Kimi Code capability flags.
|
||||||
|
* Multimodal (image_in/video_in) comes from models.dev input modalities;
|
||||||
|
* always_thinking cannot be derived and stays manual.
|
||||||
|
*/
|
||||||
|
export function capabilitiesFromRef(ref: ModelRef): string[] {
|
||||||
|
const caps: string[] = [];
|
||||||
|
if (ref.reasoning) caps.push("thinking");
|
||||||
|
if (ref.tool_call) caps.push("tool_use");
|
||||||
|
if (ref.image) caps.push("image_in");
|
||||||
|
if (ref.video) caps.push("video_in");
|
||||||
|
return caps;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user