Compare commits

...
8 Commits
Author SHA1 Message Date
KimiSwitch Dev 36fc9c50a8 chore(release): bump to v0.7.13 — 适配 kimi-code 0.41.0 + CodingPlan.site NewAPI 自动识别 + 账单查询体验修复
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s
2026-09-05 16:05:13 +08:00
KimiSwitch Dev 1a11cfbaaf feat(usage): CodingPlan.site 自动识别 NewAPI + 账单查询体验修复
- detect_provider 新增 codingplan.site → balance:newapi(该站为
  NewAPI 实例,实测确认),保存补齐 TS 侧缺失的 balance:newapi 断言
- 凭据缺失等确定性错误改走 Ok(failure)(原 Err 被前端一律显示为
  「网络异常」),错误消息按 kind 前缀包裹后仍可本地化映射
- formatAmount 收口到 usage-display 统一复用:¤ 占位符号不再渲染、
  金额统一两位小数(配置弹窗此前直接拼原始数值)
2026-09-05 16:04:38 +08:00
KimiSwitch Dev 81699e757d feat: 适配 kimi-code 0.41.0 —— 移除 file_history 镜像 + 守卫文案更新
- file_history flag 上游已移除(turn-level file history 恒开):
  从 EXPERIMENTAL_FLAGS / env 探测 / FLAG_LABELS / i18n 全链路删除,
  清单回至 9 个;用户 config 残留键被上游静默忽略
- 危险命令守卫行为更新:auto 模式自 0.41.0 起不再拦截危险命令,
  仅 Manual/YOLO 强制询问——开关描述文案(中英)与类型注释同步
2026-09-05 16:04:21 +08:00
KimiSwitch Dev 4de13ad353 feat(website): 更新官网至 v0.7.12 并去 AI 味改版
- 版本号/下载链接模板化(VERSION 单点维护),changelog 增 v0.7.12 条目
- 数据修正:模型库 5910 → 7495 / 供应商 212,JSON-LD softwareVersion 同步
- 文案重写:清除一键/开箱即用/一目了然等八股措辞(中英 ~50 处),
  改为具体可验证的说法;英文标题 sentence case
- 视觉降 AI 感:删网格底纹与光晕、主色降饱和、Download 三等宽卡改
  Windows 主推不对称布局、Privacy 改单面板、Features 双列错落
- gh-pages 已随本次改动重新部署
2026-09-03 10:53:09 +08:00
KimiSwitch Dev 5138a295e0 chore(release): bump to v0.7.12 — 适配 kimi-code 0.40.1(flag 清单/优先级/危险命令守卫)+ WebUI 竞态修复 + models-dev 7495/212
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s
2026-09-03 10:12:17 +08:00
KimiSwitch Dev a02179c9da chore(data): 同步 models-dev 最新快照(7495 模型 / 212 供应商) 2026-09-03 10:11:10 +08:00
KimiSwitch Dev 86a128efea feat: 适配 kimi-code 0.40.1 —— flag 清单/优先级语义/危险命令守卫
- flag 清单 9→10:新增 file_history、search_worker;secondary-model
  0.40.1 起默认开启(defaultEnabled)
- env 探测补齐为 12 项元组(10 flag + master + legacy),修正此前
  tower/subagent_fork/wait_for/auto_session_title 四项漏探
- 优先级语义跟随上游 flagService:单 flag env > [experimental] 显式值
  > master env(仅强制开)> 默认。master env 不再锁定 UI;setExperimentalFlag
  增加 explicitFalse 路径,关闭默认开/master 强开的 flag 时写字面 false
- 权限设置新增 dangerous_command_guard 开关(默认开,写 [permission]
  蛇形键;env KIMI_CODE_DANGEROUS_COMMAND_GUARD 运行时覆盖 config)
- 新增 11 个测试(注册表/写语义/守卫读写)
2026-09-03 10:10:52 +08:00
KimiSwitch Dev 6a7c8f5393 fix(webui): 修复首次打开慢时多次点击导致的竞态报错
open_kimi_web_embedded 加 launch_lock 串行化并发调用,拿锁后重查单例;
oneshot 在锁内等待主线程建窗完成,封死命令返回后的残留空档。
spawned_by_me 门控失败路径的 server 清理,复用的 server 不再被误杀;
spawn 前用 try_wait 检查已有子进程避免覆盖泄漏。前端两个打开按钮加
busy 态防重入(打开中...)。
2026-09-03 10:10:40 +08:00
36 changed files with 6709 additions and 5326 deletions

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "kimiswitch",
"private": true,
"version": "0.7.11",
"version": "0.7.13",
"type": "module",
"scripts": {
"dev": "vite",
+16
View File
@@ -0,0 +1,16 @@
# KimiSwitch v0.7.12
## 适配 kimi-code 0.40.1
- **实验 flag 清单更新(9→10)**:新增 `file_history`(轮级文件历史快照)、`search_worker`(kap-server 全局搜索 worker);`secondary-model`(子代理次主力模型)自 kimi-code 0.40.1 起默认开启,设置页显示"默认开启"徽章
- **flag 优先级语义修正**:跟随上游新优先级——单 flag 环境变量 > `[experimental]` 显式配置 > 总开关环境变量(仅强制开)> 默认值。总开关 `KIMI_CODE_EXPERIMENTAL_FLAG` 不再锁定全部开关,显式写入的 false/true 优先生效
- **环境变量探测补齐**:`tower` / `subagent_fork` / `wait_for` / `auto_session_title` 等此前漏探测的环境变量现在会正确显示"被环境变量锁定"状态
- **新增危险命令守卫开关**:权限设置页可配置 `[permission] dangerous_command_guard`(默认开启)——Auto 模式直接拒绝 `rm -rf` / `shutdown` 等危险命令,其它模式强制询问,关闭后回归旧行为
## 修复
- **WebUI 打开竞态**:首次打开较慢时多次点击"在应用内打开"不再报错——并发调用串行化 + 后到的点击自动聚焦已有窗口;失败路径不再误杀正在使用的 server;前端按钮增加"打开中..."防重入态
## 数据
- models-dev 快照更新至 7495 模型 / 212 供应商
+16
View File
@@ -0,0 +1,16 @@
# KimiSwitch v0.7.13
## 适配 kimi-code 0.41.0
- **实验 flag 清单回至 9 个**:上游 0.41.0 移除了 `file_history` 实验 flag(轮级文件历史转为恒开),KimiSwitch 镜像全链路同步删除;用户配置中残留的该键会被上游静默忽略,无副作用
- **危险命令守卫文案更新**:Auto(永不询问)模式自 0.41.0 起不再拦截危险命令与无法静态分析的命令,仅 Manual/YOLO 模式强制询问——设置页描述文案(中英)同步修正
## 账单查询改进
- **CodingPlan.site 自动识别**:`codingplan.site` 系供应商自动识别为 NewAPI 余额查询,无需手动选模板
- **修复凭据缺失误报「网络异常」**:NewAPI 模板未配置 Access Token/User ID 时,现在显示明确的本地化配置提示,而非误导性的网络错误
- **金额显示统一**:配置弹窗与卡片底部走同一格式化(两位小数);NewAPI 站点未配置货币符号时的 `¤` 占位符不再显示
## 其它
- models-dev 快照随 0.7.12 线更新;新增 usage 错误本地化单元测试
+1 -1
View File
@@ -1978,7 +1978,7 @@ dependencies = [
[[package]]
name = "kimiswitch"
version = "0.7.11"
version = "0.7.13"
dependencies = [
"anyhow",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "kimiswitch"
version = "0.7.11"
version = "0.7.13"
description = "Kimi Switch - model config manager"
authors = ["codingplan.site"]
edition = "2021"
+119 -59
View File
@@ -690,8 +690,8 @@ pub async fn query_provider_usage(
let base_url = resolve_base_url(provider);
// NewAPI template: query the gateway's own /api/user/self with the
// web-console access token, bypassing usage_kinds entirely.
// NewAPI template: query the gateway's own usage endpoints with its
// dedicated credentials, bypassing usage_kinds entirely.
if let Some(cfg) = &provider.usage_config {
if cfg.template_type == UsageConfig::TEMPLATE_NEWAPI {
// Err = transient (network) → propagate for retry, same semantics
@@ -1014,6 +1014,9 @@ pub struct KimiWebState {
window: Mutex<Option<tauri::WebviewWindow>>,
/// The `kimi web` child spawned by this app (None when reusing a server).
child: Mutex<Option<std::process::Child>>,
/// Serializes `open_kimi_web_embedded` invocations: a rapid double-click
/// queues here instead of racing the window/server setup.
launch_lock: tokio::sync::Mutex<()>,
}
const KIMI_WEB_PORT: u16 = 58627;
@@ -1064,7 +1067,13 @@ pub async fn open_kimi_web_embedded(
app: tauri::AppHandle,
state: tauri::State<'_, KimiWebState>,
) -> Result<(), String> {
// Singleton: focus the existing window instead of creating a second one.
// Serialize concurrent invocations (rapid double-click): a second call
// waits here until the first has finished building the window, then
// re-checks the singleton below and focuses it instead.
let _guard = state.launch_lock.lock().await;
// Singleton (re-checked after acquiring the lock): focus the existing
// window instead of creating a second one.
if let Ok(guard) = state.window.lock() {
if let Some(w) = guard.as_ref() {
if w.is_visible().unwrap_or(false) {
@@ -1075,8 +1084,21 @@ pub async fn open_kimi_web_embedded(
}
}
// Reuse an already-running server, otherwise spawn `kimi web --no-open`.
if !kimi_web_alive(Duration::from_millis(800)).await {
// Reuse the server this app spawned earlier (still running), or a server
// already running outside the app; only spawn when neither is present.
// `spawned_by_me` gates every failure-path kill, so a reused server is
// never terminated.
let mut spawned_by_me = false;
let mut child_running = false;
if let Ok(mut guard) = state.child.lock() {
// try_wait: Ok(None) = still running. Exited or unwaitable children
// are treated as gone so a fresh server is spawned below.
child_running = guard
.as_mut()
.map(|child| child.try_wait().map(|st| st.is_none()).unwrap_or(false))
.unwrap_or(false);
}
if !child_running && !kimi_web_alive(Duration::from_millis(800)).await {
let mut cmd = std::process::Command::new("kimi");
cmd.args(["web", "--no-open", "--port", &KIMI_WEB_PORT.to_string()]);
#[cfg(windows)]
@@ -1091,6 +1113,7 @@ pub async fn open_kimi_web_embedded(
format!("failed to start `kimi web`: {e}")
}
})?;
spawned_by_me = true;
if let Ok(mut guard) = state.child.lock() {
*guard = Some(child);
}
@@ -1098,57 +1121,79 @@ pub async fn open_kimi_web_embedded(
// Wait for the server to come up (fresh start takes a moment).
if !kimi_web_alive(Duration::from_secs(8)).await {
kill_spawned_kimi_web(&state);
if spawned_by_me {
kill_spawned_kimi_web(&state);
}
return Err(format!("kimi web did not come up within 8s ({KIMI_WEB_ORIGIN})"));
}
// Create the window on the main thread (required on macOS).
// Build the URL, then create the window on the main thread (required on
// macOS). The build result is awaited while still holding `launch_lock`,
// so a concurrent second invocation blocks here and then lands on the
// singleton re-check above once the window exists.
let url: tauri::Url = match kimi_web_url().parse() {
Ok(u) => u,
Err(_) => return Err("invalid kimi web url".to_string()),
};
let app = app.clone();
tauri::async_runtime::spawn(async move {
let builder_app = app.clone();
let _ = app.run_on_main_thread(move || {
match tauri::WebviewWindowBuilder::new(
&builder_app,
KIMI_WEB_WINDOW_LABEL,
tauri::WebviewUrl::External(url),
)
.title("Kimi Code WebUI")
.inner_size(1100.0, 750.0)
.resizable(true)
.center()
.build()
{
Ok(window) => {
// Track the window for the singleton check.
if let Ok(mut guard) = builder_app.state::<KimiWebState>().window.lock() {
*guard = Some(window.clone());
}
// Clean up when the window closes: forget it and stop the
// `kimi web` server we spawned (reused servers are untouched).
let w = window.clone();
window.on_window_event(move |event| match event {
tauri::WindowEvent::Destroyed => {
let app = w.app_handle().clone();
let state = app.state::<KimiWebState>();
if let Ok(mut guard) = state.window.lock() {
*guard = None;
}
kill_spawned_kimi_web(&state);
}
_ => {}
});
}
Err(_) => {
// Window creation failed (e.g. rapid double-click racing
// the singleton check): don't leak the server we spawned.
kill_spawned_kimi_web(&builder_app.state::<KimiWebState>());
let (tx, rx) = tokio::sync::oneshot::channel::<Result<(), String>>();
let builder_app = app.clone();
app.run_on_main_thread(move || {
let result = build_kimi_web_window(&builder_app, &url);
let _ = tx.send(result);
})
.map_err(|e| format!("failed to queue window creation: {e}"))?;
match rx.await {
Ok(result) => {
if let Err(e) = result {
// Window creation failed (e.g. label already taken): only stop
// the server when this invocation spawned it.
if spawned_by_me {
kill_spawned_kimi_web(&state);
}
return Err(e);
}
});
Ok(())
}
// Sender dropped without a result (app shutting down, build never
// ran): never kill the server — a later invocation may reuse it.
Err(_) => Ok(()),
}
}
/// Build the embedded WebUI window on the main thread and track it for the
/// singleton check. On success, wires close/destroy events to forget the
/// window and stop the `kimi web` server this app spawned (reused servers are
/// untouched).
fn build_kimi_web_window(app: &tauri::AppHandle, url: &tauri::Url) -> Result<(), String> {
let window = tauri::WebviewWindowBuilder::new(
app,
KIMI_WEB_WINDOW_LABEL,
tauri::WebviewUrl::External(url.clone()),
)
.title("Kimi Code WebUI")
.inner_size(1100.0, 750.0)
.resizable(true)
.center()
.build()
.map_err(|e| format!("failed to create kimi web window: {e}"))?;
// Track the window for the singleton check.
if let Ok(mut guard) = app.state::<KimiWebState>().window.lock() {
*guard = Some(window.clone());
}
// Clean up when the window closes: forget it and stop the `kimi web`
// server we spawned (reused servers are untouched).
let w = window.clone();
window.on_window_event(move |event| match event {
tauri::WindowEvent::Destroyed => {
let app = w.app_handle().clone();
let state = app.state::<KimiWebState>();
if let Ok(mut guard) = state.window.lock() {
*guard = None;
}
kill_spawned_kimi_web(&state);
}
_ => {}
});
Ok(())
}
@@ -1294,26 +1339,41 @@ pub async fn kimi_oauth_poll(
// ---------------------------------------------------------------------------
/// Read the Kimi Code environment variables that are currently set (non-empty)
/// in this process's environment. The experimental-feature vars are used by the
/// frontend to mark config.toml `[experimental]` toggles as locked: env vars
/// outrank the config file in Kimi Code's flag resolution. `KIMI_CODE_LEGACY_FLAG`
/// is probed too — the AgentSettingsPanel uses it to decide whether to
/// in this process's environment. Every `[experimental]` feature env var is
/// probed — the frontend uses a per-feature env var to mark the matching
/// config.toml `[experimental]` toggle as locked, since a single-feature env
/// var outranks the config file in Kimi Code's flag resolution. The master
/// `KIMI_CODE_EXPERIMENTAL_FLAG` (0.40.1+: only force-ONs flags without an
/// explicit config entry; never locks a toggle) and `KIMI_CODE_LEGACY_FLAG`
/// are probed too — the latter lets AgentSettingsPanel decide whether to
/// dual-write the v1 engine's loop_control keys.
///
/// The flag id → env var mapping mirrors `EXPERIMENTAL_FLAGS` in
/// src/lib/subagent-settings.ts; keep both lists in sync.
///
/// Truthy values per the CLI: `1` / `true` / `yes` / `on` (case-insensitive);
/// the raw values are returned and the truthy check happens on the frontend.
#[tauri::command]
pub fn get_experimental_env_status() -> HashMap<String, String> {
const VARS: [&str; 6] = [
"KIMI_CODE_EXPERIMENTAL_FLAG",
"KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL",
"KIMI_CODE_EXPERIMENTAL_TOOL_SELECT",
"KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL",
"KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL",
"KIMI_CODE_LEGACY_FLAG",
const VARS: [(&str, &str); 11] = [
("secondary-model", "KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL"),
("tool-select", "KIMI_CODE_EXPERIMENTAL_TOOL_SELECT"),
(
"persistence_minidb_readmodel",
"KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL",
),
("tower", "KIMI_CODE_EXPERIMENTAL_TOWER"),
("subagent_fork", "KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK"),
("wait_for", "KIMI_CODE_EXPERIMENTAL_WAIT_FOR"),
("auto_session_title", "KIMI_CODE_EXPERIMENTAL_AUTO_SESSION_TITLE"),
("remote-control", "KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL"),
("search_worker", "KIMI_CODE_EXPERIMENTAL_SEARCH_WORKER"),
// Non-flag probes consumed by the frontend:
("master", "KIMI_CODE_EXPERIMENTAL_FLAG"),
("legacy", "KIMI_CODE_LEGACY_FLAG"),
];
let mut out = HashMap::new();
for name in VARS {
for (_, name) in VARS {
if let Ok(value) = std::env::var(name) {
if !value.trim().is_empty() {
out.insert(name.to_string(), value);
+73 -10
View File
@@ -97,6 +97,11 @@ pub fn detect_provider(base_url: &str) -> Vec<UsageKind> {
if url.contains("openrouter.ai") {
kinds.push(UsageKind::BalanceOpenrouter);
}
// codingplan.site 检测为 NewAPI 实例(本工具仅支持 NewAPI 查询);
// 家族匹配意味着 ai. 子域也自然命中该规则。
if url.contains("codingplan.site") {
kinds.push(UsageKind::BalanceNewapi);
}
if url.contains("api.stepfun.com") {
kinds.push(UsageKind::BalanceStepfun);
}
@@ -126,6 +131,19 @@ pub fn detect_provider(base_url: &str) -> Vec<UsageKind> {
kinds
}
/// BalanceNewapi 模板所需凭据(web 控制台 access token + user id)。
/// 两者都必须存在且非空;返回 None 表示缺少凭据(确定性配置错误)。
fn newapi_creds(
usage_config: Option<&crate::models::UsageConfig>,
) -> Option<(&str, &str)> {
usage_config.and_then(|c| {
c.access_token
.as_deref()
.zip(c.user_id.as_deref())
.filter(|(t, u)| !t.is_empty() && !u.is_empty())
})
}
/// 按 kind 路由到对应查询实现。`base_url` 用于消歧同一家供应商的
/// 国内/海外站(SiliconFlow .cn/.com、MiniMax .com/.io、智谱 bigmodel/z.ai)。
/// `usage_config` 仅 BalanceNewapi 分支读取(access_token / user_id),
@@ -155,16 +173,13 @@ pub async fn query_kind(
balance::query_kimi(api_key, !lower.contains("moonshot.ai"), timeout).await
}
UsageKind::BalanceNewapi => {
let (token, uid) = usage_config
.and_then(|c| {
c.access_token
.as_deref()
.zip(c.user_id.as_deref())
.filter(|(t, u)| !t.is_empty() && !u.is_empty())
})
.ok_or_else(|| {
"newapi template requires accessToken and userId".to_string()
})?;
// 缺凭据是确定性配置错误 → Ok(failure)(前端按前缀本地化提示),
// 而非 Err(Err 语义 = 瞬时网络错误,见 commands.rs newapi 分支)。
let Some((token, uid)) = newapi_creds(usage_config) else {
return Ok(UsageResult::failure(
"newapi template requires accessToken and userId".to_string(),
));
};
let url = usage_config
.and_then(|c| c.base_url.as_deref())
.filter(|s| !s.is_empty())
@@ -262,6 +277,28 @@ mod tests {
);
}
#[test]
fn detect_provider_codingplan_site_family() {
// codingplan.site 是 NewAPI 实例(本工具仅支持 NewAPI 查询):
// 主域与 ai. 子域都命中家族规则,且只映射到 BalanceNewapi。
assert_eq!(
detect_provider("https://codingplan.site"),
vec![UsageKind::BalanceNewapi]
);
assert_eq!(
detect_provider("https://codingplan.site/v1"),
vec![UsageKind::BalanceNewapi]
);
assert_eq!(
detect_provider("https://ai.codingplan.site"),
vec![UsageKind::BalanceNewapi]
);
assert_eq!(
detect_provider("https://ai.codingplan.site/v1"),
vec![UsageKind::BalanceNewapi]
);
}
#[test]
fn usage_kind_string_roundtrip() {
use std::str::FromStr;
@@ -272,4 +309,30 @@ mod tests {
assert!(UsageKind::from_str("balance:unknown").is_err());
assert!(UsageKind::from_str("").is_err());
}
#[test]
fn newapi_creds_requires_both_and_nonempty() {
use crate::models::UsageConfig;
let cfg = |token: Option<&str>, uid: Option<&str>| UsageConfig {
enabled: true,
template_type: "newapi".to_string(),
base_url: None,
access_token: token.map(str::to_string),
user_id: uid.map(str::to_string),
auto_query_interval_minutes: None,
timeout_seconds: None,
};
// 缺 config / 缺任一项 / 空字符串 → None(确定性配置错误)
assert_eq!(newapi_creds(None), None);
assert_eq!(newapi_creds(Some(&cfg(None, None))), None);
assert_eq!(newapi_creds(Some(&cfg(Some("tok"), None))), None);
assert_eq!(newapi_creds(Some(&cfg(None, Some("uid")))), None);
assert_eq!(newapi_creds(Some(&cfg(Some(""), Some("uid")))), None);
assert_eq!(newapi_creds(Some(&cfg(Some("tok"), Some("")))), None);
// 两项齐全 → Some
assert_eq!(
newapi_creds(Some(&cfg(Some("tok"), Some("uid")))),
Some(("tok", "uid"))
);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"productName": "Kimi Switch",
"version": "0.7.11",
"version": "0.7.13",
"identifier": "com.kimiswitch.app",
"build": {
"beforeDevCommand": "npm run dev",
+22
View File
@@ -157,6 +157,28 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
</Card>
<Card title={t("permissionRules")}>
{/* kimi-code `[permission] dangerous_command_guard`. The env
var KIMI_CODE_DANGEROUS_COMMAND_GUARD (literal "true"/"false")
outranks this config at runtime; no env-lock UI here (deliberate
minimal scope). Absent key = upstream default on. Since
kimi-code 0.41.0 the guard only gates Manual/YOLO modes —
Auto (never-ask) mode no longer intercepts dangerous
commands. */}
<Checkbox
label={t("permissionDangerousGuard")}
checked={settings.permission?.dangerous_command_guard ?? true}
onChange={(checked) =>
update({
permission: {
...settings.permission,
dangerous_command_guard: checked,
},
})
}
/>
<p className="text-xs text-content-muted">
{t("permissionDangerousGuardDesc")}
</p>
<div className="space-y-2">
{(settings.permission?.rules ?? []).map((rule, idx) => (
<div key={idx} className="flex items-center gap-2">
+61 -22
View File
@@ -57,6 +57,7 @@ const FLAG_LABELS: Record<string, { name: TranslationKey; desc: TranslationKey }
desc: "flagAutoSessionTitleDesc",
},
"remote-control": { name: "flagRemoteControl", desc: "flagRemoteControlDesc" },
search_worker: { name: "flagSearchWorker", desc: "flagSearchWorkerDesc" },
};
/** Validation-error i18n key per engine rule, for the pre-write self-check. */
@@ -162,6 +163,8 @@ export function SubagentSettingsPage({
const [poolErrors, setPoolErrors] = useState<PoolValidationError[]>([]);
/** Local draft for the "add pool entry" select (reset after each pick). */
const [addSelection, setAddSelection] = useState("");
/** WebUI-open button in flight; disables both buttons while non-null. */
const [webuiBusy, setWebuiBusy] = useState<"embedded" | "browser" | null>(null);
useEffect(() => {
invoke<ExperimentalEnvStatus>("get_experimental_env_status")
@@ -185,13 +188,18 @@ export function SubagentSettingsPage({
const secondaryFlag = EXPERIMENTAL_FLAGS[0]; // "secondary-model"
const otherFlags = EXPERIMENTAL_FLAGS.slice(1);
/** Effective state of the secondary-model flag (env overrides config). */
const secondaryEnabled = masterOn
? true
: isFlagLockedByEnv(env, secondaryFlag)
? forcedEnvValue(env, secondaryFlag)
: flags["secondary-model"] === true;
const secondaryLocked = masterOn || isFlagLockedByEnv(env, secondaryFlag);
/** Effective state of the secondary-model flag. Mirrors the kimi-code
* 0.40.1 priority: single-flag env > explicit [experimental] value >
* master env (force-on only) > upstream default. The master env no
* longer locks the toggle — only the flag's own env var does. */
const secondaryEnabled = isFlagLockedByEnv(env, secondaryFlag)
? forcedEnvValue(env, secondaryFlag)
: isExperimentalFlagSet(rawOther, secondaryFlag.id)
? flags["secondary-model"] === true
: masterOn
? true
: (secondaryFlag.defaultEnabled ?? false);
const secondaryLocked = isFlagLockedByEnv(env, secondaryFlag);
const aliasList = Object.keys(models).sort();
@@ -300,17 +308,24 @@ export function SubagentSettingsPage({
const renderFlagRow = (flag: ExperimentalFlagDef) => {
const labels = FLAG_LABELS[flag.id];
const locked = masterOn || isFlagLockedByEnv(env, flag);
// An explicitly written flag (true or false) wins; an absent flag falls
// back to the upstream default (`defaultEnabled`).
// Only the flag's own env var locks the toggle. The master env force-ons
// undefined flags but an explicit config entry outranks it, so the user
// can still flip values here while it is set.
const locked = isFlagLockedByEnv(env, flag);
// Resolution order mirrors kimi-code 0.40.1 flagService:
// single-flag env > explicit [experimental] value (true *or* false) >
// master env (on-only) > upstream default (`defaultEnabled`).
const explicitlySet = isExperimentalFlagSet(rawOther, flag.id);
const on = masterOn
? true
: isFlagLockedByEnv(env, flag)
? forcedEnvValue(env, flag)
: explicitlySet
? flags[flag.id] === true
const on = locked
? forcedEnvValue(env, flag)
: explicitlySet
? flags[flag.id] === true
: masterOn
? true
: (flag.defaultEnabled ?? false);
// Turning the flag off only sticks via an explicit `false` when the
// fallback (master env or the upstream default) would otherwise keep it on.
const explicitFalse = masterOn || flag.defaultEnabled === true;
return (
<div key={flag.id} className="flex items-center gap-3">
<div className="flex-1 min-w-0">
@@ -337,7 +352,9 @@ export function SubagentSettingsPage({
disabled={locked}
ariaLabel={t(labels.name)}
onChange={(checked) =>
onChange(setExperimentalFlag(rawOther, flag.id, checked))
onChange(
setExperimentalFlag(rawOther, flag.id, checked, { explicitFalse })
)
}
/>
</div>
@@ -371,29 +388,37 @@ export function SubagentSettingsPage({
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
disabled={webuiBusy !== null}
onClick={async () => {
setWebuiBusy("embedded");
try {
await invoke("open_kimi_web_embedded");
} catch (err) {
alert(err instanceof Error ? err.message : String(err));
} finally {
setWebuiBusy(null);
}
}}
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
>
{t("openWebUIEmbedded")}
{webuiBusy === "embedded" ? t("webuiOpening") : t("openWebUIEmbedded")}
</button>
<button
type="button"
disabled={webuiBusy !== null}
onClick={async () => {
setWebuiBusy("browser");
try {
await invoke("open_kimi_web");
} catch (err) {
alert(err instanceof Error ? err.message : String(err));
} finally {
setWebuiBusy(null);
}
}}
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
>
{t("openWebUIBrowser")}
{webuiBusy === "browser" ? t("webuiOpening") : t("openWebUIBrowser")}
</button>
</div>
</Card>
@@ -550,6 +575,16 @@ export function SubagentSettingsPage({
<div className="border-t border-border" />
<div className="flex items-center gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-sm text-content-primary">
{t(FLAG_LABELS[secondaryFlag.id].name)}
{!secondaryLocked &&
!isExperimentalFlagSet(rawOther, secondaryFlag.id) &&
secondaryFlag.defaultEnabled && (
<span className="shrink-0 text-xs text-blue-600 dark:text-blue-400 border border-blue-500/30 rounded px-1">
{t("flagDefaultOn")}
</span>
)}
</div>
<div className="text-xs text-content-muted">
{t(FLAG_LABELS[secondaryFlag.id].desc)}
<code className="ml-2">{secondaryFlag.envVar}</code>
@@ -565,7 +600,11 @@ export function SubagentSettingsPage({
disabled={secondaryLocked}
ariaLabel={t(FLAG_LABELS[secondaryFlag.id].name)}
onChange={(checked) =>
onChange(setExperimentalFlag(rawOther, secondaryFlag.id, checked))
onChange(
setExperimentalFlag(rawOther, secondaryFlag.id, checked, {
explicitFalse: masterOn || secondaryFlag.defaultEnabled === true,
})
)
}
/>
</div>
+2 -2
View File
@@ -4,7 +4,7 @@ import { invoke } from "@tauri-apps/api/core";
import { ArrowLeft, Eye, EyeOff, Loader2, Play } from "lucide-react";
import { useTranslation } from "../i18n";
import type { TranslationKey } from "../i18n/zh";
import { localizeUsageError, planLabel } from "../lib/usage-display";
import { formatAmount, localizeUsageError, planLabel } from "../lib/usage-display";
import type { Agent, Provider, UsageConfig } from "../types";
interface UsageResult {
@@ -134,7 +134,7 @@ export function UsageConfigModal({ open, agent, provider, onClose, onSave }: Usa
.map((d) => {
const parts: string[] = [];
if (d.planName) parts.push(planLabel(d.planName, t));
if (d.remaining != null) parts.push(`${t("usageRemaining")} ${d.remaining}${d.unit ?? ""}`);
if (d.remaining != null) parts.push(`${t("usageRemaining")} ${formatAmount(d.remaining, d.unit)}`);
if (d.used != null && d.total != null && d.total > 0)
parts.push(`${Math.round((d.used / d.total) * 100)}%`);
return parts.join(" ");
+1 -12
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { Loader2, RefreshCw } from "lucide-react";
import { useTranslation } from "../i18n";
import { localizeUsageError, planLabel } from "../lib/usage-display";
import { formatAmount, localizeUsageError, planLabel } from "../lib/usage-display";
import type { UsageData, UsageQueryState } from "../hooks/useUsageQuery";
interface UsageFooterProps {
@@ -36,17 +36,6 @@ export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) {
return null;
};
const formatAmount = (
value: number | null | undefined,
unit?: string | null
): string => {
if (value == null) return "—";
const symbol =
unit === "CNY" ? "¥" : unit === "USD" ? "$" : unit ? `${unit} ` : "";
const num = Number.isInteger(value) ? String(value) : value.toFixed(2);
return `${symbol}${num}`;
};
const formatReset = (resetsAt?: string | null): string | null => {
if (!resetsAt) return null;
const ms = new Date(resetsAt).getTime() - now;
+2
View File
@@ -14,6 +14,7 @@ export type UsageKind =
| "balance:stepfun"
| "balance:novita"
| "balance:kimi"
| "balance:newapi"
| "plan:kimi_coding"
| "plan:zhipu"
| "plan:minimax"
@@ -30,6 +31,7 @@ export const SUPPORTED_USAGE_KINDS: ReadonlySet<string> = new Set<UsageKind>([
"balance:stepfun",
"balance:novita",
"balance:kimi",
"balance:newapi",
"plan:kimi_coding",
"plan:zhipu",
"plan:minimax",
+8 -1
View File
@@ -151,6 +151,9 @@ export const enTranslations: Record<TranslationKey, string> = {
permissionAllow: "Allow",
permissionDeny: "Deny",
permissionAsk: "Ask",
permissionDangerousGuard: "Dangerous Command Guard",
permissionDangerousGuardDesc:
"When on (default): Manual/YOLO modes force a prompt for dangerous commands (rm -rf, shutdown, dd of=, ...) and commands that cannot be statically analyzed; Auto (never-ask) mode no longer intercepts them since kimi-code 0.41.0. Turning it off restores the previous behavior. Env KIMI_CODE_DANGEROUS_COMMAND_GUARD overrides this config",
addRule: "+ Add rule",
addCommonRules: "Add common rules",
hooks: "Lifecycle Hooks",
@@ -477,9 +480,12 @@ export const enTranslations: Record<TranslationKey, string> = {
flagAutoSessionTitleDesc: "Generate session titles automatically via the managed chat_title tool",
flagRemoteControl: "Remote Control",
flagRemoteControlDesc: "Enable the experimental remote control feature",
flagSearchWorker: "Search Worker",
flagSearchWorkerDesc: "Run the kap-server global search backend in a background worker host",
flagDefaultOn: "On by default",
lockedByEnv: "Locked by env var",
masterEnvOnHint: "KIMI_CODE_EXPERIMENTAL_FLAG is set: all experimental features are forced on by the env var",
masterEnvOnHint:
"KIMI_CODE_EXPERIMENTAL_FLAG is set: flags without an explicit [experimental] entry are forced on; explicit entries in config.toml still win",
secondaryModelSection: "Subagent Model (Secondary Model)",
secondaryModelDisabledHint:
"The experimental flag \"Subagent Secondary Model\" is off and this config will not take effect — enable the toggle above first",
@@ -528,6 +534,7 @@ export const enTranslations: Record<TranslationKey, string> = {
"Open the new Web UI in an independent in-app window, or in your system browser (built into kimi-code 0.33+). Requires the kimi CLI on PATH.",
openWebUIEmbedded: "Open in App",
openWebUIBrowser: "Open in Browser",
webuiOpening: "Opening...",
// Plugin marketplace
pluginMarketplace: "Plugin Marketplace",
+8 -1
View File
@@ -149,6 +149,9 @@ export const zhTranslations = {
permissionAllow: "允许",
permissionDeny: "拒绝",
permissionAsk: "询问",
permissionDangerousGuard: "危险命令防护",
permissionDangerousGuardDesc:
"开启(默认)时:Manual/YOLO 模式下,危险命令(rm -rf、shutdown、dd of= 等)和无法静态分析的命令会强制询问;自动(永不询问)模式自 kimi-code 0.41.0 起不再拦截。关闭后回归旧行为。环境变量 KIMI_CODE_DANGEROUS_COMMAND_GUARD 会覆盖本配置",
addRule: "+ 添加规则",
addCommonRules: "添加常用规则",
hooks: "生命周期钩子",
@@ -472,9 +475,12 @@ export const zhTranslations = {
flagAutoSessionTitleDesc: "通过托管 chat_title 工具自动生成会话标题",
flagRemoteControl: "远程控制",
flagRemoteControlDesc: "启用实验性远程控制功能",
flagSearchWorker: "搜索 Worker",
flagSearchWorkerDesc: "kap-server 全局搜索的后台 worker 宿主服务",
flagDefaultOn: "默认开启",
lockedByEnv: "被环境变量锁定",
masterEnvOnHint: "KIMI_CODE_EXPERIMENTAL_FLAG 已设置:全部实验功能被环境变量强制开启",
masterEnvOnHint:
"KIMI_CODE_EXPERIMENTAL_FLAG 已设置:未显式配置的实验项将被强制开启;config.toml 中的显式配置优先生效",
secondaryModelSection: "子代理模型(次主力模型)",
secondaryModelDisabledHint:
"实验开关「子代理次主力模型」未启用,本配置不会生效——请先在上方打开对应开关",
@@ -520,6 +526,7 @@ export const zhTranslations = {
"以独立窗口在应用内打开新版 Web 界面,也可以选择在系统浏览器打开(kimi-code 0.33+ 内置 code-app;需要 kimi 命令在 PATH 中)。",
openWebUIEmbedded: "在应用内打开",
openWebUIBrowser: "在浏览器打开",
webuiOpening: "打开中...",
// Plugin marketplace
pluginMarketplace: "插件市场",
+72
View File
@@ -131,4 +131,76 @@ describe("loop_control — v1/v2 key handling", () => {
expect(s.loop_control?.max_attempts_per_step).toBe(4);
expect(s.loop_control?.max_retries_per_step).toBe(4);
});
});
// ---------------------------------------------------------------------------
// [permission] dangerous_command_guard (kimi-code 0.40.1) — absent key means
// upstream default ON; an explicit false must survive even with no rules
// ---------------------------------------------------------------------------
describe("permission.dangerous_command_guard", () => {
it("reads as undefined when the key is absent (default on)", () => {
expect(
getAgentSettings({}).permission?.dangerous_command_guard
).toBeUndefined();
expect(
getAgentSettings({ permission: { rules: [] } }).permission
?.dangerous_command_guard
).toBeUndefined();
});
it("reads an explicit true / false", () => {
expect(
getAgentSettings({ permission: { dangerous_command_guard: false } })
.permission?.dangerous_command_guard
).toBe(false);
expect(
getAgentSettings({ permission: { dangerous_command_guard: true } })
.permission?.dangerous_command_guard
).toBe(true);
});
it("writes guard=false even with no rules (section kept)", () => {
const next = setAgentSettings(
{},
{ permission: { rules: [], dangerous_command_guard: false } }
) as { permission?: Record<string, unknown> };
expect(next.permission).toEqual({ dangerous_command_guard: false });
});
it("writes guard together with rules", () => {
const rules = [{ decision: "allow" as const, pattern: "Read" }];
const next = setAgentSettings(
{},
{ permission: { rules, dangerous_command_guard: true } }
) as { permission?: Record<string, unknown> };
expect(next.permission).toEqual({
rules,
dangerous_command_guard: true,
});
});
it("a rules-only update carries the existing guard through", () => {
const raw = { permission: { dangerous_command_guard: false } };
const next = setAgentSettings(raw, {
permission: { rules: [{ decision: "deny" as const, pattern: "Bash" }] },
}) as { permission?: Record<string, unknown> };
expect(next.permission?.dangerous_command_guard).toBe(false);
expect(next.permission?.rules).toHaveLength(1);
});
it("omits an empty rules array; keeps an explicit guard on plain saves", () => {
const raw = { permission: { dangerous_command_guard: true } };
const off = setAgentSettings(raw, {
permission: { rules: [], dangerous_command_guard: false },
}) as { permission?: Record<string, unknown> };
expect(off.permission).toEqual({ dangerous_command_guard: false });
expect(off.permission).not.toHaveProperty("rules");
// An explicit true is materialized on plain saves (harmless, and it
// documents the state the UI toggle shows).
const on = setAgentSettings(raw, {}) as {
permission?: Record<string, unknown>;
};
expect(on.permission).toEqual({ dangerous_command_guard: true });
});
});
+36 -8
View File
@@ -65,6 +65,10 @@ export function getAgentSettings(rawOther: unknown): AgentSettings {
if (thinking.effort === "max") {
thinking.effort = "high";
}
const sectionPermission = getSection<AgentSettings["permission"]>(
rawOther,
"permission"
);
return {
thinking,
loop_control: loop,
@@ -73,9 +77,15 @@ export function getAgentSettings(rawOther: unknown): AgentSettings {
...getSection<AgentSettings["background"]>(rawOther, "background"),
},
permission: {
rules:
getSection<AgentSettings["permission"]>(rawOther, "permission")?.rules ??
[],
rules: sectionPermission?.rules ?? [],
// `dangerous_command_guard` stays undefined unless the config carries
// an explicit boolean — upstream defaults it to ON (kimi-code 0.40.1),
// so an absent key means "on". The env var
// KIMI_CODE_DANGEROUS_COMMAND_GUARD (literal "true"/"false" only)
// outranks this config value at kimi-code runtime.
...(typeof sectionPermission?.dangerous_command_guard === "boolean"
? { dangerous_command_guard: sectionPermission.dangerous_command_guard }
: {}),
},
hooks: getSection<AgentSettings["hooks"]>(rawOther, "hooks") ?? [],
};
@@ -88,13 +98,20 @@ export function setAgentSettings(
): unknown {
const root = { ...asRecord(rawOther) };
const current = getAgentSettings(rawOther);
// Explicit guard value wins over the carried-over one; undefined keeps the
// key absent (upstream default = on).
const guard =
patch.permission?.dangerous_command_guard ??
current.permission?.dangerous_command_guard;
const permission: NonNullable<AgentSettings["permission"]> = {
rules: patch.permission?.rules ?? current.permission?.rules ?? [],
...(typeof guard === "boolean" ? { dangerous_command_guard: guard } : {}),
};
const next: AgentSettings = {
thinking: { ...current.thinking, ...patch.thinking },
loop_control: { ...current.loop_control, ...patch.loop_control },
background: { ...current.background, ...patch.background },
permission: {
rules: patch.permission?.rules ?? current.permission?.rules ?? [],
},
permission,
hooks: patch.hooks ?? current.hooks ?? [],
};
@@ -115,8 +132,19 @@ export function setAgentSettings(
if (next.thinking) root.thinking = next.thinking;
if (next.loop_control) root.loop_control = next.loop_control;
if (next.background) root.background = next.background;
if (next.permission?.rules && next.permission.rules.length > 0) {
root.permission = next.permission;
// Keep `[permission]` when it carries rules *or* an explicit
// dangerous_command_guard — dropping the section would silently lose a
// guard=false write (absent key = upstream default on). An empty rules
// array is omitted rather than written as `rules = []`.
const hasRules = !!permission.rules && permission.rules.length > 0;
const hasGuard = typeof permission.dangerous_command_guard === "boolean";
if (hasRules || hasGuard) {
root.permission = {
...(hasRules ? { rules: permission.rules } : {}),
...(hasGuard
? { dangerous_command_guard: permission.dangerous_command_guard }
: {}),
};
} else {
delete root.permission;
}
+3208 -2787
View File
File diff suppressed because it is too large. Load diff
+2614 -2213
View File
File diff suppressed because it is too large. Load diff
+76
View File
@@ -1,8 +1,12 @@
import { describe, expect, it } from "vitest";
import {
EXPERIMENTAL_FLAGS,
getExperimentalFlags,
getSecondaryModel,
getSubagentModelPool,
isExperimentalFlagSet,
removeSubagentPoolEntry,
setExperimentalFlag,
setSecondaryModelOnly,
setSubagentDefault,
setSubagentForce,
@@ -449,3 +453,75 @@ describe("validateSubagentPool", () => {
expect(errors).toContainEqual({ key: "forceExcludesModels" });
});
});
// ---------------------------------------------------------------------------
// [experimental] — flag registry (kimi-code 0.41.0) + write semantics
// ---------------------------------------------------------------------------
describe("experimental flag registry", () => {
it("mirrors the 0.41.0 v2 registry (9 flags, file_history removed)", () => {
expect(EXPERIMENTAL_FLAGS.map((f) => f.id)).toEqual([
"secondary-model",
"tool-select",
"persistence_minidb_readmodel",
"tower",
"subagent_fork",
"wait_for",
"auto_session_title",
"remote-control",
"search_worker",
]);
// Upstream removed the flag in 0.41.0 (turn-level file history is
// always on), so the adapter must not mirror it either.
expect(EXPERIMENTAL_FLAGS.some((f) => f.id === "file_history")).toBe(false);
});
it("secondary-model is on by default since 0.40.1", () => {
expect(
EXPERIMENTAL_FLAGS.find((f) => f.id === "secondary-model")?.defaultEnabled
).toBe(true);
});
});
describe("setExperimentalFlag", () => {
const rawExp = (section: Record<string, unknown>) => ({
experimental: section,
});
it("enabling writes true; disabling a default-off flag deletes the key", () => {
const on = setExperimentalFlag({}, "tool-select", true) as {
experimental: Record<string, unknown>;
};
expect(on.experimental["tool-select"]).toBe(true);
const off = setExperimentalFlag(on, "tool-select", false) as Record<
string,
unknown
>;
expect(off).not.toHaveProperty("experimental"); // empty section dropped
});
it("disabling a default-on flag with explicitFalse writes a literal false", () => {
const next = setExperimentalFlag(
rawExp({ wait_for: true }),
"wait_for",
false,
{ explicitFalse: true }
) as { experimental: Record<string, unknown> };
expect(next.experimental.wait_for).toBe(false);
expect(isExperimentalFlagSet(next, "wait_for")).toBe(true);
expect(getExperimentalFlags(next).wait_for).toBe(false);
});
it("an explicit false coexists with other flags and keeps the section", () => {
const next = setExperimentalFlag(
rawExp({ "secondary-model": false }),
"tower",
true,
{ explicitFalse: true }
) as { experimental: Record<string, unknown> };
expect(next.experimental).toEqual({
"secondary-model": false,
tower: true,
});
});
});
+30 -6
View File
@@ -36,10 +36,17 @@ export interface ExperimentalFlagDef {
/** Known experimental flags — mirrors the kimi-code v2 flag registry
* (the per-feature flag.ts files under packages/agent-core-v2/src:
* secondary-model, tool-select, persistence_minidb_readmodel, tower,
* subagent_fork, wait_for, auto_session_title, remote-control). `acp-v2` was
* removed upstream and is dropped here. */
* subagent_fork, wait_for, auto_session_title, remote-control,
* search_worker). `acp-v2` was removed upstream and is dropped here;
* `file_history` was removed in kimi-code 0.41.0 (turn-level file
* history is always on, no flag). Keep the env-var list in sync with
* `get_experimental_env_status` in src-tauri/src/commands.rs. */
export const EXPERIMENTAL_FLAGS: ExperimentalFlagDef[] = [
{ id: "secondary-model", envVar: "KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL" },
{
id: "secondary-model",
envVar: "KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL",
defaultEnabled: true, // on by default since kimi-code 0.40.1
},
{ id: "tool-select", envVar: "KIMI_CODE_EXPERIMENTAL_TOOL_SELECT" },
{
id: "persistence_minidb_readmodel",
@@ -58,11 +65,20 @@ export const EXPERIMENTAL_FLAGS: ExperimentalFlagDef[] = [
envVar: "KIMI_CODE_EXPERIMENTAL_AUTO_SESSION_TITLE",
},
{ id: "remote-control", envVar: "KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL" },
{
id: "search_worker",
envVar: "KIMI_CODE_EXPERIMENTAL_SEARCH_WORKER",
defaultEnabled: true,
},
];
export const EXPERIMENTAL_MASTER_ENV = "KIMI_CODE_EXPERIMENTAL_FLAG";
/** Whether the master env var is set to a truthy value (locks every flag on). */
/** Whether the master env var is set to a truthy value. Since kimi-code
* 0.40.1 it only force-ONs flags that have *no* explicit `[experimental]`
* entry (an explicit config value — true or false — outranks it, and the
* master env can never force a flag off). It locks no UI toggles; only a
* flag's own single-feature env var does. */
export function isMasterEnvOn(env: ExperimentalEnvStatus | null): boolean {
return isTruthyEnv(env?.[EXPERIMENTAL_MASTER_ENV]);
}
@@ -105,16 +121,24 @@ export function isExperimentalFlagSet(rawOther: unknown, id: string): boolean {
);
}
/** Set one flag in `[experimental]`. Removing all flags drops the section. */
/** Set one flag in `[experimental]`. Removing all flags drops the section.
* Pass `explicitFalse` when deleting the key would NOT express "off" —
* i.e. the flag is on by upstream default (`defaultEnabled`) or the master
* env force-ons undefined flags — and write a literal `false` instead,
* which outranks both under the kimi-code 0.40.1 priority
* (single env > explicit config > master env > default). */
export function setExperimentalFlag(
rawOther: unknown,
id: string,
enabled: boolean
enabled: boolean,
opts?: { explicitFalse?: boolean }
): unknown {
const root = { ...asRecord(rawOther) };
const flags = { ...getExperimentalFlags(rawOther) };
if (enabled) {
flags[id] = true;
} else if (opts?.explicitFalse) {
flags[id] = false;
} else {
delete flags[id];
}
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import { formatAmount, localizeUsageError, planLabel } from "./usage-display";
// Minimal TranslateFn: return the key itself so assertions read the key.
const t = (key: string) => key;
describe("localizeUsageError", () => {
it("maps known Rust error prefixes to their localization keys", () => {
expect(
localizeUsageError("unsupported provider: no usage query available", t)
).toBe("usageUnsupportedProvider");
expect(localizeUsageError("no API key configured", t)).toBe("usageErrNoKey");
expect(localizeUsageError("usage query disabled in config panel", t)).toBe(
"usageErrDisabled"
);
expect(
localizeUsageError("no Kimi Code OAuth credentials found; run `kimi login` first", t)
).toBe("usageErrNoOauth");
expect(
localizeUsageError("newapi template requires accessToken and userId", t)
).toBe("usageErrNewapiCreds");
expect(localizeUsageError("Authentication failed: 401 Unauthorized", t)).toBe(
"usageInvalidKey"
);
});
it("login expired / refresh failed match case-insensitively anywhere", () => {
expect(localizeUsageError("LOGIN expired at 12:00", t)).toBe("usageErrLoginExpired");
expect(localizeUsageError("oauth refresh failed: 500", t)).toBe("usageErrLoginExpired");
});
it("strips the kinds-loop `{kind}: ` wrapper before matching", () => {
// commands.rs 的 kinds 循环把错误包成 "balance:newapi: ..." —— 必须仍命中。
expect(
localizeUsageError(
"balance:newapi: newapi template requires accessToken and userId",
t
)
).toBe("usageErrNewapiCreds");
expect(localizeUsageError("plan:zhipu: Authentication failed: 401", t)).toBe(
"usageInvalidKey"
);
expect(localizeUsageError("balance:deepseek: no API key configured", t)).toBe(
"usageErrNoKey"
);
});
it("passes unknown errors through unchanged (kind prefix retained)", () => {
const raw = "balance:openrouter: HTTP 429 too many requests";
expect(localizeUsageError(raw, t)).toBe(raw);
expect(localizeUsageError("HTTP 500 Something broke", t)).toBe(
"HTTP 500 Something broke"
);
});
});
describe("planLabel", () => {
it("maps known tier ids and passes unknown names through", () => {
expect(planLabel("five_hour", t)).toBe("usageTier5h");
expect(planLabel("weekly_limit", t)).toBe("usageTierWeekly");
expect(planLabel("monthly_limit", t)).toBe("usageTierMonthly");
expect(planLabel("NewAPI", t)).toBe("NewAPI");
expect(planLabel("¥", t)).toBe("¥");
});
});
describe("formatAmount", () => {
it("maps CNY and USD to ¥/$ with two decimals", () => {
expect(formatAmount(1.5, "CNY")).toBe("¥1.50");
expect(formatAmount(1.5, "USD")).toBe("$1.50");
});
it("treats ¤ (U+00A4) as no configured currency symbol", () => {
expect(formatAmount(136907472.01013, "¤")).toBe("136907472.01");
expect(formatAmount(136907472.01013, "\u{00A4}")).toBe("136907472.01");
});
it("prefixes arbitrary units without a symbol", () => {
// Non-integer so the two-decimal rule applies; rounds to 3.00.
expect(formatAmount(3.004, "credit")).toBe("credit 3.00");
});
it("renders null/undefined values as —", () => {
expect(formatAmount(null, "¤")).toBe("—");
expect(formatAmount(undefined, "CNY")).toBe("—");
});
it("keeps integers without decimals", () => {
expect(formatAmount(42, "CNY")).toBe("¥42");
expect(formatAmount(100, "USD")).toBe("$100");
expect(formatAmount(7, "")).toBe("7");
});
});
+27 -7
View File
@@ -35,12 +35,32 @@ export function planLabel(name: string, t: TranslateFn): string {
* src-tauri/src/commands.rs and src-tauri/src/services/*.
*/
export function localizeUsageError(error: string, t: TranslateFn): string {
if (error.startsWith("unsupported provider")) return t("usageUnsupportedProvider");
if (error.startsWith("no API key configured")) return t("usageErrNoKey");
if (error.startsWith("usage query disabled")) return t("usageErrDisabled");
if (/login expired|refresh failed/i.test(error)) return t("usageErrLoginExpired");
if (error.startsWith("no Kimi Code OAuth credentials")) return t("usageErrNoOauth");
if (error.startsWith("newapi template requires")) return t("usageErrNewapiCreds");
if (error.startsWith("Authentication failed")) return t("usageInvalidKey");
// commands.rs 的 kinds 循环把单条错误包成 `{kind}: {msg}`(kind 含数字,
// 如 plan:zhipu),先剥离再按前缀匹配;未知错误仍回退按原文展示(保留 kind
// 前缀,属诊断细节)。
const stripped = error.replace(/^(balance|plan):[a-z0-9_]+:\s*/, "");
if (stripped.startsWith("unsupported provider")) return t("usageUnsupportedProvider");
if (stripped.startsWith("no API key configured")) return t("usageErrNoKey");
if (stripped.startsWith("usage query disabled")) return t("usageErrDisabled");
if (/login expired|refresh failed/i.test(stripped)) return t("usageErrLoginExpired");
if (stripped.startsWith("no Kimi Code OAuth credentials")) return t("usageErrNoOauth");
if (stripped.startsWith("newapi template requires")) return t("usageErrNewapiCreds");
if (stripped.startsWith("Authentication failed")) return t("usageInvalidKey");
return error;
}
/**
* Format a usage amount with its unit. `¤` (U+00A4) is NewAPI's placeholder
* when the site has no custom currency symbol configured — treat it as "no
* symbol" and render only the number.
*/
export function formatAmount(
value: number | null | undefined,
unit?: string | null
): string {
if (value == null) return "—";
const symbol =
unit === "CNY" ? "¥" : unit === "USD" ? "$" : unit && unit.trim() !== "¤" ? `${unit} ` : "";
const num = Number.isInteger(value) ? String(value) : value.toFixed(2);
return `${symbol}${num}`;
}
+11 -1
View File
@@ -149,7 +149,17 @@ export interface AgentSettings {
thinking?: ThinkingConfig;
loop_control?: LoopControlConfig;
background?: BackgroundConfig;
permission?: { rules?: PermissionRule[] };
permission?: {
rules?: PermissionRule[];
/** kimi-code `[permission]` key. Default true when the key is
* absent: since 0.41.0 the guard only gates Manual/YOLO modes —
* dangerous commands (rm -rf, shutdown, dd of=, …) and commands
* that cannot be statically analyzed force a prompt, while Auto
* (never-ask) mode no longer intercepts them; false restores the
* previous behavior. Env KIMI_CODE_DANGEROUS_COMMAND_GUARD (literal
* "true"/"false" only) outranks this config. */
dangerous_command_guard?: boolean;
};
hooks?: Hook[];
}
+10 -10
View File
@@ -6,10 +6,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#efebe1" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#231f1a" media="(prefers-color-scheme: dark)" />
<title>Kimi Switch — 多 LLM 供应商统一管理 · 用量监控 · 账单一目了然</title>
<title>Kimi Switch — 多 LLM 供应商配置管理 · 用量与账单监控</title>
<meta
name="description"
content="Kimi Switch / KimiCodeSwitch — Windows 桌面端多 LLM 供应商统一管理器,一键切换 Kimi / 智谱 GLM / MiniMax / DeepSeek / OpenRouter / 基元律动,用量账单实时监控,开源 MIT。"
content="Kimi Switch / KimiCodeSwitch — 桌面端 LLM 供应商配置管理器:22 个预设选好即写入 config.toml,用量与账单直读各厂商官方接口。Tauri + Rust,开源 MIT。"
/>
<meta
name="keywords"
@@ -18,20 +18,20 @@
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:title" content="Kimi Switch — 多 LLM 供应商统一管理" />
<meta property="og:title" content="Kimi Switch — 多 LLM 供应商配置管理" />
<meta
property="og:description"
content="一键切换 Kimi / 智谱 GLM / MiniMax / DeepSeek 等供应商,用量与账单一目了然,开源 MIT。"
content="把 Kimi Code 的供应商配置搬进图形界面:22 个预设、用量与账单直读官方接口,免费开源 MIT。"
/>
<meta property="og:url" content="https://billowliu2.github.io/KimiSwitch/" />
<meta property="og:image" content="https://billowliu2.github.io/KimiSwitch/og.png" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Kimi Switch — 多 LLM 供应商统一管理" />
<meta name="twitter:title" content="Kimi Switch — 多 LLM 供应商配置管理" />
<meta
name="twitter:description"
content="一键切换 Kimi / 智谱 GLM / MiniMax / DeepSeek 等供应商,用量与账单一目了然,开源 MIT。"
content="把 Kimi Code 的供应商配置搬进图形界面:22 个预设、用量与账单直读官方接口,免费开源 MIT。"
/>
<meta name="twitter:image" content="https://billowliu2.github.io/KimiSwitch/og.png" />
@@ -49,10 +49,10 @@
"alternateName": ["KimiSwitch", "KimiCodeSwitch", "Kimi Switch", "KimiCode Switch"],
"url": "https://billowliu2.github.io/KimiSwitch/",
"applicationCategory": "DeveloperApplication",
"operatingSystem": "Windows 10, Windows 11",
"softwareVersion": "0.6.7",
"dateModified": "2026-08-01",
"description": "Windows 桌面端多 LLM 供应商统一管理器,一键切换 Kimi / 智谱 GLM / MiniMax / DeepSeek / OpenRouter / 基元律动,用量账单实时监控,开源 MIT。",
"operatingSystem": "Windows 10, Windows 11, macOS, Linux",
"softwareVersion": "0.7.12",
"dateModified": "2026-09-03",
"description": "桌面端 LLM 供应商配置管理器:22 个预设选好即写入 config.toml,用量与账单直读各厂商官方接口。Tauri + Rust,开源 MIT。",
"downloadUrl": "https://github.com/billowliu2/KimiSwitch/releases",
"softwareHelp": "https://github.com/billowliu2/KimiSwitch",
"license": "https://opensource.org/licenses/MIT",
+1 -1
View File
@@ -87,7 +87,7 @@ export default function Footer() {
href={link.url}
target="_blank"
rel="noreferrer"
className="group flex items-start gap-3 rounded-2xl border border-border bg-card p-4 transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/40"
className="group flex items-start gap-3 rounded-xl border border-border bg-card p-4 transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/40"
style={{ boxShadow: "var(--shadow-soft)" }}
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-border bg-white">
+3 -1
View File
@@ -24,7 +24,9 @@ export default function Nav() {
/>
<span>Kimi Switch</span>
</NavLink>
<nav className="flex items-center gap-5 overflow-x-auto overflow-y-hidden text-sm text-muted-foreground [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<nav
aria-label="Primary"
className="flex items-center gap-5 overflow-x-auto overflow-y-hidden text-sm text-muted-foreground [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{links.map((l) => (
<NavLink
key={l.to}
+4 -2
View File
@@ -15,7 +15,7 @@ export default function SectionShell({ id, title, subtitle, children }: SectionS
ref={ref}
id={id}
aria-labelledby={`${id}-title`}
className={`py-20 transition-all duration-700 ease-out ${
className={`pt-16 pb-20 transition-all duration-700 ease-out ${
inView
? "motion-safe:opacity-100 motion-safe:translate-y-0"
: "motion-safe:opacity-0 motion-safe:translate-y-6"
@@ -30,7 +30,9 @@ export default function SectionShell({ id, title, subtitle, children }: SectionS
{title}
</h2>
{subtitle && (
<p className="mt-3 text-base leading-relaxed text-muted-foreground">{subtitle}</p>
<p className="mt-3 max-w-[68ch] text-base leading-relaxed text-muted-foreground">
{subtitle}
</p>
)}
</div>
{children}
+100 -78
View File
@@ -16,43 +16,43 @@ const zh = {
performance: "性能",
changelog: "更新日志",
download: "下载",
downloadBtn: "下载 v0.7.4",
downloadBtn: "下载 v0.7.12",
},
hero: {
badge: "v0.7.4 · 开源 MIT · Windows / macOS / Linux",
badge: "v0.7.12 · 开源 MIT · Windows / macOS / Linux",
titleBefore: "统一管理你的",
titleAccent: "AI 供应商",
titleAfter: "",
subtitle:
"一键切换 Kimi / 智谱 GLM / MiniMax / DeepSeek / OpenRouter / 基元律动,用量与账单一目了然。",
"Kimi Code 的供应商配置不用再手改 config.toml:22 个预设选好即写入(写入前自动备份),用量与账单直读各厂商官方接口。",
download: "免费下载",
source: "查看源码",
stats: [
{ value: "22", label: "预设供应商" },
{ value: "5910", label: "模型价格库" },
{ value: "100%", label: "开源免费" },
{ value: "7495", label: "模型价格库" },
{ value: "MIT", label: "开源协议" },
],
},
features: {
title: "核心功能",
subtitle: "从多供应商管理到用量监控,一站搞定。",
subtitle: "配置、切换、用量、账单、会话——都落在本机那份 ~/.kimi-code/config.toml 上。",
items: [
{
id: "multi-provider",
title: "多供应商管理",
desc: "Kimi / 智谱 GLM / MiniMax / DeepSeek / OpenRouter / 基元律动等 22 个预设,一键添加与切换。",
desc: "Kimi / 智谱 GLM / MiniMax / DeepSeek / OpenRouter / 基元律动等 22 个预设,Base URL 与模型映射已填好,选定即写入配置。",
img: "screenshots/preset-picker.png",
},
{
id: "custom-provider",
title: "自定义供应商",
desc: "自定义 Base URL / API Key / 模型映射,支持 JSON 高级编辑。",
desc: "Base URL、API Key、模型别名逐字段编辑,也可切到 JSON 页签直接改;配置里的未知字段原样保留。",
img: "screenshots/edit-provider-basic-managed.png",
},
{
id: "usage",
title: "用量与账单监控",
desc: "套餐类 5 小时 / 每周用量 + 按量余额,models.dev 真实价格计算成本,卡片直显。",
desc: "套餐的 5 小时 / 每周额度与按量余额直接显示在卡片上,成本按 models.dev 牌价折算。",
img: "screenshots/dashboard-light.png",
},
{
@@ -63,7 +63,7 @@ const zh = {
},
{
id: "kimi-auth",
title: "Kimi 一键授权登录",
title: "Kimi 授权登录",
desc: "应用内置设备码授权(等同 kimi login),浏览器授权即用,15 分钟过期自动续期。",
img: "screenshots/kimi-oauth-dialog.png",
},
@@ -75,31 +75,31 @@ const zh = {
},
{
id: "model-discovery",
title: "智能模型发现",
desc: "模型能力 / 上下文大小自动从 models.dev 同步,构建时自动拉取,5910 模型覆盖。",
title: "模型自动发现",
desc: "可用模型直接问供应商 API;显示名 / 上下文 / 能力取自 models.dev 快照(当前 7495 个模型、212 家供应商)。",
img: "screenshots/kimi-cli-select-model.png",
},
{
id: "auto-update",
title: "自动更新",
desc: "自动检测新版本,一键下载安装,始终保持最新。",
desc: "启动时和每 8 小时各查一次新版本(私有镜像优先、GitHub 兜底),应用内下载并引导安装。",
img: "screenshots/settings.png",
},
],
},
showcase: {
title: "界面演示",
subtitle: "真实界面截图,所见即所得。",
subtitle: "以下截图取自 v0.7.12 实机运行。",
items: [
{
src: "screenshots/usage-config-page.png",
title: "配置用量查询",
desc: "NewAPI 中转站模板、超时与自动刷新、测试查询一屏搞定",
desc: "NewAPI 中转站模板、超时与自动刷新、测试查询放在同一页",
},
{
src: "screenshots/preset-picker.png",
title: "预设供应商",
desc: "22 个预设开箱即用,一键添加常用厂商",
desc: "22 个预设,勾选即可添加常用厂商",
},
{
src: "screenshots/kimi-oauth-dialog.png",
@@ -125,7 +125,7 @@ const zh = {
},
performance: {
title: "轻量原生",
subtitle: "Tauri + Rust 内核,WebView2 前端,常驻托盘零负担。",
subtitle: "Tauri + Rust,复用系统 WebView2,不打包 Chromium。下面三项是空闲状态实测。",
metrics: [
{ target: 93, suffix: " MB", label: "Working Set", desc: "程序空闲态工作集内存" },
{ target: 34, suffix: " MB", label: "Private WS", desc: "独占内存(WebView2 共享部分扣除)" },
@@ -140,6 +140,17 @@ const zh = {
syncedNote: "数据已同步 GitHub Releases",
fallbackNote: "内置版本记录",
entries: [
{
version: "v0.7.12",
date: "2026-09-03",
items: [
"适配 kimi-code 0.40.1:实验 flag 清单更新(新增 file_history / search_worker,secondary-model 默认开启)",
"flag 优先级语义修正:显式配置优先于总开关,总开关不再锁定全部实验开关",
"新增危险命令守卫开关:Auto 模式直接拒绝 rm -rf / shutdown 等危险命令,其它模式强制询问",
"修复 WebUI 首次打开慢时多次点击的竞态报错",
"models-dev 快照更新至 7495 模型 / 212 供应商",
],
},
{
version: "v0.7.4",
date: "2026-08-09",
@@ -170,7 +181,7 @@ const zh = {
"兼容 kimi-code 0.33+(v2 引擎):循环控制改用新键 max_attempts_per_step,旧配置自动迁移",
"models.dev 模型列表与价格数据同步更新",
"官网企业版视觉升级;出品公司标识统一为 codingplan.site",
"macOS 安装优化:Release 附带 install-macos.sh 一键脚本,自动清除下载隔离,免去手动右键打开",
"macOS 安装优化:Release 附带 install-macos.sh,自动清除下载隔离,免去手动右键打开",
],
},
{
@@ -201,7 +212,7 @@ const zh = {
version: "v0.6.9",
date: "2026-08-01",
items: [
"最近请求新增缓存命中率列,每次请求命中一目了然",
"最近请求新增缓存命中率列,逐条请求的命中情况直接可见",
"中文模式费用按最新汇率换算人民币(¥)显示,英文模式保持美元",
"仪表盘性能再优化:价格解析记忆化缓存,扫描从秒级降到百毫秒级;右上角新增加载耗时/数据量跟踪",
],
@@ -229,7 +240,7 @@ const zh = {
version: "v0.6.6",
date: "2026-08-01",
items: [
"模型价格对标 models.dev,覆盖 5910 个模型",
"模型价格数据对标 models.dev,快照随版本更新",
"新增基元律动供应商与推荐链接",
"设置页文案与参考来源说明优化",
],
@@ -311,34 +322,34 @@ const zh = {
},
download: {
title: "下载 Kimi Switch",
subtitle: "当前版本 v0.7.4 · Windows / macOS / Linux 三平台已发布",
autoUpdate: "应用内置自动检测更新,新版本发布后在设置页一键升级。",
subtitle: "当前版本 v0.7.12 · Windows / macOS / Linux 三平台已发布",
autoUpdate: "更新检查跑在启动时和每 8 小时的周期任务里,设置页也可以手动触发。",
ready: "已发布",
wip: "开发中",
githubBtn: "GitHub 下载",
mirrorBtn: "镜像下载",
items: [
{ id: "windows", name: "Windows", ready: true, note: ".msi 安装包,系统托盘常驻" },
{ id: "macos", name: "macOS", ready: true, note: ".dmg 安装包(Apple Silicon),未签名:下载 install-macos.sh 后运行 bash install-macos.sh 一键安装" },
{ id: "macos", name: "macOS", ready: true, note: ".dmg 安装包(Apple Silicon),未签名:下载 Release 附带的 install-macos.sh,运行 bash install-macos.sh 清除隔离属性" },
{ id: "linux", name: "Linux", ready: true, note: ".deb / .AppImage / .rpm 三格式" },
],
},
cta: {
title: "现在就把所有供应商,放进一个应用",
subtitle: "免费 · 开源 MIT · 一分钟完成安装",
title: "把供应商配置、用量和账单收进一个窗口",
subtitle: "免费 · MIT 开源 · Windows / macOS / Linux",
download: "免费下载",
source: "GitHub 源码",
},
seo: {
home: {
title: "Kimi Switch — 多 LLM 供应商统一管理 · 用量监控 · 账单一目了然",
title: "Kimi Switch — 多 LLM 供应商配置管理 · 用量与账单监控",
description:
"Kimi Switch / KimiCodeSwitch — Windows 桌面端多 LLM 供应商统一管理器,一键切换 Kimi / 智谱 GLM / MiniMax / DeepSeek / OpenRouter / 基元律动,用量账单实时监控,开源 MIT。",
"Kimi Switch / KimiCodeSwitch — 桌面端 LLM 供应商配置管理器:22 个预设选好即写入 config.toml,用量与账单直读各厂商官方接口。Tauri + Rust,开源 MIT。",
},
features: {
title: "核心功能 - Kimi Switch",
description:
"Kimi Switch 核心功能:多供应商管理、用量与账单监控、Kimi 一键授权登录、智能模型发现与自动更新,22 个预设开箱即用。",
"Kimi Switch 功能页:多供应商预设与自定义配置、用量与账单监控、Kimi 设备码授权、模型自动发现与周期更新检查。",
},
changelog: {
title: "更新日志 - Kimi Switch",
@@ -380,116 +391,116 @@ const en: Dict = {
performance: "Performance",
changelog: "Changelog",
download: "Download",
downloadBtn: "Download v0.7.4",
downloadBtn: "Download v0.7.12",
},
hero: {
badge: "v0.7.4 · Open source MIT · Windows / macOS / Linux",
badge: "v0.7.12 · Open source MIT · Windows / macOS / Linux",
titleBefore: "One app for all your ",
titleAccent: "AI providers",
titleAfter: "",
subtitle:
"Switch between Kimi, Zhipu GLM, MiniMax, DeepSeek, OpenRouter and TokenRhythm in one click, with usage and billing at a glance.",
"Stop hand-editing ~/.kimi-code/config.toml. Pick one of 22 provider presets and it goes straight into your config (backed up first); usage and billing read from each vendor's own API.",
download: "Free Download",
source: "View Source",
source: "View source",
stats: [
{ value: "22", label: "Provider presets" },
{ value: "5910", label: "Model price DB" },
{ value: "100%", label: "Open source" },
{ value: "7495", label: "Model price DB" },
{ value: "MIT", label: "License" },
],
},
features: {
title: "Core Features",
subtitle: "From multi-provider management to usage monitoring, all in one place.",
title: "Core features",
subtitle: "Configuration, switching, usage, billing and sessions — all against the config.toml on your machine.",
items: [
{
id: "multi-provider",
title: "Multi-Provider Management",
desc: "22 presets including Kimi, Zhipu GLM, MiniMax, DeepSeek, OpenRouter and TokenRhythm. Add and switch in one click.",
title: "Provider presets",
desc: "22 presets — Kimi, Zhipu GLM, MiniMax, DeepSeek, OpenRouter, TokenRhythm and more — ship with Base URL and model mapping filled in; picking one writes it to your config.",
img: "screenshots/preset-picker.png",
},
{
id: "custom-provider",
title: "Custom Providers",
desc: "Custom Base URL, API Key and model mapping, with advanced JSON editing.",
title: "Custom providers",
desc: "Edit Base URL, API key and model aliases field by field, or switch to the raw JSON tab; unknown fields are passed through untouched.",
img: "screenshots/edit-provider-basic-managed.png",
},
{
id: "usage",
title: "Usage & Billing",
desc: "5-hour / weekly plan quotas plus pay-as-you-go balance, priced with real models.dev rates and shown on cards.",
title: "Usage & billing",
desc: "5-hour / weekly plan quotas and pay-as-you-go balance shown directly on cards, with costs computed at models.dev list prices.",
img: "screenshots/dashboard-light.png",
},
{
id: "sessions",
title: "Session Manager",
title: "Session manager",
desc: "Browse, search and resume past sessions across providers.",
img: "screenshots/sessions-light.png",
},
{
id: "kimi-auth",
title: "One-Click Kimi Login",
title: "Kimi device-code login",
desc: "Built-in device-code authorization (same as kimi login). Authorize in the browser; 15-minute expiry auto-renews.",
img: "screenshots/kimi-oauth-dialog.png",
},
{
id: "usage-config",
title: "Usage Query Setup",
title: "Usage query setup",
desc: "Full-page config: NewAPI relay templates, timeout, auto-refresh interval, and a test query.",
img: "screenshots/usage-config-page.png",
},
{
id: "model-discovery",
title: "Smart Model Discovery",
desc: "Model capabilities and context sizes sync from models.dev at build time, covering 5910 models.",
title: "Model discovery",
desc: "Model lists are fetched from the provider API itself; display names, context sizes and capabilities come from a build-time models.dev snapshot (7495 models, 212 providers).",
img: "screenshots/kimi-cli-select-model.png",
},
{
id: "auto-update",
title: "Auto Updates",
desc: "Automatically detects new versions and installs them in one click.",
title: "Auto updates",
desc: "Checks at startup and every 8 hours (private mirror first, GitHub fallback), downloads in-app and walks you through install.",
img: "screenshots/settings.png",
},
],
},
showcase: {
title: "Screenshots",
subtitle: "Real screenshots from the app. What you see is what you get.",
subtitle: "All screenshots below are taken from the running v0.7.12 build.",
items: [
{
src: "screenshots/usage-config-page.png",
title: "Usage Query Setup",
title: "Usage query setup",
desc: "NewAPI relay templates, timeout and auto-refresh, plus a test query, all on one page",
},
{
src: "screenshots/preset-picker.png",
title: "Provider Presets",
desc: "22 presets ready out of the box, add popular vendors in one click",
title: "Provider presets",
desc: "Tick through 22 presets to add popular vendors",
},
{
src: "screenshots/kimi-oauth-dialog.png",
title: "Kimi Authorization",
title: "Kimi authorization",
desc: "Device-code flow identical to kimi login, auto-renews on expiry",
},
{
src: "screenshots/dashboard-daily-detail.png",
title: "Usage Details",
title: "Usage details",
desc: "Daily token and cost trends",
},
{
src: "screenshots/sessions-light.png",
title: "Session Manager",
title: "Session manager",
desc: "Browse and resume past sessions across providers",
},
{
src: "screenshots/kimi-plan-quota-card.png",
title: "Plan Quota Card",
desc: "5-hour / weekly quotas at a glance",
title: "Plan quota card",
desc: "5-hour / weekly quotas read straight off the card",
},
],
},
performance: {
title: "Lightweight & Native",
subtitle: "Tauri + Rust core with a WebView2 frontend. Zero tray overhead.",
title: "Lightweight and native",
subtitle: "Tauri + Rust on the system WebView2 — no bundled Chromium. The three numbers below are idle-state measurements.",
metrics: [
{ target: 93, suffix: " MB", label: "Working Set", desc: "Idle working-set memory" },
{ target: 34, suffix: " MB", label: "Private WS", desc: "Private memory (shared WebView2 excluded)" },
@@ -504,6 +515,17 @@ const en: Dict = {
syncedNote: "Synced from GitHub Releases",
fallbackNote: "Built-in release notes",
entries: [
{
version: "v0.7.12",
date: "2026-09-03",
items: [
"Adapted to kimi-code 0.40.1: experimental flag list updated (adds file_history / search_worker; secondary-model now on by default)",
"Flag priority semantics fixed: explicit configuration takes precedence over the master toggle, which no longer locks every switch",
"New dangerous-command guard toggle: Auto mode rejects rm -rf / shutdown outright, other modes force a confirmation prompt",
"Fix race-condition errors when clicking repeatedly while the WebUI is slow to open for the first time",
"models-dev snapshot updated to 7495 models / 212 providers",
],
},
{
version: "v0.7.4",
date: "2026-08-09",
@@ -534,7 +556,7 @@ const en: Dict = {
"Compatible with kimi-code 0.33+ (v2 engine): loop control now writes max_attempts_per_step with auto-migration",
"models.dev model list and pricing snapshot synced",
"Enterprise-style website refresh; publisher identity unified as codingplan.site",
"macOS install polish: releases now ship install-macos.sh, a one-click script that clears the download quarantine",
"macOS install polish: releases now ship install-macos.sh, which clears the download quarantine for you",
],
},
{
@@ -565,7 +587,7 @@ const en: Dict = {
version: "v0.6.9",
date: "2026-08-01",
items: [
"Recent requests gain a cache-hit-rate column, per request at a glance",
"Recent requests gain a cache-hit-rate column, shown per request",
"Chinese UI now shows costs in CNY (latest exchange rate); English keeps USD",
"Dashboard speedup: memoized price resolution drops the scan from seconds to milliseconds; load time / payload tracking in the top-right corner",
],
@@ -592,7 +614,7 @@ const en: Dict = {
version: "v0.6.6",
date: "2026-08-01",
items: [
"Model pricing aligned with models.dev, covering 5910 models",
"Model pricing data aligned with models.dev, snapshot refreshed per release",
"New TokenRhythm provider with referral link",
"Settings copy and source attribution polish",
],
@@ -652,56 +674,56 @@ const en: Dict = {
],
},
privacy: {
title: "Your Data Stays on Your Machine",
title: "Your data stays on your machine",
subtitle: "Our privacy and open-source commitments in three sentences.",
items: [
{
id: "local",
title: "Local Storage",
title: "Local storage",
desc: "Configs and usage data live only in ~/.kimi-switch and config.toml. Nothing is uploaded.",
},
{
id: "direct",
title: "Direct Connections",
title: "Direct connections",
desc: "Billing queries go straight to each vendor's official API. No middlemen, no tracking.",
},
{
id: "open",
title: "Open & Auditable",
title: "Open & auditable",
desc: "Fully open source under MIT on GitHub. Reviews and contributions welcome.",
},
],
},
download: {
title: "Download Kimi Switch",
subtitle: "Current version v0.7.4 · Windows, macOS and Linux now released",
autoUpdate: "Built-in update detection: upgrade in one click from Settings when a new version ships.",
subtitle: "Current version v0.7.12 · Windows, macOS and Linux now released",
autoUpdate: "Update checks run at startup and every 8 hours; Settings also has a manual check.",
ready: "Available",
wip: "In development",
githubBtn: "Download from GitHub",
mirrorBtn: "China mirror",
items: [
{ id: "windows", name: "Windows", ready: true, note: ".msi installer, lives in the system tray" },
{ id: "macos", name: "macOS", ready: true, note: ".dmg installer (Apple Silicon), unsigned: download install-macos.sh and run bash install-macos.sh for one-click install" },
{ id: "macos", name: "macOS", ready: true, note: ".dmg installer (Apple Silicon); unsigned — grab install-macos.sh from the release and run bash install-macos.sh to clear the quarantine flag" },
{ id: "linux", name: "Linux", ready: true, note: ".deb / .AppImage / .rpm" },
],
},
cta: {
title: "Bring every provider into one app",
subtitle: "Free · Open source MIT · One-minute install",
title: "Put provider config, usage and billing in one window",
subtitle: "Free · MIT licensed · Windows / macOS / Linux",
download: "Free Download",
source: "GitHub Source",
source: "GitHub source",
},
seo: {
home: {
title: "Kimi Switch — Unified Management for AI Providers · Usage & Billing",
title: "Kimi Switch — LLM provider config, usage and billing in one app",
description:
"Kimi Switch / KimiCodeSwitch — a Windows desktop manager for multiple LLM providers. Switch between Kimi, Zhipu GLM, MiniMax, DeepSeek, OpenRouter and TokenRhythm in one click, with usage and billing at a glance. Open source MIT.",
"Kimi Switch / KimiCodeSwitch — a desktop manager for LLM provider configuration. 22 presets write straight into ~/.kimi-code/config.toml; usage and billing read from each vendor's own API. Open source MIT.",
},
features: {
title: "Core Features - Kimi Switch",
title: "Core features - Kimi Switch",
description:
"Kimi Switch core features: multi-provider management, usage & billing, one-click Kimi login, smart model discovery and auto updates. 22 presets out of the box.",
"Kimi Switch features: provider presets and custom config, usage & billing, Kimi device-code login, model discovery and periodic update checks.",
},
changelog: {
title: "Changelog - Kimi Switch",
@@ -715,7 +737,7 @@ const en: Dict = {
},
},
footer: {
recommend: "Recommended Providers",
recommend: "Recommended providers",
mirror: "China mirror",
downloads: "Total GitHub downloads: {n}",
creditBefore: "Dashboard & session management adapted from ",
+10 -33
View File
@@ -23,9 +23,9 @@
--accent: 240 6% 94%;
--accent-foreground: 240 9% 11%;
--border: 240 6% 90%;
--primary: 221 83% 53%;
--primary: 221 74% 50%;
--primary-foreground: 0 0% 100%;
--ring: 221 83% 53%;
--ring: 221 74% 50%;
/* 克制分层阴影(色相一致的 tinted shadow,非荧光发光) */
--shadow-soft: 0 1px 2px hsl(240 9% 11% / 0.05),
0 8px 24px hsl(240 9% 11% / 0.06);
@@ -44,9 +44,9 @@
--accent: 222 25% 14%;
--accent-foreground: 220 20% 92%;
--border: 222 22% 17%;
--primary: 217 91% 60%;
--primary: 216 68% 62%;
--primary-foreground: 0 0% 100%;
--ring: 217 91% 60%;
--ring: 216 68% 62%;
--shadow-soft: 0 1px 2px hsl(222 47% 2% / 0.5),
0 8px 24px hsl(222 47% 2% / 0.45);
--shadow-lift: 0 1px 2px hsl(222 47% 2% / 0.5),
@@ -60,24 +60,6 @@
body {
@apply bg-background text-foreground antialiased;
}
/* 科技风底纹:固定网格线,极低透明度,不干扰阅读 */
body::before {
content: "";
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
background-image:
linear-gradient(to right, hsl(var(--primary) / 0.04) 1px, transparent 1px),
linear-gradient(to bottom, hsl(var(--primary) / 0.04) 1px, transparent 1px);
background-size: 44px 44px;
mask-image: radial-gradient(ellipse 80% 60% at 50% 0%, black 30%, transparent 75%);
}
#root {
position: relative;
z-index: 1;
}
/* 选中色与键盘焦点(无障碍) */
::selection {
background: hsl(var(--primary) / 0.16);
@@ -96,19 +78,14 @@
@layer components {
.container-page {
@apply mx-auto w-full max-w-6xl px-6;
@apply mx-auto w-full max-w-7xl px-5 md:px-8;
}
.btn {
@apply inline-flex items-center justify-center gap-2 rounded-xl px-5 py-2.5 text-sm font-medium transition-all duration-200;
@apply inline-flex items-center justify-center gap-2 rounded-lg px-5 py-2.5 text-sm font-medium transition-all duration-200;
}
.btn-primary {
@apply btn bg-primary text-primary-foreground hover:bg-primary/90 hover:-translate-y-px active:translate-y-0 active:scale-[0.98];
box-shadow: 0 1px 2px hsl(var(--primary) / 0.3),
0 6px 16px hsl(var(--primary) / 0.22);
}
.btn-primary:hover {
box-shadow: 0 1px 2px hsl(var(--primary) / 0.3),
0 10px 24px hsl(var(--primary) / 0.3);
box-shadow: 0 1px 2px hsl(221 45% 25% / 0.25);
}
.btn-ghost {
@apply btn border border-border bg-card/60 text-foreground hover:border-foreground/25 hover:bg-accent active:scale-[0.98];
@@ -126,14 +103,14 @@
}
/* 截图容器:限定最大宽高,避免撑满页面 */
.screenshot {
@apply overflow-hidden rounded-2xl border border-border bg-muted;
@apply overflow-hidden rounded-xl border border-border bg-muted;
box-shadow: var(--shadow-lift);
}
.screenshot > img {
@apply block h-auto w-full;
}
/* 图标容器:统一的蓝色徽标容器 */
/* 图标容器:中性底、统一描边宽度(Phosphor duotone) */
.icon-chip {
@apply flex h-10 w-10 items-center justify-center rounded-xl border border-primary/20 bg-primary/10 text-primary;
@apply flex h-10 w-10 items-center justify-center rounded-lg border border-border bg-muted text-foreground;
}
}
+24 -30
View File
@@ -7,42 +7,36 @@ export default function CTA() {
<section
id="cta"
aria-labelledby="cta-title"
className="py-24"
className="pt-20 pb-28"
>
<div className="container-page">
<div className="card relative overflow-hidden p-12 text-center md:p-16">
{/* 克制同色系光晕 + 顶部强调线(去紫,保持页面调性) */}
{/* 左对齐 + 内容错位:不再整块居中 */}
<div className="card relative max-w-3xl p-10 md:p-14">
<div
aria-hidden="true"
className="pointer-events-none absolute -top-24 left-1/2 h-64 w-[480px] -translate-x-1/2 rounded-full bg-primary/10 blur-3xl"
className="absolute inset-x-0 top-0 h-px bg-primary/25"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-primary/40 to-transparent"
/>
<div className="relative">
<h2
id="cta-title"
className="text-3xl font-bold tracking-[-0.03em] md:text-5xl"
<h2
id="cta-title"
className="text-3xl font-bold tracking-[-0.03em] md:text-4xl"
>
{t.cta.title}
</h2>
<p className="mt-4 max-w-[52ch] text-base text-muted-foreground md:text-lg">
{t.cta.subtitle}
</p>
<div className="mt-8 flex flex-wrap gap-3">
<Link to="/download" className="btn-primary">
{t.cta.download}
</Link>
<a
href="https://github.com/billowliu2/KimiSwitch"
target="_blank"
rel="noreferrer"
className="btn-ghost"
>
{t.cta.title}
</h2>
<p className="mx-auto mt-4 max-w-2xl text-base text-muted-foreground md:text-lg">
{t.cta.subtitle}
</p>
<div className="mt-8 flex flex-wrap justify-center gap-3">
<Link to="/download" className="btn-primary">
{t.cta.download}
</Link>
<a
href="https://github.com/billowliu2/KimiSwitch"
target="_blank"
rel="noreferrer"
className="btn-ghost"
>
{t.cta.source}
</a>
</div>
{t.cta.source}
</a>
</div>
</div>
</div>
+36 -20
View File
@@ -8,27 +8,30 @@ const icons: Record<string, Icon> = {
linux: LinuxLogo,
};
const VERSION = "0.7.4";
const VERSION = "0.7.12";
const GITHUB = "https://github.com/billowliu2/KimiSwitch";
const MIRROR_RELEASES = "https://git.codingplan.site/admin/KimiCodeSwitch/releases";
const dl = (file: string) => `${GITHUB}/releases/download/v${VERSION}/${file}`;
/** Per-platform download assets for the current release (names match the CI
* release workflow exactly — verified against the published v0.7.4 assets:
* tauri-bundler on CI produces `Kimi.Switch_…` (dots) on every platform). */
* release workflow exactly — verified against the published v0.7.12 assets:
* tauri-bundler on CI produces `Kimi.Switch_…` (dots) on every platform,
* except the rpm which uses `Kimi.Switch-<version>-1` (hyphens).
* Filenames interpolate VERSION, so a release bump only changes the
* constant above. */
const assets: Record<string, { label: string; href: string }[]> = {
windows: [
{ label: "MSI", href: dl("Kimi.Switch_0.7.4_x64_en-US.msi") },
{ label: "MSI", href: dl(`Kimi.Switch_${VERSION}_x64_en-US.msi`) },
{ label: "镜像", href: MIRROR_RELEASES },
],
macos: [
{ label: "Apple Silicon (.dmg)", href: dl("Kimi.Switch_0.7.4_aarch64.dmg") },
{ label: "Apple Silicon (.dmg)", href: dl(`Kimi.Switch_${VERSION}_aarch64.dmg`) },
{ label: "安装脚本", href: dl("install-macos.sh") },
],
linux: [
{ label: ".deb", href: dl("Kimi.Switch_0.7.4_amd64.deb") },
{ label: ".AppImage", href: dl("Kimi.Switch_0.7.4_amd64.AppImage") },
{ label: ".rpm", href: dl("Kimi.Switch-0.7.4-1.x86_64.rpm") },
{ label: ".deb", href: dl(`Kimi.Switch_${VERSION}_amd64.deb`) },
{ label: ".AppImage", href: dl(`Kimi.Switch_${VERSION}_amd64.AppImage`) },
{ label: ".rpm", href: dl(`Kimi.Switch-${VERSION}-1.x86_64.rpm`) },
],
};
@@ -40,23 +43,36 @@ export default function Download() {
title={t.download.title}
subtitle={t.download.subtitle}
>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{/* 不对称:Windows 为主推平台,横跨整行;macOS / Linux 两列在下方 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{t.download.items.map((p) => {
const Icon = icons[p.id] ?? WindowsLogo;
const links = assets[p.id] ?? [];
const featured = p.id === "windows";
return (
<div key={p.id} className="card flex flex-col">
<div className="mb-3 flex items-center justify-between">
<div className="icon-chip">
<Icon className="h-5 w-5" weight="duotone" />
<div
key={p.id}
className={`card flex flex-col ${
featured
? "md:col-span-2 md:flex-row md:items-center md:justify-between md:gap-10"
: ""
}`}
>
<div className="min-w-0">
<div className="mb-3 flex items-center gap-3">
<div className="icon-chip shrink-0">
<Icon className="h-5 w-5" weight="duotone" />
</div>
<h3 className="text-lg font-semibold tracking-tight">{p.name}</h3>
<span className="ml-auto rounded-md border border-primary/20 bg-primary/10 px-2 py-0.5 text-[11px] font-medium tracking-wide text-primary">
{t.download.ready}
</span>
</div>
<span className="rounded-md border border-primary/20 bg-primary/10 px-2 py-0.5 text-[11px] font-medium tracking-wide text-primary">
{t.download.ready}
</span>
<p className={`text-sm text-muted-foreground ${featured ? "max-w-[65ch]" : ""}`}>
{p.note}
</p>
</div>
<h3 className="mb-2 text-lg font-semibold tracking-tight">{p.name}</h3>
<p className="text-sm text-muted-foreground">{p.note}</p>
<div className="mt-4 flex flex-wrap gap-2">
<div className="mt-4 flex shrink-0 flex-wrap items-start gap-2 md:mt-0">
{links.map((l) => (
<a
key={l.label}
@@ -74,7 +90,7 @@ export default function Download() {
})}
</div>
<p className="mt-6 flex items-center gap-2 text-sm text-muted-foreground">
<p className="mt-6 flex max-w-[65ch] items-center gap-2 text-sm text-muted-foreground">
<DownloadSimple className="h-4 w-4 text-primary" weight="duotone" />
{t.download.autoUpdate}
</p>
+2
View File
@@ -45,6 +45,8 @@ export default function Features() {
key={f.id}
style={{ transitionDelay: `${i * 60}ms` }}
className={`card overflow-hidden p-0 transition-all duration-500 ease-out hover:border-primary/50 ${
i % 2 === 1 ? "sm:mt-12" : ""
} ${
gridInView
? "motion-safe:opacity-100 motion-safe:translate-y-0"
: "motion-safe:opacity-0 motion-safe:translate-y-4"
+1 -10
View File
@@ -23,15 +23,6 @@ export default function Hero() {
aria-labelledby="hero-title"
className="relative overflow-hidden pt-16 pb-20 md:pt-24"
>
{/* 克制的同色系光晕(去紫,统一蓝色 + 中性) */}
<div
aria-hidden="true"
className="pointer-events-none absolute -top-32 right-[-10%] h-[420px] w-[420px] rounded-full bg-primary/10 blur-3xl"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute -left-40 top-1/2 h-[320px] w-[320px] rounded-full bg-primary/5 blur-3xl"
/>
<div className="container-page relative grid grid-cols-1 gap-10 md:grid-cols-2 md:items-center">
<div>
<div {...reveal(0)}>
@@ -87,7 +78,7 @@ export default function Hero() {
<div className="screenshot max-h-[420px]">
<img
src={asset("screenshots/hero.png")}
alt="Kimi Switch: multi-LLM provider list with one-click switching"
alt="Kimi Switch provider list, active provider pinned to the top"
loading="eager"
/>
</div>
+12 -7
View File
@@ -16,18 +16,23 @@ export default function Privacy() {
title={t.privacy.title}
subtitle={t.privacy.subtitle}
>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{/* 三条承诺改为一个面板内的三行,图标左置,避免三张等宽卡片 */}
<div className="card divide-y divide-border p-0">
{t.privacy.items.map((it) => {
const Icon = icons[it.id] ?? Database;
return (
<div key={it.id} className="card">
<div className="icon-chip mb-3">
<div key={it.id} className="flex items-start gap-5 p-6 md:p-7">
<div className="icon-chip shrink-0">
<Icon className="h-5 w-5" weight="duotone" />
</div>
<h3 className="mb-2 text-lg font-semibold tracking-tight">{it.title}</h3>
<p className="text-sm leading-relaxed text-muted-foreground">
{it.desc}
</p>
<div className="min-w-0">
<h3 className="text-base font-semibold tracking-tight">
{it.title}
</h3>
<p className="mt-1 max-w-[65ch] text-sm leading-relaxed text-muted-foreground">
{it.desc}
</p>
</div>
</div>
);
})}
+7 -1
View File
@@ -21,12 +21,18 @@ export default function Showcase() {
key={s.src}
style={{ transitionDelay: `${i * 60}ms` }}
className={`transition-all duration-500 ease-out ${
i === 0 ? "sm:col-span-2" : ""
} ${
gridInView
? "motion-safe:opacity-100 motion-safe:translate-y-0"
: "motion-safe:opacity-0 motion-safe:translate-y-4"
}`}
>
<div className="screenshot group aspect-[4/3] overflow-hidden">
<div
className={`screenshot group overflow-hidden ${
i === 0 ? "aspect-[16/9]" : "aspect-[4/3]"
}`}
>
<img
src={asset(s.src)}
alt={`Kimi Switch - ${s.title}`}