Compare commits

...
15 Commits
Author SHA1 Message Date
KimiSwitch Dev 86112ee90b chore(release): bump to v0.7.14 — 修复关闭用量查询后仍查询/报错的问题
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-06 19:38:13 +08:00
KimiSwitch Dev e349fa5971 chore(data): 同步 models-dev 快照价格漂移 2026-09-06 19:36:44 +08:00
KimiSwitch Dev 1de8b9ee4c fix(usage): 关闭「启用用量查询」后不再发起查询且隐藏用量行
useUsageQuery 增加 disabled 门控:关闭时自动查询不发、refresh no-op、
状态立即复位 idle(UsageFooter 对 idle 返回 null,compact/detail 均
整体隐藏);在途响应按 generation 作废且不写入模块级共享缓存,
避免关闭瞬间的失败结果在 5 分钟内重新挂载时复活。
2026-09-06 19:36:42 +08:00
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
KimiSwitch Dev 8b597496b3 chore(release): bump to v0.7.11 — 适配 kimi-code 0.39.1(thinking keep/loop_control/flags)+ 归档元数据化 + 子代理池修复 + models-dev 7482/207
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-08-29 11:15:40 +08:00
KimiSwitch Dev 8ce3f290ed chore(data): 同步 models-dev 最新快照(7482 模型 / 207 供应商) 2026-08-29 11:14:11 +08:00
KimiSwitch Dev 8639188968 fix(sessions): 归档迁移为 state.json 元数据(archived/archivedAt,与上游 v2 setArchived 逐字段一致),不再物理搬目录;遗留 .kcd-archive 目录兼容识别与恢复;归档不再动 session_index 2026-08-29 11:14:01 +08:00
KimiSwitch Dev 45ec073e6f fix(config): 适配 kimi-code 0.39.1——thinking keep 写字符串 off(布尔会整节作废)、存量布尔归一化;loop_control 默认只写 v2 键(legacy flag 才双写);effort 移除 max 档;实验 flags 补 remote-control、wait_for/minidb 标默认开启 2026-08-29 11:13:58 +08:00
42 changed files with 180367 additions and 172484 deletions

No files matched your search

+2 -2
View File
@@ -123,7 +123,7 @@
![会话管理](docs/screenshots/sessions.png) ![会话管理](docs/screenshots/sessions.png)
按工作区隔离浏览 Kimi Code 会话,支持活跃/已归档/全部筛选;流式逐行预览(20MB 字节上限,500 字符折叠);归档/取消归档/批量删除。 按工作区隔离浏览 Kimi Code 会话,支持活跃/已归档/全部筛选;流式逐行预览(20MB 字节上限,500 字符折叠);归档/取消归档/批量删除。归档写入 `state.json` 元数据(与 CLI v2 一致),旧版本物理归档目录 `.kcd-archive/` 仍可识别与恢复。
## 架构总览 ## 架构总览
@@ -239,7 +239,7 @@
- 按工作区隔离浏览 Kimi Code 会话,支持活跃/已归档/全部筛选 - 按工作区隔离浏览 Kimi Code 会话,支持活跃/已归档/全部筛选
- 流式逐行预览(20MB 字节上限,500 字符折叠可展开) - 流式逐行预览(20MB 字节上限,500 字符折叠可展开)
- 归档 / 取消归档 / 批量删除 - 归档 / 取消归档 / 批量删除(归档写入 `state.json` 元数据,与 CLI v2 一致;旧版本物理归档目录 `.kcd-archive/` 仍可识别与恢复)
- 早期版本的"闪崩"已通过流式读取 + 限制解决 - 早期版本的"闪崩"已通过流式读取 + 限制解决
### 设置面板 ### 设置面板
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "kimiswitch", "name": "kimiswitch",
"private": true, "private": true,
"version": "0.7.10", "version": "0.7.14",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+22
View File
@@ -0,0 +1,22 @@
## v0.7.11
### 适配 kimi-code 0.39.1
- **修复:`[thinking]` 保留开关不再破坏思考配置**。此前在 UI 关闭"保留思考内容"会写入布尔 `keep = false`,而上游两个引擎都只接受字符串——v2 会把整个 `[thinking]` 节校验失败并丢弃。现改为写 `keep = "off"`,存量配置里的布尔值加载时自动归一化
- **修复:`loop_control` 不再触发上游废弃告警**。默认只写新键 `max_attempts_per_step` 并清理存量旧键 `max_retries_per_step`;仅检测到 `KIMI_CODE_LEGACY_FLAG`(v1 引擎)时才双写
- **思考强度移除 `max` 档**(上游已自动迁移为 `high`),存量配置显示归一
- **实验开关同步上游全集**:新增 remote-control;`wait_for` 与 persistence_minidb_readmodel 已在上游转正默认开启,UI 会标注"默认开启"(显式关闭优先)
### 会话归档与上游 v2 对齐
- **归档不再搬动文件**:改为在会话 `state.json` 写入 `archived`/`archivedAt` 元数据(与 CLI v2 的 `setArchived` 逐字段一致),CLI 侧与 Kimi Switch 侧归档状态从此互通
- 旧版本物理归档的会话(`.kcd-archive/` 目录)仍可识别为已归档并正常恢复
- 归档不再改写 `session_index.jsonl`(删除会话的行为不变)
### 子代理模型池修复
- **修复:模型池无法添加第二个条目**。从"仅默认模型"的隐式单条目形态添加条目时,默认模型现在会自动物化进池,不再被"默认模型不在模型池中"的校验拦截
### 模型数据同步
- models.dev 快照刷新至 **7482 个模型 / 207 个供应商**
+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 错误本地化单元测试
+10
View File
@@ -0,0 +1,10 @@
# KimiSwitch v0.7.14
## 修复
- **关闭「启用用量查询」后不再残留查询行为**:此前关闭开关后前端仍会发起查询,卡片上持续显示「查询失败 · 用量查询已关闭」的红色错误行。现在关闭后立即停止查询、隐藏整行用量信息(卡片头部与底部两种展示均生效),重新开启后自动恢复
- 在途的查询结果在开关切换瞬间作废,且不再写入共享缓存,避免过期的失败状态在 5 分钟内复活
## 数据
- models-dev 快照同步(上游格式与价格更新)
+1 -1
View File
@@ -1978,7 +1978,7 @@ dependencies = [
[[package]] [[package]]
name = "kimiswitch" name = "kimiswitch"
version = "0.7.10" version = "0.7.14"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "kimiswitch" name = "kimiswitch"
version = "0.7.10" version = "0.7.14"
description = "Kimi Switch - model config manager" description = "Kimi Switch - model config manager"
authors = ["codingplan.site"] authors = ["codingplan.site"]
edition = "2021" edition = "2021"
+121 -58
View File
@@ -690,8 +690,8 @@ pub async fn query_provider_usage(
let base_url = resolve_base_url(provider); let base_url = resolve_base_url(provider);
// NewAPI template: query the gateway's own /api/user/self with the // NewAPI template: query the gateway's own usage endpoints with its
// web-console access token, bypassing usage_kinds entirely. // dedicated credentials, bypassing usage_kinds entirely.
if let Some(cfg) = &provider.usage_config { if let Some(cfg) = &provider.usage_config {
if cfg.template_type == UsageConfig::TEMPLATE_NEWAPI { if cfg.template_type == UsageConfig::TEMPLATE_NEWAPI {
// Err = transient (network) → propagate for retry, same semantics // Err = transient (network) → propagate for retry, same semantics
@@ -1014,6 +1014,9 @@ pub struct KimiWebState {
window: Mutex<Option<tauri::WebviewWindow>>, window: Mutex<Option<tauri::WebviewWindow>>,
/// The `kimi web` child spawned by this app (None when reusing a server). /// The `kimi web` child spawned by this app (None when reusing a server).
child: Mutex<Option<std::process::Child>>, 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; const KIMI_WEB_PORT: u16 = 58627;
@@ -1064,7 +1067,13 @@ pub async fn open_kimi_web_embedded(
app: tauri::AppHandle, app: tauri::AppHandle,
state: tauri::State<'_, KimiWebState>, state: tauri::State<'_, KimiWebState>,
) -> Result<(), String> { ) -> 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 Ok(guard) = state.window.lock() {
if let Some(w) = guard.as_ref() { if let Some(w) = guard.as_ref() {
if w.is_visible().unwrap_or(false) { 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`. // Reuse the server this app spawned earlier (still running), or a server
if !kimi_web_alive(Duration::from_millis(800)).await { // 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"); let mut cmd = std::process::Command::new("kimi");
cmd.args(["web", "--no-open", "--port", &KIMI_WEB_PORT.to_string()]); cmd.args(["web", "--no-open", "--port", &KIMI_WEB_PORT.to_string()]);
#[cfg(windows)] #[cfg(windows)]
@@ -1091,6 +1113,7 @@ pub async fn open_kimi_web_embedded(
format!("failed to start `kimi web`: {e}") format!("failed to start `kimi web`: {e}")
} }
})?; })?;
spawned_by_me = true;
if let Ok(mut guard) = state.child.lock() { if let Ok(mut guard) = state.child.lock() {
*guard = Some(child); *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). // Wait for the server to come up (fresh start takes a moment).
if !kimi_web_alive(Duration::from_secs(8)).await { 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})")); 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() { let url: tauri::Url = match kimi_web_url().parse() {
Ok(u) => u, Ok(u) => u,
Err(_) => return Err("invalid kimi web url".to_string()), Err(_) => return Err("invalid kimi web url".to_string()),
}; };
let app = app.clone(); let (tx, rx) = tokio::sync::oneshot::channel::<Result<(), String>>();
tauri::async_runtime::spawn(async move { let builder_app = app.clone();
let builder_app = app.clone(); app.run_on_main_thread(move || {
let _ = app.run_on_main_thread(move || { let result = build_kimi_web_window(&builder_app, &url);
match tauri::WebviewWindowBuilder::new( let _ = tx.send(result);
&builder_app, })
KIMI_WEB_WINDOW_LABEL, .map_err(|e| format!("failed to queue window creation: {e}"))?;
tauri::WebviewUrl::External(url), match rx.await {
) Ok(result) => {
.title("Kimi Code WebUI") if let Err(e) = result {
.inner_size(1100.0, 750.0) // Window creation failed (e.g. label already taken): only stop
.resizable(true) // the server when this invocation spawned it.
.center() if spawned_by_me {
.build() kill_spawned_kimi_web(&state);
{
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>());
} }
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(()) Ok(())
} }
@@ -1293,24 +1338,42 @@ pub async fn kimi_oauth_poll(
// Experimental feature env-var probe (Kimi Code secondary model etc.) // Experimental feature env-var probe (Kimi Code secondary model etc.)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Read the Kimi Code experimental-feature environment variables that are /// Read the Kimi Code environment variables that are currently set (non-empty)
/// currently set (non-empty) in this process's environment. The frontend /// in this process's environment. Every `[experimental]` feature env var is
/// uses the result to mark config.toml `[experimental]` toggles as locked: /// probed — the frontend uses a per-feature env var to mark the matching
/// env vars outrank the config file in Kimi Code's flag resolution. /// 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); /// Truthy values per the CLI: `1` / `true` / `yes` / `on` (case-insensitive);
/// the raw values are returned and the truthy check happens on the frontend. /// the raw values are returned and the truthy check happens on the frontend.
#[tauri::command] #[tauri::command]
pub fn get_experimental_env_status() -> HashMap<String, String> { pub fn get_experimental_env_status() -> HashMap<String, String> {
const VARS: [&str; 5] = [ const VARS: [(&str, &str); 11] = [
"KIMI_CODE_EXPERIMENTAL_FLAG", ("secondary-model", "KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL"),
"KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL", ("tool-select", "KIMI_CODE_EXPERIMENTAL_TOOL_SELECT"),
"KIMI_CODE_EXPERIMENTAL_TOOL_SELECT", (
"KIMI_CODE_EXPERIMENTAL_ACP_V2", "persistence_minidb_readmodel",
"KIMI_CODE_EXPERIMENTAL_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(); let mut out = HashMap::new();
for name in VARS { for (_, name) in VARS {
if let Ok(value) = std::env::var(name) { if let Ok(value) = std::env::var(name) {
if !value.trim().is_empty() { if !value.trim().is_empty() {
out.insert(name.to_string(), value); out.insert(name.to_string(), value);
+343 -34
View File
@@ -1234,6 +1234,49 @@ fn read_state_safe(session_dir: &Path) -> (Option<String>, Option<String>, Optio
(None, None, None, None) (None, None, None, None)
} }
/// Whether a session is archived per its `state.json` metadata: the
/// kimi-code v2 engine writes `archived: true` (plus an `archivedAt` epoch-ms
/// timestamp) when archiving and clears both on restore. Unparseable or
/// missing state.json counts as not archived (and won't panic).
fn read_state_archived(session_dir: &Path) -> bool {
let sp = session_dir.join("state.json");
let Ok(content) = fs::read_to_string(&sp) else { return false };
let Ok(obj) = serde_json::from_str::<serde_json::Value>(&content) else { return false };
obj.get("archived").and_then(|v| v.as_bool()).unwrap_or(false)
}
/// Set or clear the archived metadata on a session's `state.json`, preserving
/// every other field. Mirrors upstream kimi-code v2 `SessionMetadata.setArchived`:
/// archive writes `{ archived: true, archivedAt: Date.now() }` (epoch ms, same
/// `Date.now()` format as the CLI), restore writes `{ archived: false }` and
/// drops the `archivedAt` key; `updatedAt` is untouched in both cases.
/// The write is atomic (tmp file + rename), same pattern as the plugin store.
/// Missing or malformed state.json returns a descriptive error, never a panic.
fn set_session_archived_meta(session_dir: &Path, archived: bool) -> Result<(), String> {
let sp = session_dir.join("state.json");
let content = fs::read_to_string(&sp).map_err(|e| format!("read state.json: {e}"))?;
let mut obj: serde_json::Value =
serde_json::from_str(&content).map_err(|e| format!("parse state.json: {e}"))?;
let obj = obj
.as_object_mut()
.ok_or_else(|| "state.json is not a JSON object".to_string())?;
obj.insert("archived".into(), serde_json::Value::Bool(archived));
if archived {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
obj.insert("archivedAt".into(), serde_json::Value::Number(now_ms.into()));
} else {
obj.remove("archivedAt");
}
let out = serde_json::to_string(&obj).map_err(|e| format!("serialize state.json: {e}"))?;
let tmp = sp.with_file_name("state.json.tmp");
fs::write(&tmp, out).map_err(|e| format!("write state.json: {e}"))?;
fs::rename(&tmp, &sp).map_err(|e| format!("commit state.json: {e}"))?;
Ok(())
}
fn humanize_workspace(id: &str) -> String { fn humanize_workspace(id: &str) -> String {
let re = Regex::new(r"^wd_(.+)_[0-9a-fA-F]{8,}$").unwrap(); let re = Regex::new(r"^wd_(.+)_[0-9a-fA-F]{8,}$").unwrap();
if let Some(caps) = re.captures(id) { if let Some(caps) = re.captures(id) {
@@ -1243,7 +1286,11 @@ fn humanize_workspace(id: &str) -> String {
} }
} }
fn list_sessions_in_dir(dir: &Path, workspace_id: &str, status: &str) -> Vec<SessionRow> { /// List session rows under one directory. `in_archive_dir` marks `.kcd-archive`
/// (legacy physical archive); a session is additionally archived when its own
/// `state.json` carries `archived: true` — so CLI-v2 metadata-archived sessions
/// that still live at the active root are correctly classified.
fn list_sessions_in_dir(dir: &Path, workspace_id: &str, in_archive_dir: bool) -> Vec<SessionRow> {
let mut sessions = Vec::new(); let mut sessions = Vec::new();
if !dir.exists() { return sessions; } if !dir.exists() { return sessions; }
let re = session_re(); let re = session_re();
@@ -1251,6 +1298,8 @@ fn list_sessions_in_dir(dir: &Path, workspace_id: &str, status: &str) -> Vec<Ses
if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { continue; } if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { continue; }
let name = entry.file_name().to_string_lossy().to_string(); let name = entry.file_name().to_string_lossy().to_string();
if !re.is_match(&name) { continue; } if !re.is_match(&name) { continue; }
let archived = in_archive_dir || read_state_archived(&entry.path());
let status = if archived { "archived" } else { "active" };
let (title, work_dir, created_at, updated_at) = read_state_safe(&entry.path()); let (title, work_dir, created_at, updated_at) = read_state_safe(&entry.path());
let (bytes, files) = file_size_approx(&entry.path()); let (bytes, files) = file_size_approx(&entry.path());
let mtime = entry.path().metadata().ok().and_then(|m| m.modified().ok()) let mtime = entry.path().metadata().ok().and_then(|m| m.modified().ok())
@@ -1311,19 +1360,33 @@ fn list_sessions_cmd(home: &Path, status: &str, workspace_filter: Option<String>
let active_dir = root.join(wid); let active_dir = root.join(wid);
let arch_dir = archive_root.join(wid); let arch_dir = archive_root.join(wid);
let active_all = list_sessions_in_dir(&active_dir, wid, "active"); // Merge both sources, deduping by session id (ids are UUIDs and never
let arch_all = list_sessions_in_dir(&arch_dir, wid, "archived"); // reused; a stale legacy copy under .kcd-archive loses to the entry at
let active_list = if status == "archived" { vec![] } else { active_all.clone() }; // the active root). Status comes from each row, so metadata-archived
let arch_list = if status == "active" { vec![] } else { arch_all.clone() }; // sessions surface in the archived filter and the archive-only counts.
let mut by_id: HashMap<String, SessionRow> = HashMap::new();
for row in list_sessions_in_dir(&active_dir, wid, false) {
by_id.insert(row.id.clone(), row);
}
for row in list_sessions_in_dir(&arch_dir, wid, true) {
by_id.entry(row.id.clone()).or_insert(row);
}
let all: Vec<SessionRow> = by_id.into_values().collect();
let active_count = all.iter().filter(|s| s.status == "active").count();
let archived_count = all.iter().filter(|s| s.status == "archived").count();
let listed: Vec<SessionRow> = match status {
"active" => all.iter().filter(|s| s.status == "active").cloned().collect(),
"archived" => all.iter().filter(|s| s.status == "archived").cloned().collect(),
_ => all,
};
let empty = active_all.is_empty() && arch_all.is_empty(); let empty = active_count == 0 && archived_count == 0;
workspaces.push(WorkspaceRow { workspaces.push(WorkspaceRow {
id: wid.clone(), name, root: root_path, id: wid.clone(), name, root: root_path,
created_at: None, last_opened_at: None, created_at: None, last_opened_at: None,
active_count: active_all.len(), archived_count: arch_all.len(), empty, active_count, archived_count, empty,
}); });
all_sessions.extend(active_list); all_sessions.extend(listed);
all_sessions.extend(arch_list);
} }
all_sessions.sort_by(|a, b| { all_sessions.sort_by(|a, b| {
@@ -1356,38 +1419,57 @@ fn assert_safe_path(home: &Path, workspace_id: &str, session_id: &str) -> Result
} }
fn archive_session_cmd(home: &Path, workspace_id: &str, session_id: &str) -> Result<ActionResponse, String> { fn archive_session_cmd(home: &Path, workspace_id: &str, session_id: &str) -> Result<ActionResponse, String> {
let src = assert_safe_path(home, workspace_id, session_id)?; let dir = assert_safe_path(home, workspace_id, session_id)?;
let dest = sessions_root(home).join(".kcd-archive").join(workspace_id).join(session_id); if !dir.exists() { return Err("session not found".into()); }
if !src.exists() { return Err("session not found".into()); } // Metadata archive, matching upstream kimi-code v2: write `archived: true`
if let Some(parent) = dest.parent() { // + `archivedAt` into state.json, leave the session directory in place, and
fs::create_dir_all(parent).map_err(|e| format!("mkdir: {}", e))?; // do NOT touch session_index.jsonl — archived state is derived from
// state.json, and the CLI reconciles its own index mirror. A session
// without a readable state.json gets a clear error rather than a panic.
let sp = dir.join("state.json");
if !sp.is_file() {
return Err(format!("session {session_id} has no state.json; cannot archive"));
} }
fs::rename(&src, &dest).or_else(|_| { set_session_archived_meta(&dir, true)?;
// cross-device fallback
fs_extra::dir::copy(&src, dest.parent().unwrap(), &Default::default()).ok();
fs::remove_dir_all(&src).ok();
Ok::<(), String>(())
}).map_err(|e: String| format!("move: {}", e))?;
scrub_session_index(home, session_id);
Ok(ActionResponse { Ok(ActionResponse {
ok: true, workspace_id: workspace_id.to_string(), ok: true, workspace_id: workspace_id.to_string(),
session_id: session_id.to_string(), status: Some("archived".into()), session_id: session_id.to_string(), status: Some("archived".into()),
path: Some(dest.to_string_lossy().to_string()), deleted: None, path: Some(dir.to_string_lossy().to_string()), deleted: None,
}) })
} }
fn unarchive_session_cmd(home: &Path, workspace_id: &str, session_id: &str) -> Result<ActionResponse, String> { fn unarchive_session_cmd(home: &Path, workspace_id: &str, session_id: &str) -> Result<ActionResponse, String> {
let src = sessions_root(home).join(".kcd-archive").join(workspace_id).join(session_id);
let dest = assert_safe_path(home, workspace_id, session_id)?; let dest = assert_safe_path(home, workspace_id, session_id)?;
if !src.exists() { return Err("archived session not found".into()); } // 1) Legacy recovery: sessions physically moved to .kcd-archive by older
if let Some(parent) = dest.parent() { // KimiSwitch versions are moved back, then their metadata flag is
fs::create_dir_all(parent).map_err(|e| format!("mkdir: {}", e))?; // cleared per upstream restore semantics (archived:false + archivedAt
// dropped). A missing state.json is fine here — there is no flag.
let archived_dir = sessions_root(home).join(".kcd-archive").join(workspace_id).join(session_id);
if archived_dir.exists() {
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).map_err(|e| format!("mkdir: {}", e))?;
}
fs::rename(&archived_dir, &dest).or_else(|_| {
// cross-device fallback
fs_extra::dir::copy(&archived_dir, dest.parent().unwrap(), &Default::default()).ok();
fs::remove_dir_all(&archived_dir).ok();
Ok::<(), String>(())
}).map_err(|e: String| format!("move: {}", e))?;
// The move already restored the session; a corrupt state.json must not
// turn a successful restore into an error — the session simply lists
// as active with its flag intact.
if dest.join("state.json").is_file() {
let _ = set_session_archived_meta(&dest, false);
}
return Ok(ActionResponse {
ok: true, workspace_id: workspace_id.to_string(),
session_id: session_id.to_string(), status: Some("active".into()),
path: Some(dest.to_string_lossy().to_string()), deleted: None,
});
} }
fs::rename(&src, &dest).or_else(|_| { // 2) Metadata-archived session: still at the active root, just clear the flag.
fs_extra::dir::copy(&src, dest.parent().unwrap(), &Default::default()).ok(); if !dest.exists() { return Err("archived session not found".into()); }
fs::remove_dir_all(&src).ok(); set_session_archived_meta(&dest, false)?;
Ok::<(), String>(())
}).map_err(|e: String| format!("move: {}", e))?;
Ok(ActionResponse { Ok(ActionResponse {
ok: true, workspace_id: workspace_id.to_string(), ok: true, workspace_id: workspace_id.to_string(),
session_id: session_id.to_string(), status: Some("active".into()), session_id: session_id.to_string(), status: Some("active".into()),
@@ -1398,6 +1480,9 @@ fn unarchive_session_cmd(home: &Path, workspace_id: &str, session_id: &str) -> R
fn delete_session_cmd(home: &Path, workspace_id: &str, session_id: &str, status_hint: Option<&str>) -> Result<ActionResponse, String> { fn delete_session_cmd(home: &Path, workspace_id: &str, session_id: &str, status_hint: Option<&str>) -> Result<ActionResponse, String> {
let active = assert_safe_path(home, workspace_id, session_id)?; let active = assert_safe_path(home, workspace_id, session_id)?;
let archived = sessions_root(home).join(".kcd-archive").join(workspace_id).join(session_id); let archived = sessions_root(home).join(".kcd-archive").join(workspace_id).join(session_id);
// Metadata-archived sessions still live at the active root, so a
// status_hint of "archived" with no legacy .kcd-archive copy falls through
// to the default arm and removes the in-place directory.
let target = match status_hint { let target = match status_hint {
Some("archived") if archived.exists() => archived, Some("archived") if archived.exists() => archived,
Some("active") if active.exists() => active, Some("active") if active.exists() => active,
@@ -1440,11 +1525,11 @@ fn delete_workspace_cmd(home: &Path, workspace_id: &str, _confirm: bool, _force:
let arch_dir = root.join(".kcd-archive").join(workspace_id); let arch_dir = root.join(".kcd-archive").join(workspace_id);
if active_dir.exists() { if active_dir.exists() {
let active_list = list_sessions_in_dir(&active_dir, workspace_id, "active"); let active_list = list_sessions_in_dir(&active_dir, workspace_id, false);
if !active_list.is_empty() { return Err("workspace is not empty; archive/delete sessions first".into()); } if !active_list.is_empty() { return Err("workspace is not empty; archive/delete sessions first".into()); }
} }
if arch_dir.exists() { if arch_dir.exists() {
let arch_list = list_sessions_in_dir(&arch_dir, workspace_id, "archived"); let arch_list = list_sessions_in_dir(&arch_dir, workspace_id, true);
if !arch_list.is_empty() { return Err("workspace is not empty; archive/delete sessions first".into()); } if !arch_list.is_empty() { return Err("workspace is not empty; archive/delete sessions first".into()); }
} }
@@ -1546,6 +1631,10 @@ fn get_session_preview_cmd(home: &Path, workspace_id: &str, session_id: &str, st
let archived = root.join(".kcd-archive").join(workspace_id).join(session_id); let archived = root.join(".kcd-archive").join(workspace_id).join(session_id);
let session_dir = match status_hint { let session_dir = match status_hint {
Some("archived") if archived.exists() => archived, Some("archived") if archived.exists() => archived,
// Metadata-archived sessions stay at the active root (kimi-code v2
// writes state.json.archived, no physical move) — read them in place.
Some("archived") if active.exists() => active,
Some("active") if active.exists() => active,
_ => if active.exists() { active } else if archived.exists() { archived } _ => if active.exists() { active } else if archived.exists() { archived }
else { return Err("session not found".into()); } else { return Err("session not found".into()); }
}; };
@@ -1666,7 +1755,11 @@ fn get_session_preview_cmd(home: &Path, workspace_id: &str, session_id: &str, st
Ok(PreviewResult { Ok(PreviewResult {
workspace_id: workspace_id.to_string(), workspace_id: workspace_id.to_string(),
session_id: session_id.to_string(), session_id: session_id.to_string(),
status: if session_dir.to_string_lossy().contains(".kcd-archive") { "archived".into() } else { "active".into() }, status: if session_dir.to_string_lossy().contains(".kcd-archive") || read_state_archived(&session_dir) {
"archived".into()
} else {
"active".into()
},
title, work_dir, created_at, updated_at, title, work_dir, created_at, updated_at,
message_count: messages.len(), message_count: messages.len(),
truncated, truncated,
@@ -1998,3 +2091,219 @@ mod pricing_tests {
assert_eq!(ch, 0.11); assert_eq!(ch, 0.11);
} }
} }
#[cfg(test)]
mod session_tests {
use super::*;
const WID: &str = "wd_proj_a1b2c3d4e5f6";
const SID: &str = "session_11111111-2222-3333-4444-555555555555";
fn setup_home() -> (tempfile::TempDir, PathBuf) {
let td = tempfile::tempdir().unwrap();
let home = td.path().join("home");
fs::create_dir_all(home.join("sessions").join(WID)).unwrap();
(td, home)
}
fn active_dir(home: &Path) -> PathBuf {
home.join("sessions").join(WID)
}
fn legacy_arch_dir(home: &Path) -> PathBuf {
home.join("sessions").join(".kcd-archive").join(WID)
}
fn write_state(session_dir: &Path, archived: bool, archived_at: Option<u64>) {
let mut obj = serde_json::json!({
"id": SID,
"version": 2,
"cwd": "/tmp/work",
"createdAt": 1_700_000_000_000u64,
"updatedAt": 1_700_000_000_000u64,
"archived": archived,
"title": "test session",
});
let o = obj.as_object_mut().unwrap();
match archived_at {
Some(at) => { o.insert("archivedAt".into(), serde_json::json!(at)); }
None => { o.remove("archivedAt"); }
}
fs::write(session_dir.join("state.json"), serde_json::to_string(&obj).unwrap()).unwrap();
}
fn read_state_json(session_dir: &Path) -> serde_json::Value {
serde_json::from_str(&fs::read_to_string(session_dir.join("state.json")).unwrap()).unwrap()
}
#[test]
fn metadata_archive_roundtrip() {
let (_td, home) = setup_home();
let dir = active_dir(&home).join(SID);
fs::create_dir_all(&dir).unwrap();
write_state(&dir, false, None);
// Fresh session lists as active.
let res = list_sessions_cmd(&home, "all", None);
assert_eq!(res.sessions.len(), 1);
assert_eq!(res.sessions[0].status, "active");
assert_eq!(res.workspaces[0].active_count, 1);
assert_eq!(res.workspaces[0].archived_count, 0);
// Archive is metadata-only: dir stays put, flag + epoch-ms archivedAt written.
let ar = archive_session_cmd(&home, WID, SID).unwrap();
assert_eq!(ar.status.as_deref(), Some("archived"));
assert!(dir.exists(), "metadata archive must not move the session dir");
assert!(!legacy_arch_dir(&home).join(SID).exists(), "nothing moves into .kcd-archive");
let st = read_state_json(&dir);
assert_eq!(st["archived"], true);
let archived_at = st["archivedAt"].as_u64().expect("archivedAt as epoch ms");
assert!(archived_at > 0, "archivedAt is a Date.now()-style epoch-ms number");
// List reflects the new status.
let res = list_sessions_cmd(&home, "all", None);
assert_eq!(res.sessions[0].status, "archived");
assert_eq!(res.workspaces[0].active_count, 0);
assert_eq!(res.workspaces[0].archived_count, 1);
assert!(list_sessions_cmd(&home, "active", None).sessions.is_empty());
assert_eq!(list_sessions_cmd(&home, "archived", None).sessions.len(), 1);
// Unarchive clears the flag in place (archived:false, archivedAt dropped).
let ur = unarchive_session_cmd(&home, WID, SID).unwrap();
assert_eq!(ur.status.as_deref(), Some("active"));
assert!(dir.exists(), "unarchive must not move the dir either");
let st = read_state_json(&dir);
assert_eq!(st["archived"], false);
assert!(st.get("archivedAt").is_none(), "archivedAt key removed on restore");
let res = list_sessions_cmd(&home, "all", None);
assert_eq!(res.sessions[0].status, "active");
assert_eq!(res.workspaces[0].active_count, 1);
assert_eq!(res.workspaces[0].archived_count, 0);
}
#[test]
fn metadata_archive_preserves_other_state_fields() {
let (_td, home) = setup_home();
let dir = active_dir(&home).join(SID);
fs::create_dir_all(&dir).unwrap();
let mut obj = serde_json::json!({
"id": SID,
"version": 2,
"cwd": "/repo",
"createdAt": 1,
"updatedAt": 2,
"archived": false,
"title": "keep me",
"custom": { "goal": "x" },
"agents": { "main": { "type": "main" } },
});
obj.as_object_mut().unwrap().insert("someFutureField".into(), serde_json::json!([1, 2, 3]));
fs::write(dir.join("state.json"), serde_json::to_string(&obj).unwrap()).unwrap();
archive_session_cmd(&home, WID, SID).unwrap();
unarchive_session_cmd(&home, WID, SID).unwrap();
let st = read_state_json(&dir);
assert_eq!(st["title"], "keep me");
assert_eq!(st["custom"]["goal"], "x");
assert_eq!(st["agents"]["main"]["type"], "main");
assert_eq!(st["someFutureField"][0], 1);
assert_eq!(st["updatedAt"], 2, "updatedAt untouched, like upstream touchUpdatedAt:false");
}
#[test]
fn cli_archived_session_in_active_root_lists_as_archived() {
// A session already carrying archived:true in state.json while still
// living at the active root (written by kimi-code CLI v2 itself) must
// surface in the archived filter / counts.
let (_td, home) = setup_home();
let dir = active_dir(&home).join(SID);
fs::create_dir_all(&dir).unwrap();
write_state(&dir, true, Some(1_700_000_000_000u64));
fs::write(dir.join("wire.jsonl"), "").unwrap();
let res = list_sessions_cmd(&home, "all", None);
assert_eq!(res.sessions.len(), 1);
assert_eq!(res.sessions[0].status, "archived");
assert_eq!(res.workspaces[0].active_count, 0);
assert_eq!(res.workspaces[0].archived_count, 1);
assert!(list_sessions_cmd(&home, "active", None).sessions.is_empty());
assert_eq!(list_sessions_cmd(&home, "archived", None).sessions.len(), 1);
// Preview resolves it in place with status "archived".
let pv = get_session_preview_cmd(&home, WID, SID, Some("archived")).unwrap();
assert_eq!(pv.status, "archived");
}
#[test]
fn legacy_kcd_archive_dir_still_listed_and_restored() {
let (_td, home) = setup_home();
let arch_dir = legacy_arch_dir(&home).join(SID);
fs::create_dir_all(&arch_dir).unwrap();
write_state(&arch_dir, false, None);
// Legacy physical archive still shows as archived.
let res = list_sessions_cmd(&home, "all", None);
assert_eq!(res.sessions.len(), 1);
assert_eq!(res.sessions[0].status, "archived");
assert_eq!(res.workspaces[0].active_count, 0);
assert_eq!(res.workspaces[0].archived_count, 1);
assert!(list_sessions_cmd(&home, "active", None).sessions.is_empty());
// Unarchive moves it back and clears the flag.
let ur = unarchive_session_cmd(&home, WID, SID).unwrap();
assert_eq!(ur.status.as_deref(), Some("active"));
assert!(!arch_dir.exists(), "legacy .kcd-archive copy moved back");
let restored = active_dir(&home).join(SID);
assert!(restored.exists());
let st = read_state_json(&restored);
assert_eq!(st["archived"], false);
assert!(st.get("archivedAt").is_none());
let res = list_sessions_cmd(&home, "all", None);
assert_eq!(res.sessions[0].status, "active");
assert_eq!(res.workspaces[0].active_count, 1);
assert_eq!(res.workspaces[0].archived_count, 0);
}
#[test]
fn corrupt_state_json_archive_errors_without_panic() {
let (_td, home) = setup_home();
let dir = active_dir(&home).join(SID);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("state.json"), "{ this is not json").unwrap();
let err = archive_session_cmd(&home, WID, SID).unwrap_err();
assert!(err.contains("state.json"), "clear error mentioning state.json, got: {err}");
assert!(dir.exists(), "failed archive leaves the session dir in place");
assert_eq!(fs::read_to_string(dir.join("state.json")).unwrap(), "{ this is not json");
}
#[test]
fn missing_state_json_archive_errors_without_panic() {
let (_td, home) = setup_home();
let dir = active_dir(&home).join(SID);
fs::create_dir_all(&dir).unwrap();
let err = archive_session_cmd(&home, WID, SID).unwrap_err();
assert!(err.contains("no state.json"), "clear error, got: {err}");
assert!(dir.exists());
}
#[test]
fn delete_session_covers_metadata_archived() {
// Status hint "archived" without a legacy .kcd-archive copy falls back
// to removing the metadata-archived in-place directory.
let (_td, home) = setup_home();
let dir = active_dir(&home).join(SID);
fs::create_dir_all(&dir).unwrap();
write_state(&dir, true, Some(1_700_000_000_000u64));
let del = delete_session_cmd(&home, WID, SID, Some("archived")).unwrap();
assert_eq!(del.deleted, Some(true));
assert!(!dir.exists(), "metadata-archived session deleted in place");
assert!(!legacy_arch_dir(&home).join(SID).exists());
assert!(list_sessions_cmd(&home, "all", None).sessions.is_empty());
}
}
+73 -10
View File
@@ -97,6 +97,11 @@ pub fn detect_provider(base_url: &str) -> Vec<UsageKind> {
if url.contains("openrouter.ai") { if url.contains("openrouter.ai") {
kinds.push(UsageKind::BalanceOpenrouter); kinds.push(UsageKind::BalanceOpenrouter);
} }
// codingplan.site 检测为 NewAPI 实例(本工具仅支持 NewAPI 查询);
// 家族匹配意味着 ai. 子域也自然命中该规则。
if url.contains("codingplan.site") {
kinds.push(UsageKind::BalanceNewapi);
}
if url.contains("api.stepfun.com") { if url.contains("api.stepfun.com") {
kinds.push(UsageKind::BalanceStepfun); kinds.push(UsageKind::BalanceStepfun);
} }
@@ -126,6 +131,19 @@ pub fn detect_provider(base_url: &str) -> Vec<UsageKind> {
kinds 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` 用于消歧同一家供应商的 /// 按 kind 路由到对应查询实现。`base_url` 用于消歧同一家供应商的
/// 国内/海外站(SiliconFlow .cn/.com、MiniMax .com/.io、智谱 bigmodel/z.ai)。 /// 国内/海外站(SiliconFlow .cn/.com、MiniMax .com/.io、智谱 bigmodel/z.ai)。
/// `usage_config` 仅 BalanceNewapi 分支读取(access_token / user_id), /// `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 balance::query_kimi(api_key, !lower.contains("moonshot.ai"), timeout).await
} }
UsageKind::BalanceNewapi => { UsageKind::BalanceNewapi => {
let (token, uid) = usage_config // 缺凭据是确定性配置错误 → Ok(failure)(前端按前缀本地化提示),
.and_then(|c| { // 而非 Err(Err 语义 = 瞬时网络错误,见 commands.rs newapi 分支)。
c.access_token let Some((token, uid)) = newapi_creds(usage_config) else {
.as_deref() return Ok(UsageResult::failure(
.zip(c.user_id.as_deref()) "newapi template requires accessToken and userId".to_string(),
.filter(|(t, u)| !t.is_empty() && !u.is_empty()) ));
}) };
.ok_or_else(|| {
"newapi template requires accessToken and userId".to_string()
})?;
let url = usage_config let url = usage_config
.and_then(|c| c.base_url.as_deref()) .and_then(|c| c.base_url.as_deref())
.filter(|s| !s.is_empty()) .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] #[test]
fn usage_kind_string_roundtrip() { fn usage_kind_string_roundtrip() {
use std::str::FromStr; use std::str::FromStr;
@@ -272,4 +309,30 @@ mod tests {
assert!(UsageKind::from_str("balance:unknown").is_err()); assert!(UsageKind::from_str("balance:unknown").is_err());
assert!(UsageKind::from_str("").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", "productName": "Kimi Switch",
"version": "0.7.10", "version": "0.7.14",
"identifier": "com.kimiswitch.app", "identifier": "com.kimiswitch.app",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
+48 -5
View File
@@ -1,7 +1,14 @@
import { useEffect, useState } from "react";
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 { getAgentSettings, setAgentSettings } from "../lib/agent-settings"; import { getAgentSettings, setAgentSettings } from "../lib/agent-settings";
import type { AgentSettings, Hook, PermissionRule } from "../types"; import type {
AgentSettings,
ExperimentalEnvStatus,
Hook,
PermissionRule,
} from "../types";
import { Card, Checkbox, NumberField, Segmented } from "./ui/controls"; import { Card, Checkbox, NumberField, Segmented } from "./ui/controls";
interface AgentSettingsPanelProps { interface AgentSettingsPanelProps {
@@ -9,12 +16,12 @@ interface AgentSettingsPanelProps {
onChange: (nextRawOther: unknown) => void; onChange: (nextRawOther: unknown) => void;
} }
const THINKING_LEVELS = ["low", "medium", "high", "max"] as const; // Upstream removed the "max" effort tier (auto-migrates to "high").
const THINKING_LEVELS = ["low", "medium", "high"] as const;
const THINKING_LABELS: Record<(typeof THINKING_LEVELS)[number], TranslationKey> = { const THINKING_LABELS: Record<(typeof THINKING_LEVELS)[number], TranslationKey> = {
low: "thinkingLow", low: "thinkingLow",
medium: "thinkingMedium", medium: "thinkingMedium",
high: "thinkingHigh", high: "thinkingHigh",
max: "thinkingMax",
}; };
const PERMISSION_DECISIONS = ["allow", "deny", "ask"] as const; const PERMISSION_DECISIONS = ["allow", "deny", "ask"] as const;
@@ -40,9 +47,23 @@ const COMMON_EVENTS = [
export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelProps) { export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const settings = getAgentSettings(rawOther); const settings = getAgentSettings(rawOther);
/**
* KIMI_CODE_LEGACY_FLAG=1 keeps the v1 loop_control keys dual-written for
* v1-engine users; the default (v2) writes only max_attempts_per_step.
*/
const [legacyV1, setLegacyV1] = useState(false);
useEffect(() => {
invoke<ExperimentalEnvStatus>("get_experimental_env_status")
.then((env) => {
const value = env?.KIMI_CODE_LEGACY_FLAG ?? "";
setLegacyV1(["1", "true", "yes", "on"].includes(value.trim().toLowerCase()));
})
.catch(() => setLegacyV1(false));
}, []);
const update = (patch: Partial<AgentSettings>) => { const update = (patch: Partial<AgentSettings>) => {
onChange(setAgentSettings(rawOther, patch)); onChange(setAgentSettings(rawOther, patch, { legacyV1 }));
}; };
const updateThinking = (patch: Partial<AgentSettings["thinking"]>) => { const updateThinking = (patch: Partial<AgentSettings["thinking"]>) => {
@@ -95,7 +116,7 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
label={t("thinkingKeep")} label={t("thinkingKeep")}
checked={settings.thinking?.keep === "all"} checked={settings.thinking?.keep === "all"}
disabled={!thinkingEnabled} disabled={!thinkingEnabled}
onChange={(checked) => updateThinking({ keep: checked ? "all" : false })} onChange={(checked) => updateThinking({ keep: checked ? "all" : "off" })}
/> />
<p className="text-xs text-content-muted">{t("thinkingContextHint")}</p> <p className="text-xs text-content-muted">{t("thinkingContextHint")}</p>
</Card> </Card>
@@ -136,6 +157,28 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
</Card> </Card>
<Card title={t("permissionRules")}> <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"> <div className="space-y-2">
{(settings.permission?.rules ?? []).map((rule, idx) => ( {(settings.permission?.rules ?? []).map((rule, idx) => (
<div key={idx} className="flex items-center gap-2"> <div key={idx} className="flex items-center gap-2">
+2 -1
View File
@@ -69,7 +69,8 @@ function ProviderCard({
agent, agent,
provider.name, provider.name,
provider.usageKinds, provider.usageKinds,
provider.usageConfig?.autoQueryIntervalMinutes provider.usageConfig?.autoQueryIntervalMinutes,
provider.usageConfig?.enabled === false
); );
const providerModels = Object.values(models).filter( const providerModels = Object.values(models).filter(
(m) => m.provider === provider.name (m) => m.provider === provider.name
+78 -23
View File
@@ -9,6 +9,7 @@ import {
forcedEnvValue, forcedEnvValue,
getExperimentalFlags, getExperimentalFlags,
getSubagentModelPool, getSubagentModelPool,
isExperimentalFlagSet,
isFlagLockedByEnv, isFlagLockedByEnv,
isMasterEnvOn, isMasterEnvOn,
removeSubagentPoolEntry, removeSubagentPoolEntry,
@@ -33,12 +34,12 @@ interface SubagentSettingsPageProps {
onBack: () => void; onBack: () => void;
} }
const EFFORTS = ["low", "medium", "high", "max"] as const; // Upstream removed the "max" effort tier (auto-migrates to "high").
const EFFORTS = ["low", "medium", "high"] as const;
const EFFORT_LABELS: Record<(typeof EFFORTS)[number], TranslationKey> = { const EFFORT_LABELS: Record<(typeof EFFORTS)[number], TranslationKey> = {
low: "thinkingLow", low: "thinkingLow",
medium: "thinkingMedium", medium: "thinkingMedium",
high: "thinkingHigh", high: "thinkingHigh",
max: "thinkingMax",
}; };
const FLAG_LABELS: Record<string, { name: TranslationKey; desc: TranslationKey }> = { const FLAG_LABELS: Record<string, { name: TranslationKey; desc: TranslationKey }> = {
@@ -55,6 +56,8 @@ const FLAG_LABELS: Record<string, { name: TranslationKey; desc: TranslationKey }
name: "flagAutoSessionTitle", name: "flagAutoSessionTitle",
desc: "flagAutoSessionTitleDesc", 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. */ /** Validation-error i18n key per engine rule, for the pre-write self-check. */
@@ -160,6 +163,8 @@ export function SubagentSettingsPage({
const [poolErrors, setPoolErrors] = useState<PoolValidationError[]>([]); const [poolErrors, setPoolErrors] = useState<PoolValidationError[]>([]);
/** Local draft for the "add pool entry" select (reset after each pick). */ /** Local draft for the "add pool entry" select (reset after each pick). */
const [addSelection, setAddSelection] = useState(""); const [addSelection, setAddSelection] = useState("");
/** WebUI-open button in flight; disables both buttons while non-null. */
const [webuiBusy, setWebuiBusy] = useState<"embedded" | "browser" | null>(null);
useEffect(() => { useEffect(() => {
invoke<ExperimentalEnvStatus>("get_experimental_env_status") invoke<ExperimentalEnvStatus>("get_experimental_env_status")
@@ -183,13 +188,18 @@ export function SubagentSettingsPage({
const secondaryFlag = EXPERIMENTAL_FLAGS[0]; // "secondary-model" const secondaryFlag = EXPERIMENTAL_FLAGS[0]; // "secondary-model"
const otherFlags = EXPERIMENTAL_FLAGS.slice(1); const otherFlags = EXPERIMENTAL_FLAGS.slice(1);
/** Effective state of the secondary-model flag (env overrides config). */ /** Effective state of the secondary-model flag. Mirrors the kimi-code
const secondaryEnabled = masterOn * 0.40.1 priority: single-flag env > explicit [experimental] value >
? true * master env (force-on only) > upstream default. The master env no
: isFlagLockedByEnv(env, secondaryFlag) * longer locks the toggle — only the flag's own env var does. */
? forcedEnvValue(env, secondaryFlag) const secondaryEnabled = isFlagLockedByEnv(env, secondaryFlag)
: flags["secondary-model"] === true; ? forcedEnvValue(env, secondaryFlag)
const secondaryLocked = masterOn || isFlagLockedByEnv(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(); const aliasList = Object.keys(models).sort();
@@ -235,7 +245,9 @@ export function SubagentSettingsPage({
? `${Math.round(n / 1000)}K` ? `${Math.round(n / 1000)}K`
: String(n); : String(n);
const effortLabel = (e: string): string => { const effortLabel = (e: string): string => {
const key = EFFORT_LABELS[e as (typeof EFFORTS)[number]]; // Stored "max" tiers from old configs are shown as "high" (upstream
// removed the tier; it auto-migrates to "high").
const key = EFFORT_LABELS[e === "max" ? "high" : (e as (typeof EFFORTS)[number])];
return key ? t(key) : e; return key ? t(key) : e;
}; };
@@ -296,16 +308,35 @@ export function SubagentSettingsPage({
const renderFlagRow = (flag: ExperimentalFlagDef) => { const renderFlagRow = (flag: ExperimentalFlagDef) => {
const labels = FLAG_LABELS[flag.id]; const labels = FLAG_LABELS[flag.id];
const locked = masterOn || isFlagLockedByEnv(env, flag); // Only the flag's own env var locks the toggle. The master env force-ons
const on = masterOn // undefined flags but an explicit config entry outranks it, so the user
? true // can still flip values here while it is set.
: isFlagLockedByEnv(env, flag) const locked = isFlagLockedByEnv(env, flag);
? forcedEnvValue(env, flag) // Resolution order mirrors kimi-code 0.40.1 flagService:
: flags[flag.id] === true; // single-flag env > explicit [experimental] value (true *or* false) >
// master env (on-only) > upstream default (`defaultEnabled`).
const explicitlySet = isExperimentalFlagSet(rawOther, flag.id);
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 ( return (
<div key={flag.id} className="flex items-center gap-3"> <div key={flag.id} className="flex items-center gap-3">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm text-content-primary">{t(labels.name)}</div> <div className="flex items-center gap-2 text-sm text-content-primary">
{t(labels.name)}
{!locked && !explicitlySet && flag.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"> <div className="text-xs text-content-muted">
{t(labels.desc)} {t(labels.desc)}
<code className="ml-2">{flag.envVar}</code> <code className="ml-2">{flag.envVar}</code>
@@ -321,7 +352,9 @@ export function SubagentSettingsPage({
disabled={locked} disabled={locked}
ariaLabel={t(labels.name)} ariaLabel={t(labels.name)}
onChange={(checked) => onChange={(checked) =>
onChange(setExperimentalFlag(rawOther, flag.id, checked)) onChange(
setExperimentalFlag(rawOther, flag.id, checked, { explicitFalse })
)
} }
/> />
</div> </div>
@@ -355,29 +388,37 @@ export function SubagentSettingsPage({
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
<button <button
type="button" type="button"
disabled={webuiBusy !== null}
onClick={async () => { onClick={async () => {
setWebuiBusy("embedded");
try { try {
await invoke("open_kimi_web_embedded"); await invoke("open_kimi_web_embedded");
} catch (err) { } catch (err) {
alert(err instanceof Error ? err.message : String(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>
<button <button
type="button" type="button"
disabled={webuiBusy !== null}
onClick={async () => { onClick={async () => {
setWebuiBusy("browser");
try { try {
await invoke("open_kimi_web"); await invoke("open_kimi_web");
} catch (err) { } catch (err) {
alert(err instanceof Error ? err.message : String(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> </button>
</div> </div>
</Card> </Card>
@@ -534,6 +575,16 @@ export function SubagentSettingsPage({
<div className="border-t border-border" /> <div className="border-t border-border" />
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex-1 min-w-0"> <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"> <div className="text-xs text-content-muted">
{t(FLAG_LABELS[secondaryFlag.id].desc)} {t(FLAG_LABELS[secondaryFlag.id].desc)}
<code className="ml-2">{secondaryFlag.envVar}</code> <code className="ml-2">{secondaryFlag.envVar}</code>
@@ -549,7 +600,11 @@ export function SubagentSettingsPage({
disabled={secondaryLocked} disabled={secondaryLocked}
ariaLabel={t(FLAG_LABELS[secondaryFlag.id].name)} ariaLabel={t(FLAG_LABELS[secondaryFlag.id].name)}
onChange={(checked) => onChange={(checked) =>
onChange(setExperimentalFlag(rawOther, secondaryFlag.id, checked)) onChange(
setExperimentalFlag(rawOther, secondaryFlag.id, checked, {
explicitFalse: masterOn || secondaryFlag.defaultEnabled === true,
})
)
} }
/> />
</div> </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 { ArrowLeft, Eye, EyeOff, Loader2, Play } from "lucide-react";
import { useTranslation } from "../i18n"; import { useTranslation } from "../i18n";
import type { TranslationKey } from "../i18n/zh"; 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"; import type { Agent, Provider, UsageConfig } from "../types";
interface UsageResult { interface UsageResult {
@@ -134,7 +134,7 @@ export function UsageConfigModal({ open, agent, provider, onClose, onSave }: Usa
.map((d) => { .map((d) => {
const parts: string[] = []; const parts: string[] = [];
if (d.planName) parts.push(planLabel(d.planName, t)); 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) if (d.used != null && d.total != null && d.total > 0)
parts.push(`${Math.round((d.used / d.total) * 100)}%`); parts.push(`${Math.round((d.used / d.total) * 100)}%`);
return parts.join(" "); return parts.join(" ");
+1 -12
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Loader2, RefreshCw } from "lucide-react"; import { Loader2, RefreshCw } from "lucide-react";
import { useTranslation } from "../i18n"; 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"; import type { UsageData, UsageQueryState } from "../hooks/useUsageQuery";
interface UsageFooterProps { interface UsageFooterProps {
@@ -36,17 +36,6 @@ export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) {
return null; 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 => { const formatReset = (resetsAt?: string | null): string | null => {
if (!resetsAt) return null; if (!resetsAt) return null;
const ms = new Date(resetsAt).getTime() - now; const ms = new Date(resetsAt).getTime() - now;
+2
View File
@@ -14,6 +14,7 @@ export type UsageKind =
| "balance:stepfun" | "balance:stepfun"
| "balance:novita" | "balance:novita"
| "balance:kimi" | "balance:kimi"
| "balance:newapi"
| "plan:kimi_coding" | "plan:kimi_coding"
| "plan:zhipu" | "plan:zhipu"
| "plan:minimax" | "plan:minimax"
@@ -30,6 +31,7 @@ export const SUPPORTED_USAGE_KINDS: ReadonlySet<string> = new Set<UsageKind>([
"balance:stepfun", "balance:stepfun",
"balance:novita", "balance:novita",
"balance:kimi", "balance:kimi",
"balance:newapi",
"plan:kimi_coding", "plan:kimi_coding",
"plan:zhipu", "plan:zhipu",
"plan:minimax", "plan:minimax",
+18 -6
View File
@@ -62,7 +62,8 @@ export function useUsageQuery(
agent: Agent, agent: Agent,
providerName: string, providerName: string,
usageKinds?: string[], usageKinds?: string[],
autoIntervalMinutes?: number autoIntervalMinutes?: number,
disabled?: boolean
): UsageQueryState { ): UsageQueryState {
const supported = (usageKinds?.length ?? 0) > 0; const supported = (usageKinds?.length ?? 0) > 0;
// Cache key includes the agent so a Kimi Code provider and a Pi provider // Cache key includes the agent so a Kimi Code provider and a Pi provider
@@ -79,12 +80,13 @@ export function useUsageQuery(
const runQuery = useCallback( const runQuery = useCallback(
async (forceRefresh: boolean) => { async (forceRefresh: boolean) => {
if (disabled) return;
const gen = ++genRef.current; const gen = ++genRef.current;
setStatus("loading"); setStatus("loading");
setError(undefined); setError(undefined);
const finish = (entry: CacheEntry) => { const finish = (entry: CacheEntry) => {
cache.set(cacheKey, entry);
if (genRef.current !== gen) return; if (genRef.current !== gen) return;
cache.set(cacheKey, entry);
setStatus(entry.status); setStatus(entry.status);
setData(entry.data); setData(entry.data);
setError(entry.error); setError(entry.error);
@@ -129,11 +131,21 @@ export function useUsageQuery(
}); });
} }
}, },
[agent, providerName, cacheKey] [agent, providerName, cacheKey, disabled]
); );
// On mount / provider change: serve fresh cache, otherwise query once. // On mount / provider change: serve fresh cache, otherwise query once.
useEffect(() => { useEffect(() => {
if (disabled) {
// Toggle back off: stop immediately and return to idle so the footer
// hides; bump gen so an in-flight response is dropped (no stale write).
genRef.current += 1;
setStatus("idle");
setData([]);
setError(undefined);
setUpdatedAt(null);
return;
}
if (!supported) return; if (!supported) return;
const cached = cache.get(cacheKey); const cached = cache.get(cacheKey);
if (cached) { if (cached) {
@@ -144,7 +156,7 @@ export function useUsageQuery(
if (Date.now() - cached.updatedAt < STALE_TTL_MS) return; if (Date.now() - cached.updatedAt < STALE_TTL_MS) return;
} }
void runQuery(false); void runQuery(false);
}, [supported, cacheKey, runQuery]); }, [supported, cacheKey, runQuery, disabled]);
// Ignore late responses after unmount. // Ignore late responses after unmount.
useEffect(() => { useEffect(() => {
@@ -156,12 +168,12 @@ export function useUsageQuery(
// Auto query interval — the hook is called once per provider card and both // Auto query interval — the hook is called once per provider card and both
// variants read the same state, so there is no second mount to double-fire. // variants read the same state, so there is no second mount to double-fire.
useEffect(() => { useEffect(() => {
if (!supported) return; if (!supported || disabled) return;
const mins = autoIntervalMinutes ?? 0; const mins = autoIntervalMinutes ?? 0;
if (mins <= 0) return; if (mins <= 0) return;
const id = setInterval(() => void runQuery(false), mins * 60_000); const id = setInterval(() => void runQuery(false), mins * 60_000);
return () => clearInterval(id); return () => clearInterval(id);
}, [supported, autoIntervalMinutes, runQuery]); }, [supported, autoIntervalMinutes, runQuery, disabled]);
return { return {
status, status,
+11 -2
View File
@@ -138,7 +138,6 @@ export const enTranslations: Record<TranslationKey, string> = {
thinkingLow: "Low", thinkingLow: "Low",
thinkingMedium: "Medium", thinkingMedium: "Medium",
thinkingHigh: "High", thinkingHigh: "High",
thinkingMax: "Max",
thinkingContextHint: "Thinking uses more context. Ensure the model context length and reserved size are sufficient.", thinkingContextHint: "Thinking uses more context. Ensure the model context length and reserved size are sufficient.",
loopControlSettings: "Loop Control", loopControlSettings: "Loop Control",
maxAttemptsPerStep: "Max attempts per step", maxAttemptsPerStep: "Max attempts per step",
@@ -152,6 +151,9 @@ export const enTranslations: Record<TranslationKey, string> = {
permissionAllow: "Allow", permissionAllow: "Allow",
permissionDeny: "Deny", permissionDeny: "Deny",
permissionAsk: "Ask", 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", addRule: "+ Add rule",
addCommonRules: "Add common rules", addCommonRules: "Add common rules",
hooks: "Lifecycle Hooks", hooks: "Lifecycle Hooks",
@@ -476,8 +478,14 @@ export const enTranslations: Record<TranslationKey, string> = {
flagWaitForDesc: "The agent can wait for background tasks to finish within the current turn", flagWaitForDesc: "The agent can wait for background tasks to finish within the current turn",
flagAutoSessionTitle: "Auto Session Title", flagAutoSessionTitle: "Auto Session Title",
flagAutoSessionTitleDesc: "Generate session titles automatically via the managed chat_title tool", 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", 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)", secondaryModelSection: "Subagent Model (Secondary Model)",
secondaryModelDisabledHint: secondaryModelDisabledHint:
"The experimental flag \"Subagent Secondary Model\" is off and this config will not take effect — enable the toggle above first", "The experimental flag \"Subagent Secondary Model\" is off and this config will not take effect — enable the toggle above first",
@@ -526,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.", "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", openWebUIEmbedded: "Open in App",
openWebUIBrowser: "Open in Browser", openWebUIBrowser: "Open in Browser",
webuiOpening: "Opening...",
// Plugin marketplace // Plugin marketplace
pluginMarketplace: "Plugin Marketplace", pluginMarketplace: "Plugin Marketplace",
+11 -2
View File
@@ -136,7 +136,6 @@ export const zhTranslations = {
thinkingLow: "低", thinkingLow: "低",
thinkingMedium: "中", thinkingMedium: "中",
thinkingHigh: "高", thinkingHigh: "高",
thinkingMax: "最大",
thinkingContextHint: "启用思考会占用更多上下文,请确保模型上下文长度和预留空间足够。", thinkingContextHint: "启用思考会占用更多上下文,请确保模型上下文长度和预留空间足够。",
loopControlSettings: "循环控制", loopControlSettings: "循环控制",
maxAttemptsPerStep: "单步最大尝试次数", maxAttemptsPerStep: "单步最大尝试次数",
@@ -150,6 +149,9 @@ export const zhTranslations = {
permissionAllow: "允许", permissionAllow: "允许",
permissionDeny: "拒绝", permissionDeny: "拒绝",
permissionAsk: "询问", permissionAsk: "询问",
permissionDangerousGuard: "危险命令防护",
permissionDangerousGuardDesc:
"开启(默认)时:Manual/YOLO 模式下,危险命令(rm -rf、shutdown、dd of= 等)和无法静态分析的命令会强制询问;自动(永不询问)模式自 kimi-code 0.41.0 起不再拦截。关闭后回归旧行为。环境变量 KIMI_CODE_DANGEROUS_COMMAND_GUARD 会覆盖本配置",
addRule: "+ 添加规则", addRule: "+ 添加规则",
addCommonRules: "添加常用规则", addCommonRules: "添加常用规则",
hooks: "生命周期钩子", hooks: "生命周期钩子",
@@ -471,8 +473,14 @@ export const zhTranslations = {
flagWaitForDesc: "代理可在当前回合内等待后台任务完成,而无需结束回合再被唤醒", flagWaitForDesc: "代理可在当前回合内等待后台任务完成,而无需结束回合再被唤醒",
flagAutoSessionTitle: "自动会话标题", flagAutoSessionTitle: "自动会话标题",
flagAutoSessionTitleDesc: "通过托管 chat_title 工具自动生成会话标题", flagAutoSessionTitleDesc: "通过托管 chat_title 工具自动生成会话标题",
flagRemoteControl: "远程控制",
flagRemoteControlDesc: "启用实验性远程控制功能",
flagSearchWorker: "搜索 Worker",
flagSearchWorkerDesc: "kap-server 全局搜索的后台 worker 宿主服务",
flagDefaultOn: "默认开启",
lockedByEnv: "被环境变量锁定", lockedByEnv: "被环境变量锁定",
masterEnvOnHint: "KIMI_CODE_EXPERIMENTAL_FLAG 已设置:全部实验功能被环境变量强制开启", masterEnvOnHint:
"KIMI_CODE_EXPERIMENTAL_FLAG 已设置:未显式配置的实验项将被强制开启;config.toml 中的显式配置优先生效",
secondaryModelSection: "子代理模型(次主力模型)", secondaryModelSection: "子代理模型(次主力模型)",
secondaryModelDisabledHint: secondaryModelDisabledHint:
"实验开关「子代理次主力模型」未启用,本配置不会生效——请先在上方打开对应开关", "实验开关「子代理次主力模型」未启用,本配置不会生效——请先在上方打开对应开关",
@@ -518,6 +526,7 @@ export const zhTranslations = {
"以独立窗口在应用内打开新版 Web 界面,也可以选择在系统浏览器打开(kimi-code 0.33+ 内置 code-app;需要 kimi 命令在 PATH 中)。", "以独立窗口在应用内打开新版 Web 界面,也可以选择在系统浏览器打开(kimi-code 0.33+ 内置 code-app;需要 kimi 命令在 PATH 中)。",
openWebUIEmbedded: "在应用内打开", openWebUIEmbedded: "在应用内打开",
openWebUIBrowser: "在浏览器打开", openWebUIBrowser: "在浏览器打开",
webuiOpening: "打开中...",
// Plugin marketplace // Plugin marketplace
pluginMarketplace: "插件市场", pluginMarketplace: "插件市场",
+206
View File
@@ -0,0 +1,206 @@
import { describe, expect, it } from "vitest";
import { getAgentSettings, setAgentSettings } from "./agent-settings";
// ---------------------------------------------------------------------------
// Helpers — drill into the written `[thinking]` / `[loop_control]` sections
// ---------------------------------------------------------------------------
function thinkingOf(raw: unknown): Record<string, unknown> {
const t = (raw as { thinking?: unknown })?.thinking;
return t && typeof t === "object" && !Array.isArray(t)
? (t as Record<string, unknown>)
: {};
}
function loopOf(raw: unknown): Record<string, unknown> {
const l = (raw as { loop_control?: unknown })?.loop_control;
return l && typeof l === "object" && !Array.isArray(l)
? (l as Record<string, unknown>)
: {};
}
// ---------------------------------------------------------------------------
// Reading — legacy off values normalized to "off", "max" → "high"
// ---------------------------------------------------------------------------
describe("getAgentSettings — thinking.keep normalization", () => {
it("normalizes a legacy boolean `false` to \"off\"", () => {
expect(getAgentSettings({ thinking: { keep: false } }).thinking?.keep).toBe(
"off"
);
});
it("normalizes the other legacy off values (0, null, \"no\", \"none\") to \"off\"", () => {
for (const v of [0, null, "no", "none"]) {
expect(
getAgentSettings({ thinking: { keep: v } }).thinking?.keep
).toBe("off");
}
});
it("keeps \"all\" and \"off\" as-is on read", () => {
expect(getAgentSettings({ thinking: { keep: "all" } }).thinking?.keep).toBe(
"all"
);
expect(getAgentSettings({ thinking: { keep: "off" } }).thinking?.keep).toBe(
"off"
);
});
it("keeps the default \"all\" when the key is absent", () => {
expect(getAgentSettings({}).thinking?.keep).toBe("all");
});
});
describe("getAgentSettings — effort \"max\" read mapping", () => {
it("normalizes a stored \"max\" to \"high\" on read", () => {
expect(getAgentSettings({ thinking: { effort: "max" } }).thinking?.effort).toBe(
"high"
);
});
});
// ---------------------------------------------------------------------------
// Writing — keep: "all" round-trips, "off" is written for legacy off values,
// absent keep is not materialized
// ---------------------------------------------------------------------------
describe("setAgentSettings — keep serialization", () => {
it("round-trips keep = \"all\"", () => {
const raw = { thinking: { keep: "all" } };
const next = setAgentSettings(raw, { thinking: { keep: "all" } });
expect(thinkingOf(next).keep).toBe("all");
});
it("writes \"off\" (string) for a legacy keep = false, never a boolean", () => {
const next = setAgentSettings({ thinking: { keep: false } }, {});
expect(thinkingOf(next).keep).toBe("off");
});
it("does not write the keep key when it was never set (absent in raw and patch)", () => {
expect(thinkingOf(setAgentSettings({}, {}))).not.toHaveProperty("keep");
const next = setAgentSettings(
{ thinking: { enabled: true } },
{ thinking: { effort: "high" } }
);
expect(thinkingOf(next)).not.toHaveProperty("keep");
expect(thinkingOf(next).effort).toBe("high");
});
it("writes the key when an absent raw config's keep is explicitly toggled off", () => {
const next = setAgentSettings({}, { thinking: { keep: "off" } });
expect(thinkingOf(next).keep).toBe("off");
});
});
// ---------------------------------------------------------------------------
// loop_control — v2 default writes only max_attempts_per_step; legacyV1 opts
// dual-writes; read fallback from the v1 key is preserved
// ---------------------------------------------------------------------------
describe("loop_control — v1/v2 key handling", () => {
const raw = { loop_control: { max_attempts_per_step: 5, max_retries_per_step: 5 } };
it("default (v2): a save strips the legacy max_retries_per_step key", () => {
const next = setAgentSettings(raw, { loop_control: { reserved_context_size: 60000 } });
expect(loopOf(next).max_attempts_per_step).toBe(5);
expect(loopOf(next)).not.toHaveProperty("max_retries_per_step");
});
it("default (v2): a dual-write patch ends up with only the v2 key", () => {
const next = setAgentSettings(
raw,
{ loop_control: { max_attempts_per_step: 7, max_retries_per_step: 7 } }
);
expect(loopOf(next).max_attempts_per_step).toBe(7);
expect(loopOf(next)).not.toHaveProperty("max_retries_per_step");
});
it("legacyV1: both keys are written", () => {
const next = setAgentSettings(
raw,
{ loop_control: { max_attempts_per_step: 7, max_retries_per_step: 7 } },
{ legacyV1: true }
);
expect(loopOf(next).max_attempts_per_step).toBe(7);
expect(loopOf(next).max_retries_per_step).toBe(7);
});
it("read fallback keeps a max_retries_per_step-only config editable", () => {
const s = getAgentSettings({ loop_control: { max_retries_per_step: 4 } });
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 });
});
});
+69 -13
View File
@@ -49,20 +49,43 @@ export function getAgentSettings(rawOther: unknown): AgentSettings {
) { ) {
loop.max_attempts_per_step = sectionLoop.max_retries_per_step; loop.max_attempts_per_step = sectionLoop.max_retries_per_step;
} }
const thinking = {
...DEFAULT_SETTINGS.thinking,
...getSection<AgentSettings["thinking"]>(rawOther, "thinking"),
};
// Upstream engines only accept string off values ("off"/"none"/"no"; see
// KEEP_OFF_VALUES) — a `false` from an old config fails the v2 validator
// and the v1 strict parse. Normalize every legacy off value to "off" for
// display; the serialization path (setAgentSettings) writes the string.
if (thinking.keep !== undefined && thinking.keep !== "all") {
thinking.keep = "off";
}
// The "max" effort tier was removed upstream (auto-migrates to "high");
// old configs still carrying it are shown as "high" (not rewritten on read).
if (thinking.effort === "max") {
thinking.effort = "high";
}
const sectionPermission = getSection<AgentSettings["permission"]>(
rawOther,
"permission"
);
return { return {
thinking: { thinking,
...DEFAULT_SETTINGS.thinking,
...getSection<AgentSettings["thinking"]>(rawOther, "thinking"),
},
loop_control: loop, loop_control: loop,
background: { background: {
...DEFAULT_SETTINGS.background, ...DEFAULT_SETTINGS.background,
...getSection<AgentSettings["background"]>(rawOther, "background"), ...getSection<AgentSettings["background"]>(rawOther, "background"),
}, },
permission: { permission: {
rules: rules: sectionPermission?.rules ?? [],
getSection<AgentSettings["permission"]>(rawOther, "permission")?.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") ?? [], hooks: getSection<AgentSettings["hooks"]>(rawOther, "hooks") ?? [],
}; };
@@ -70,25 +93,58 @@ export function getAgentSettings(rawOther: unknown): AgentSettings {
export function setAgentSettings( export function setAgentSettings(
rawOther: unknown, rawOther: unknown,
patch: Partial<AgentSettings> patch: Partial<AgentSettings>,
opts?: { legacyV1?: boolean }
): unknown { ): unknown {
const root = { ...asRecord(rawOther) }; const root = { ...asRecord(rawOther) };
const current = getAgentSettings(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 = { const next: AgentSettings = {
thinking: { ...current.thinking, ...patch.thinking }, thinking: { ...current.thinking, ...patch.thinking },
loop_control: { ...current.loop_control, ...patch.loop_control }, loop_control: { ...current.loop_control, ...patch.loop_control },
background: { ...current.background, ...patch.background }, background: { ...current.background, ...patch.background },
permission: { permission,
rules: patch.permission?.rules ?? current.permission?.rules ?? [],
},
hooks: patch.hooks ?? current.hooks ?? [], hooks: patch.hooks ?? current.hooks ?? [],
}; };
// `keep` is only written when the source config explicitly carries it or the
// patch sets it — an absent key keeps the engine default ("all") instead of
// being materialized, so a plain save does not add the key.
const rawThinking = asRecord(getSection(rawOther, "thinking"));
const patchHasKeep = patch.thinking !== undefined && "keep" in patch.thinking;
if (!("keep" in rawThinking) && !patchHasKeep) {
delete next.thinking?.keep;
}
// The v2 engine (default) only reads max_attempts_per_step; the legacy v1
// key is stripped on save unless KIMI_CODE_LEGACY_FLAG=1 (v1 engine compat).
if (!opts?.legacyV1) {
delete next.loop_control?.max_retries_per_step;
}
if (next.thinking) root.thinking = next.thinking; if (next.thinking) root.thinking = next.thinking;
if (next.loop_control) root.loop_control = next.loop_control; if (next.loop_control) root.loop_control = next.loop_control;
if (next.background) root.background = next.background; if (next.background) root.background = next.background;
if (next.permission?.rules && next.permission.rules.length > 0) { // Keep `[permission]` when it carries rules *or* an explicit
root.permission = next.permission; // 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 { } else {
delete root.permission; delete root.permission;
} }
+98214 -94629
View File
File diff suppressed because it is too large. Load diff
+80576 -77470
View File
File diff suppressed because it is too large. Load diff
+101
View File
@@ -1,8 +1,12 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
EXPERIMENTAL_FLAGS,
getExperimentalFlags,
getSecondaryModel, getSecondaryModel,
getSubagentModelPool, getSubagentModelPool,
isExperimentalFlagSet,
removeSubagentPoolEntry, removeSubagentPoolEntry,
setExperimentalFlag,
setSecondaryModelOnly, setSecondaryModelOnly,
setSubagentDefault, setSubagentDefault,
setSubagentForce, setSubagentForce,
@@ -231,6 +235,31 @@ describe("pool mutations", () => {
expect(section(updated).default_model).toBe("kimi-k1"); expect(section(updated).default_model).toBe("kimi-k1");
}); });
it("upsertSubagentPoolEntry materializes the implicit default into a new table", () => {
// Implicit single-entry form (default_model set, no models table): adding a
// second alias must pull the default into the table, otherwise the result
// violates "default_model must be a pool key" (rule 3) and gets rejected.
const raw = rawWith({ default_model: "kimi-k1", force: false });
const added = upsertSubagentPoolEntry(raw, "kimi-k2", "");
expect(modelsOf(added)).toEqual({ "kimi-k1": "", "kimi-k2": "" });
expect(section(added).default_model).toBe("kimi-k1");
const pool = getSubagentModelPool(added);
expect(pool).toBeDefined();
expect(
validateSubagentPool(pool!, ["kimi-k1", "kimi-k2"]),
).toEqual([]);
});
it("upsertSubagentPoolEntry upgrades a legacy `model`-only section to a valid pool", () => {
const raw = rawWith({ model: "kimi-k1" });
const added = upsertSubagentPoolEntry(raw, "kimi-k2", "K2");
expect(modelsOf(added)).toEqual({ "kimi-k1": "", "kimi-k2": "K2" });
expect(section(added).default_model).toBe("kimi-k1");
expect(section(added).model).toBe("kimi-k1");
const pool = getSubagentModelPool(added);
expect(validateSubagentPool(pool!, ["kimi-k1", "kimi-k2"])).toEqual([]);
});
it("removeSubagentPoolEntry keeps the default when removing a non-default alias", () => { it("removeSubagentPoolEntry keeps the default when removing a non-default alias", () => {
const raw = rawWith({ const raw = rawWith({
default_model: "kimi-k1", default_model: "kimi-k1",
@@ -424,3 +453,75 @@ describe("validateSubagentPool", () => {
expect(errors).toContainEqual({ key: "forceExcludesModels" }); 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,
});
});
});
+67 -9
View File
@@ -28,32 +28,57 @@ export interface ExperimentalFlagDef {
id: string; id: string;
/** Single-feature env var that can force the flag (locks the UI toggle). */ /** Single-feature env var that can force the flag (locks the UI toggle). */
envVar: string; envVar: string;
/** Feature turned on by default upstream; an absent config key falls back
* to this, while an explicit `false` in `[experimental]` still wins. */
defaultEnabled?: boolean;
} }
/** Known experimental flags — mirrors the kimi-code v2 flag registry /** Known experimental flags — mirrors the kimi-code v2 flag registry
* (the per-feature flag.ts files under packages/agent-core-v2/src: * (the per-feature flag.ts files under packages/agent-core-v2/src:
* secondary-model, tool-select, persistence_minidb_readmodel, tower, * secondary-model, tool-select, persistence_minidb_readmodel, tower,
* subagent_fork, wait_for, auto_session_title). `acp-v2` was removed * subagent_fork, wait_for, auto_session_title, remote-control,
* upstream and is dropped here. */ * 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[] = [ 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: "tool-select", envVar: "KIMI_CODE_EXPERIMENTAL_TOOL_SELECT" },
{ {
id: "persistence_minidb_readmodel", id: "persistence_minidb_readmodel",
envVar: "KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL", envVar: "KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL",
defaultEnabled: true,
}, },
{ id: "tower", envVar: "KIMI_CODE_EXPERIMENTAL_TOWER" }, { id: "tower", envVar: "KIMI_CODE_EXPERIMENTAL_TOWER" },
{ id: "subagent_fork", envVar: "KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK" }, { id: "subagent_fork", envVar: "KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK" },
{ id: "wait_for", envVar: "KIMI_CODE_EXPERIMENTAL_WAIT_FOR" }, {
id: "wait_for",
envVar: "KIMI_CODE_EXPERIMENTAL_WAIT_FOR",
defaultEnabled: true,
},
{ {
id: "auto_session_title", id: "auto_session_title",
envVar: "KIMI_CODE_EXPERIMENTAL_AUTO_SESSION_TITLE", 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"; 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 { export function isMasterEnvOn(env: ExperimentalEnvStatus | null): boolean {
return isTruthyEnv(env?.[EXPERIMENTAL_MASTER_ENV]); return isTruthyEnv(env?.[EXPERIMENTAL_MASTER_ENV]);
} }
@@ -87,16 +112,33 @@ export function getExperimentalFlags(rawOther: unknown): Record<string, boolean>
return out; return out;
} }
/** Set one flag in `[experimental]`. Removing all flags drops the section. */ /** Whether a flag is explicitly written in `[experimental]` (true *or* false).
* An absent flag falls back to the upstream default (`defaultEnabled`). */
export function isExperimentalFlagSet(rawOther: unknown, id: string): boolean {
return Object.prototype.hasOwnProperty.call(
asRecord(asRecord(rawOther)["experimental"]),
id
);
}
/** 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( export function setExperimentalFlag(
rawOther: unknown, rawOther: unknown,
id: string, id: string,
enabled: boolean enabled: boolean,
opts?: { explicitFalse?: boolean }
): unknown { ): unknown {
const root = { ...asRecord(rawOther) }; const root = { ...asRecord(rawOther) };
const flags = { ...getExperimentalFlags(rawOther) }; const flags = { ...getExperimentalFlags(rawOther) };
if (enabled) { if (enabled) {
flags[id] = true; flags[id] = true;
} else if (opts?.explicitFalse) {
flags[id] = false;
} else { } else {
delete flags[id]; delete flags[id];
} }
@@ -256,8 +298,11 @@ export function setSubagentModelPool(
return root; return root;
} }
/** Add or update one alias in the pool `models` table. The rest of the /** Add or update one alias in the pool `models` table. When the section is
* section (default, force, unknown fields) is preserved untouched. */ * the implicit single-entry form (a `default_model`/`model` but no table),
* the effective default is materialized into the table first — otherwise the
* new table would fail the engine rule "default_model must be a pool key".
* The rest of the section (force, unknown fields) is preserved untouched. */
export function upsertSubagentPoolEntry( export function upsertSubagentPoolEntry(
rawOther: unknown, rawOther: unknown,
alias: string, alias: string,
@@ -269,8 +314,21 @@ export function upsertSubagentPoolEntry(
for (const [k, v] of Object.entries(section)) next[k] = v; for (const [k, v] of Object.entries(section)) next[k] = v;
const models: Record<string, unknown> = {}; const models: Record<string, unknown> = {};
for (const [k, v] of Object.entries(asRecord(next.models))) models[k] = v; for (const [k, v] of Object.entries(asRecord(next.models))) models[k] = v;
const effectiveDefault =
typeof next.default_model === "string" && next.default_model !== ""
? next.default_model
: typeof next.model === "string" && next.model !== ""
? next.model
: undefined;
if (effectiveDefault !== undefined && !(effectiveDefault in models)) {
models[effectiveDefault] = "";
}
models[alias] = description; models[alias] = description;
next.models = models; next.models = models;
if (effectiveDefault !== undefined) {
next.default_model = effectiveDefault;
next.model = effectiveDefault;
}
root.secondary_model = next; root.secondary_model = next;
return root; return root;
} }
+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/*. * src-tauri/src/commands.rs and src-tauri/src/services/*.
*/ */
export function localizeUsageError(error: string, t: TranslateFn): string { export function localizeUsageError(error: string, t: TranslateFn): string {
if (error.startsWith("unsupported provider")) return t("usageUnsupportedProvider"); // commands.rs 的 kinds 循环把单条错误包成 `{kind}: {msg}`(kind 含数字,
if (error.startsWith("no API key configured")) return t("usageErrNoKey"); // 如 plan:zhipu),先剥离再按前缀匹配;未知错误仍回退按原文展示(保留 kind
if (error.startsWith("usage query disabled")) return t("usageErrDisabled"); // 前缀,属诊断细节)。
if (/login expired|refresh failed/i.test(error)) return t("usageErrLoginExpired"); const stripped = error.replace(/^(balance|plan):[a-z0-9_]+:\s*/, "");
if (error.startsWith("no Kimi Code OAuth credentials")) return t("usageErrNoOauth"); if (stripped.startsWith("unsupported provider")) return t("usageUnsupportedProvider");
if (error.startsWith("newapi template requires")) return t("usageErrNewapiCreds"); if (stripped.startsWith("no API key configured")) return t("usageErrNoKey");
if (error.startsWith("Authentication failed")) return t("usageInvalidKey"); 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; 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}`;
}
+24 -2
View File
@@ -100,7 +100,18 @@ export interface DiscoveredModel {
export interface ThinkingConfig { export interface ThinkingConfig {
enabled?: boolean; enabled?: boolean;
/**
* Effort tier. `max` is read-compatible only — upstream removed the tier
* (old configs auto-migrate to `high`); the UI normalizes it and the
* serialization path never writes it.
*/
effort?: "low" | "medium" | "high" | "max"; effort?: "low" | "medium" | "high" | "max";
/**
* Keep thinking content. The legacy off values (`false`, `0`, "no", "none",
* `null`) are read-compatible — old configs may carry them; the UI
* normalizes them to `"off"` and the serialization path never emits a
* boolean (upstream engines only accept string off values).
*/
keep?: "all" | false | 0 | "no" | "off" | "none" | null; keep?: "all" | false | 0 | "no" | "off" | "none" | null;
} }
@@ -109,7 +120,8 @@ export interface LoopControlConfig {
max_attempts_per_step?: number; max_attempts_per_step?: number;
/** v2 engine key (kimi-code 0.33+): `max_steps_per_run` was renamed. */ /** v2 engine key (kimi-code 0.33+): `max_steps_per_run` was renamed. */
max_steps_per_turn?: number; max_steps_per_turn?: number;
/** Legacy v1 key — written in sync so KIMI_CODE_LEGACY_FLAG=1 still works. */ /** Legacy v1 key — read fallback only; written/kept only when
* KIMI_CODE_LEGACY_FLAG=1 (v1 engine compat). v2 saves strip it. */
max_retries_per_step?: number; max_retries_per_step?: number;
reserved_context_size?: number; reserved_context_size?: number;
} }
@@ -137,7 +149,17 @@ export interface AgentSettings {
thinking?: ThinkingConfig; thinking?: ThinkingConfig;
loop_control?: LoopControlConfig; loop_control?: LoopControlConfig;
background?: BackgroundConfig; 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[]; hooks?: Hook[];
} }
+10 -10
View File
@@ -6,10 +6,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <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="#efebe1" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#231f1a" media="(prefers-color-scheme: dark)" /> <meta name="theme-color" content="#231f1a" media="(prefers-color-scheme: dark)" />
<title>Kimi Switch — 多 LLM 供应商统一管理 · 用量监控 · 账单一目了然</title> <title>Kimi Switch — 多 LLM 供应商配置管理 · 用量与账单监控</title>
<meta <meta
name="description" 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 <meta
name="keywords" name="keywords"
@@ -18,20 +18,20 @@
<!-- Open Graph --> <!-- Open Graph -->
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:title" content="Kimi Switch — 多 LLM 供应商统一管理" /> <meta property="og:title" content="Kimi Switch — 多 LLM 供应商配置管理" />
<meta <meta
property="og:description" 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:url" content="https://billowliu2.github.io/KimiSwitch/" />
<meta property="og:image" content="https://billowliu2.github.io/KimiSwitch/og.png" /> <meta property="og:image" content="https://billowliu2.github.io/KimiSwitch/og.png" />
<!-- Twitter --> <!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" /> <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 <meta
name="twitter:description" 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" /> <meta name="twitter:image" content="https://billowliu2.github.io/KimiSwitch/og.png" />
@@ -49,10 +49,10 @@
"alternateName": ["KimiSwitch", "KimiCodeSwitch", "Kimi Switch", "KimiCode Switch"], "alternateName": ["KimiSwitch", "KimiCodeSwitch", "Kimi Switch", "KimiCode Switch"],
"url": "https://billowliu2.github.io/KimiSwitch/", "url": "https://billowliu2.github.io/KimiSwitch/",
"applicationCategory": "DeveloperApplication", "applicationCategory": "DeveloperApplication",
"operatingSystem": "Windows 10, Windows 11", "operatingSystem": "Windows 10, Windows 11, macOS, Linux",
"softwareVersion": "0.6.7", "softwareVersion": "0.7.12",
"dateModified": "2026-08-01", "dateModified": "2026-09-03",
"description": "Windows 桌面端多 LLM 供应商统一管理器,一键切换 Kimi / 智谱 GLM / MiniMax / DeepSeek / OpenRouter / 基元律动,用量账单实时监控,开源 MIT。", "description": "桌面端 LLM 供应商配置管理器:22 个预设选好即写入 config.toml,用量与账单直读各厂商官方接口。Tauri + Rust,开源 MIT。",
"downloadUrl": "https://github.com/billowliu2/KimiSwitch/releases", "downloadUrl": "https://github.com/billowliu2/KimiSwitch/releases",
"softwareHelp": "https://github.com/billowliu2/KimiSwitch", "softwareHelp": "https://github.com/billowliu2/KimiSwitch",
"license": "https://opensource.org/licenses/MIT", "license": "https://opensource.org/licenses/MIT",
+1 -1
View File
@@ -87,7 +87,7 @@ export default function Footer() {
href={link.url} href={link.url}
target="_blank" target="_blank"
rel="noreferrer" 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)" }} 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"> <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> <span>Kimi Switch</span>
</NavLink> </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) => ( {links.map((l) => (
<NavLink <NavLink
key={l.to} key={l.to}
+4 -2
View File
@@ -15,7 +15,7 @@ export default function SectionShell({ id, title, subtitle, children }: SectionS
ref={ref} ref={ref}
id={id} id={id}
aria-labelledby={`${id}-title`} 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 inView
? "motion-safe:opacity-100 motion-safe:translate-y-0" ? "motion-safe:opacity-100 motion-safe:translate-y-0"
: "motion-safe:opacity-0 motion-safe:translate-y-6" : "motion-safe:opacity-0 motion-safe:translate-y-6"
@@ -30,7 +30,9 @@ export default function SectionShell({ id, title, subtitle, children }: SectionS
{title} {title}
</h2> </h2>
{subtitle && ( {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> </div>
{children} {children}
+100 -78
View File
@@ -16,43 +16,43 @@ const zh = {
performance: "性能", performance: "性能",
changelog: "更新日志", changelog: "更新日志",
download: "下载", download: "下载",
downloadBtn: "下载 v0.7.4", downloadBtn: "下载 v0.7.12",
}, },
hero: { hero: {
badge: "v0.7.4 · 开源 MIT · Windows / macOS / Linux", badge: "v0.7.12 · 开源 MIT · Windows / macOS / Linux",
titleBefore: "统一管理你的", titleBefore: "统一管理你的",
titleAccent: "AI 供应商", titleAccent: "AI 供应商",
titleAfter: "", titleAfter: "",
subtitle: subtitle:
"一键切换 Kimi / 智谱 GLM / MiniMax / DeepSeek / OpenRouter / 基元律动,用量与账单一目了然。", "Kimi Code 的供应商配置不用再手改 config.toml:22 个预设选好即写入(写入前自动备份),用量与账单直读各厂商官方接口。",
download: "免费下载", download: "免费下载",
source: "查看源码", source: "查看源码",
stats: [ stats: [
{ value: "22", label: "预设供应商" }, { value: "22", label: "预设供应商" },
{ value: "5910", label: "模型价格库" }, { value: "7495", label: "模型价格库" },
{ value: "100%", label: "开源免费" }, { value: "MIT", label: "开源协议" },
], ],
}, },
features: { features: {
title: "核心功能", title: "核心功能",
subtitle: "从多供应商管理到用量监控,一站搞定。", subtitle: "配置、切换、用量、账单、会话——都落在本机那份 ~/.kimi-code/config.toml 上。",
items: [ items: [
{ {
id: "multi-provider", id: "multi-provider",
title: "多供应商管理", title: "多供应商管理",
desc: "Kimi / 智谱 GLM / MiniMax / DeepSeek / OpenRouter / 基元律动等 22 个预设,一键添加与切换。", desc: "Kimi / 智谱 GLM / MiniMax / DeepSeek / OpenRouter / 基元律动等 22 个预设,Base URL 与模型映射已填好,选定即写入配置。",
img: "screenshots/preset-picker.png", img: "screenshots/preset-picker.png",
}, },
{ {
id: "custom-provider", id: "custom-provider",
title: "自定义供应商", title: "自定义供应商",
desc: "自定义 Base URL / API Key / 模型映射,支持 JSON 高级编辑。", desc: "Base URL、API Key、模型别名逐字段编辑,也可切到 JSON 页签直接改;配置里的未知字段原样保留。",
img: "screenshots/edit-provider-basic-managed.png", img: "screenshots/edit-provider-basic-managed.png",
}, },
{ {
id: "usage", id: "usage",
title: "用量与账单监控", title: "用量与账单监控",
desc: "套餐类 5 小时 / 每周用量 + 按量余额,models.dev 真实价格计算成本,卡片直显。", desc: "套餐的 5 小时 / 每周额度与按量余额直接显示在卡片上,成本按 models.dev 牌价折算。",
img: "screenshots/dashboard-light.png", img: "screenshots/dashboard-light.png",
}, },
{ {
@@ -63,7 +63,7 @@ const zh = {
}, },
{ {
id: "kimi-auth", id: "kimi-auth",
title: "Kimi 一键授权登录", title: "Kimi 授权登录",
desc: "应用内置设备码授权(等同 kimi login),浏览器授权即用,15 分钟过期自动续期。", desc: "应用内置设备码授权(等同 kimi login),浏览器授权即用,15 分钟过期自动续期。",
img: "screenshots/kimi-oauth-dialog.png", img: "screenshots/kimi-oauth-dialog.png",
}, },
@@ -75,31 +75,31 @@ const zh = {
}, },
{ {
id: "model-discovery", id: "model-discovery",
title: "智能模型发现", title: "模型自动发现",
desc: "模型能力 / 上下文大小自动从 models.dev 同步,构建时自动拉取,5910 模型覆盖。", desc: "可用模型直接问供应商 API;显示名 / 上下文 / 能力取自 models.dev 快照(当前 7495 个模型、212 家供应商)。",
img: "screenshots/kimi-cli-select-model.png", img: "screenshots/kimi-cli-select-model.png",
}, },
{ {
id: "auto-update", id: "auto-update",
title: "自动更新", title: "自动更新",
desc: "自动检测新版本,一键下载安装,始终保持最新。", desc: "启动时和每 8 小时各查一次新版本(私有镜像优先、GitHub 兜底),应用内下载并引导安装。",
img: "screenshots/settings.png", img: "screenshots/settings.png",
}, },
], ],
}, },
showcase: { showcase: {
title: "界面演示", title: "界面演示",
subtitle: "真实界面截图,所见即所得。", subtitle: "以下截图取自 v0.7.12 实机运行。",
items: [ items: [
{ {
src: "screenshots/usage-config-page.png", src: "screenshots/usage-config-page.png",
title: "配置用量查询", title: "配置用量查询",
desc: "NewAPI 中转站模板、超时与自动刷新、测试查询一屏搞定", desc: "NewAPI 中转站模板、超时与自动刷新、测试查询放在同一页",
}, },
{ {
src: "screenshots/preset-picker.png", src: "screenshots/preset-picker.png",
title: "预设供应商", title: "预设供应商",
desc: "22 个预设开箱即用,一键添加常用厂商", desc: "22 个预设,勾选即可添加常用厂商",
}, },
{ {
src: "screenshots/kimi-oauth-dialog.png", src: "screenshots/kimi-oauth-dialog.png",
@@ -125,7 +125,7 @@ const zh = {
}, },
performance: { performance: {
title: "轻量原生", title: "轻量原生",
subtitle: "Tauri + Rust 内核,WebView2 前端,常驻托盘零负担。", subtitle: "Tauri + Rust,复用系统 WebView2,不打包 Chromium。下面三项是空闲状态实测。",
metrics: [ metrics: [
{ target: 93, suffix: " MB", label: "Working Set", desc: "程序空闲态工作集内存" }, { target: 93, suffix: " MB", label: "Working Set", desc: "程序空闲态工作集内存" },
{ target: 34, suffix: " MB", label: "Private WS", desc: "独占内存(WebView2 共享部分扣除)" }, { target: 34, suffix: " MB", label: "Private WS", desc: "独占内存(WebView2 共享部分扣除)" },
@@ -140,6 +140,17 @@ const zh = {
syncedNote: "数据已同步 GitHub Releases", syncedNote: "数据已同步 GitHub Releases",
fallbackNote: "内置版本记录", fallbackNote: "内置版本记录",
entries: [ 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", version: "v0.7.4",
date: "2026-08-09", date: "2026-08-09",
@@ -170,7 +181,7 @@ const zh = {
"兼容 kimi-code 0.33+(v2 引擎):循环控制改用新键 max_attempts_per_step,旧配置自动迁移", "兼容 kimi-code 0.33+(v2 引擎):循环控制改用新键 max_attempts_per_step,旧配置自动迁移",
"models.dev 模型列表与价格数据同步更新", "models.dev 模型列表与价格数据同步更新",
"官网企业版视觉升级;出品公司标识统一为 codingplan.site", "官网企业版视觉升级;出品公司标识统一为 codingplan.site",
"macOS 安装优化:Release 附带 install-macos.sh 一键脚本,自动清除下载隔离,免去手动右键打开", "macOS 安装优化:Release 附带 install-macos.sh,自动清除下载隔离,免去手动右键打开",
], ],
}, },
{ {
@@ -201,7 +212,7 @@ const zh = {
version: "v0.6.9", version: "v0.6.9",
date: "2026-08-01", date: "2026-08-01",
items: [ items: [
"最近请求新增缓存命中率列,每次请求命中一目了然", "最近请求新增缓存命中率列,逐条请求的命中情况直接可见",
"中文模式费用按最新汇率换算人民币(¥)显示,英文模式保持美元", "中文模式费用按最新汇率换算人民币(¥)显示,英文模式保持美元",
"仪表盘性能再优化:价格解析记忆化缓存,扫描从秒级降到百毫秒级;右上角新增加载耗时/数据量跟踪", "仪表盘性能再优化:价格解析记忆化缓存,扫描从秒级降到百毫秒级;右上角新增加载耗时/数据量跟踪",
], ],
@@ -229,7 +240,7 @@ const zh = {
version: "v0.6.6", version: "v0.6.6",
date: "2026-08-01", date: "2026-08-01",
items: [ items: [
"模型价格对标 models.dev,覆盖 5910 个模型", "模型价格数据对标 models.dev,快照随版本更新",
"新增基元律动供应商与推荐链接", "新增基元律动供应商与推荐链接",
"设置页文案与参考来源说明优化", "设置页文案与参考来源说明优化",
], ],
@@ -311,34 +322,34 @@ const zh = {
}, },
download: { download: {
title: "下载 Kimi Switch", title: "下载 Kimi Switch",
subtitle: "当前版本 v0.7.4 · Windows / macOS / Linux 三平台已发布", subtitle: "当前版本 v0.7.12 · Windows / macOS / Linux 三平台已发布",
autoUpdate: "应用内置自动检测更新,新版本发布后在设置页一键升级。", autoUpdate: "更新检查跑在启动时和每 8 小时的周期任务里,设置页也可以手动触发。",
ready: "已发布", ready: "已发布",
wip: "开发中", wip: "开发中",
githubBtn: "GitHub 下载", githubBtn: "GitHub 下载",
mirrorBtn: "镜像下载", mirrorBtn: "镜像下载",
items: [ items: [
{ id: "windows", name: "Windows", ready: true, note: ".msi 安装包,系统托盘常驻" }, { 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 三格式" }, { id: "linux", name: "Linux", ready: true, note: ".deb / .AppImage / .rpm 三格式" },
], ],
}, },
cta: { cta: {
title: "现在就把所有供应商,放进一个应用", title: "把供应商配置、用量和账单收进一个窗口",
subtitle: "免费 · 开源 MIT · 一分钟完成安装", subtitle: "免费 · MIT 开源 · Windows / macOS / Linux",
download: "免费下载", download: "免费下载",
source: "GitHub 源码", source: "GitHub 源码",
}, },
seo: { seo: {
home: { home: {
title: "Kimi Switch — 多 LLM 供应商统一管理 · 用量监控 · 账单一目了然", title: "Kimi Switch — 多 LLM 供应商配置管理 · 用量与账单监控",
description: description:
"Kimi Switch / KimiCodeSwitch — Windows 桌面端多 LLM 供应商统一管理器,一键切换 Kimi / 智谱 GLM / MiniMax / DeepSeek / OpenRouter / 基元律动,用量账单实时监控,开源 MIT。", "Kimi Switch / KimiCodeSwitch — 桌面端 LLM 供应商配置管理器:22 个预设选好即写入 config.toml,用量与账单直读各厂商官方接口。Tauri + Rust,开源 MIT。",
}, },
features: { features: {
title: "核心功能 - Kimi Switch", title: "核心功能 - Kimi Switch",
description: description:
"Kimi Switch 核心功能:多供应商管理、用量与账单监控、Kimi 一键授权登录、智能模型发现与自动更新,22 个预设开箱即用。", "Kimi Switch 功能页:多供应商预设与自定义配置、用量与账单监控、Kimi 设备码授权、模型自动发现与周期更新检查。",
}, },
changelog: { changelog: {
title: "更新日志 - Kimi Switch", title: "更新日志 - Kimi Switch",
@@ -380,116 +391,116 @@ const en: Dict = {
performance: "Performance", performance: "Performance",
changelog: "Changelog", changelog: "Changelog",
download: "Download", download: "Download",
downloadBtn: "Download v0.7.4", downloadBtn: "Download v0.7.12",
}, },
hero: { 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 ", titleBefore: "One app for all your ",
titleAccent: "AI providers", titleAccent: "AI providers",
titleAfter: "", titleAfter: "",
subtitle: 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", download: "Free Download",
source: "View Source", source: "View source",
stats: [ stats: [
{ value: "22", label: "Provider presets" }, { value: "22", label: "Provider presets" },
{ value: "5910", label: "Model price DB" }, { value: "7495", label: "Model price DB" },
{ value: "100%", label: "Open source" }, { value: "MIT", label: "License" },
], ],
}, },
features: { features: {
title: "Core Features", title: "Core features",
subtitle: "From multi-provider management to usage monitoring, all in one place.", subtitle: "Configuration, switching, usage, billing and sessions — all against the config.toml on your machine.",
items: [ items: [
{ {
id: "multi-provider", id: "multi-provider",
title: "Multi-Provider Management", title: "Provider presets",
desc: "22 presets including Kimi, Zhipu GLM, MiniMax, DeepSeek, OpenRouter and TokenRhythm. Add and switch in one click.", 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", img: "screenshots/preset-picker.png",
}, },
{ {
id: "custom-provider", id: "custom-provider",
title: "Custom Providers", title: "Custom providers",
desc: "Custom Base URL, API Key and model mapping, with advanced JSON editing.", 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", img: "screenshots/edit-provider-basic-managed.png",
}, },
{ {
id: "usage", id: "usage",
title: "Usage & Billing", 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.", 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", img: "screenshots/dashboard-light.png",
}, },
{ {
id: "sessions", id: "sessions",
title: "Session Manager", title: "Session manager",
desc: "Browse, search and resume past sessions across providers.", desc: "Browse, search and resume past sessions across providers.",
img: "screenshots/sessions-light.png", img: "screenshots/sessions-light.png",
}, },
{ {
id: "kimi-auth", 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.", 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", img: "screenshots/kimi-oauth-dialog.png",
}, },
{ {
id: "usage-config", 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.", desc: "Full-page config: NewAPI relay templates, timeout, auto-refresh interval, and a test query.",
img: "screenshots/usage-config-page.png", img: "screenshots/usage-config-page.png",
}, },
{ {
id: "model-discovery", id: "model-discovery",
title: "Smart Model Discovery", title: "Model discovery",
desc: "Model capabilities and context sizes sync from models.dev at build time, covering 5910 models.", 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", img: "screenshots/kimi-cli-select-model.png",
}, },
{ {
id: "auto-update", id: "auto-update",
title: "Auto Updates", title: "Auto updates",
desc: "Automatically detects new versions and installs them in one click.", 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", img: "screenshots/settings.png",
}, },
], ],
}, },
showcase: { showcase: {
title: "Screenshots", 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: [ items: [
{ {
src: "screenshots/usage-config-page.png", 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", desc: "NewAPI relay templates, timeout and auto-refresh, plus a test query, all on one page",
}, },
{ {
src: "screenshots/preset-picker.png", src: "screenshots/preset-picker.png",
title: "Provider Presets", title: "Provider presets",
desc: "22 presets ready out of the box, add popular vendors in one click", desc: "Tick through 22 presets to add popular vendors",
}, },
{ {
src: "screenshots/kimi-oauth-dialog.png", src: "screenshots/kimi-oauth-dialog.png",
title: "Kimi Authorization", title: "Kimi authorization",
desc: "Device-code flow identical to kimi login, auto-renews on expiry", desc: "Device-code flow identical to kimi login, auto-renews on expiry",
}, },
{ {
src: "screenshots/dashboard-daily-detail.png", src: "screenshots/dashboard-daily-detail.png",
title: "Usage Details", title: "Usage details",
desc: "Daily token and cost trends", desc: "Daily token and cost trends",
}, },
{ {
src: "screenshots/sessions-light.png", src: "screenshots/sessions-light.png",
title: "Session Manager", title: "Session manager",
desc: "Browse and resume past sessions across providers", desc: "Browse and resume past sessions across providers",
}, },
{ {
src: "screenshots/kimi-plan-quota-card.png", src: "screenshots/kimi-plan-quota-card.png",
title: "Plan Quota Card", title: "Plan quota card",
desc: "5-hour / weekly quotas at a glance", desc: "5-hour / weekly quotas read straight off the card",
}, },
], ],
}, },
performance: { performance: {
title: "Lightweight & Native", title: "Lightweight and native",
subtitle: "Tauri + Rust core with a WebView2 frontend. Zero tray overhead.", subtitle: "Tauri + Rust on the system WebView2 — no bundled Chromium. The three numbers below are idle-state measurements.",
metrics: [ metrics: [
{ target: 93, suffix: " MB", label: "Working Set", desc: "Idle working-set memory" }, { 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)" }, { 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", syncedNote: "Synced from GitHub Releases",
fallbackNote: "Built-in release notes", fallbackNote: "Built-in release notes",
entries: [ 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", version: "v0.7.4",
date: "2026-08-09", 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", "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", "models.dev model list and pricing snapshot synced",
"Enterprise-style website refresh; publisher identity unified as codingplan.site", "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", version: "v0.6.9",
date: "2026-08-01", date: "2026-08-01",
items: [ 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", "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", "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", version: "v0.6.6",
date: "2026-08-01", date: "2026-08-01",
items: [ 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", "New TokenRhythm provider with referral link",
"Settings copy and source attribution polish", "Settings copy and source attribution polish",
], ],
@@ -652,56 +674,56 @@ const en: Dict = {
], ],
}, },
privacy: { 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.", subtitle: "Our privacy and open-source commitments in three sentences.",
items: [ items: [
{ {
id: "local", id: "local",
title: "Local Storage", title: "Local storage",
desc: "Configs and usage data live only in ~/.kimi-switch and config.toml. Nothing is uploaded.", desc: "Configs and usage data live only in ~/.kimi-switch and config.toml. Nothing is uploaded.",
}, },
{ {
id: "direct", id: "direct",
title: "Direct Connections", title: "Direct connections",
desc: "Billing queries go straight to each vendor's official API. No middlemen, no tracking.", desc: "Billing queries go straight to each vendor's official API. No middlemen, no tracking.",
}, },
{ {
id: "open", id: "open",
title: "Open & Auditable", title: "Open & auditable",
desc: "Fully open source under MIT on GitHub. Reviews and contributions welcome.", desc: "Fully open source under MIT on GitHub. Reviews and contributions welcome.",
}, },
], ],
}, },
download: { download: {
title: "Download Kimi Switch", title: "Download Kimi Switch",
subtitle: "Current version v0.7.4 · Windows, macOS and Linux now released", subtitle: "Current version v0.7.12 · Windows, macOS and Linux now released",
autoUpdate: "Built-in update detection: upgrade in one click from Settings when a new version ships.", autoUpdate: "Update checks run at startup and every 8 hours; Settings also has a manual check.",
ready: "Available", ready: "Available",
wip: "In development", wip: "In development",
githubBtn: "Download from GitHub", githubBtn: "Download from GitHub",
mirrorBtn: "China mirror", mirrorBtn: "China mirror",
items: [ items: [
{ id: "windows", name: "Windows", ready: true, note: ".msi installer, lives in the system tray" }, { 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" }, { id: "linux", name: "Linux", ready: true, note: ".deb / .AppImage / .rpm" },
], ],
}, },
cta: { cta: {
title: "Bring every provider into one app", title: "Put provider config, usage and billing in one window",
subtitle: "Free · Open source MIT · One-minute install", subtitle: "Free · MIT licensed · Windows / macOS / Linux",
download: "Free Download", download: "Free Download",
source: "GitHub Source", source: "GitHub source",
}, },
seo: { seo: {
home: { home: {
title: "Kimi Switch — Unified Management for AI Providers · Usage & Billing", title: "Kimi Switch — LLM provider config, usage and billing in one app",
description: 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: { features: {
title: "Core Features - Kimi Switch", title: "Core features - Kimi Switch",
description: 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: { changelog: {
title: "Changelog - Kimi Switch", title: "Changelog - Kimi Switch",
@@ -715,7 +737,7 @@ const en: Dict = {
}, },
}, },
footer: { footer: {
recommend: "Recommended Providers", recommend: "Recommended providers",
mirror: "China mirror", mirror: "China mirror",
downloads: "Total GitHub downloads: {n}", downloads: "Total GitHub downloads: {n}",
creditBefore: "Dashboard & session management adapted from ", creditBefore: "Dashboard & session management adapted from ",
+10 -33
View File
@@ -23,9 +23,9 @@
--accent: 240 6% 94%; --accent: 240 6% 94%;
--accent-foreground: 240 9% 11%; --accent-foreground: 240 9% 11%;
--border: 240 6% 90%; --border: 240 6% 90%;
--primary: 221 83% 53%; --primary: 221 74% 50%;
--primary-foreground: 0 0% 100%; --primary-foreground: 0 0% 100%;
--ring: 221 83% 53%; --ring: 221 74% 50%;
/* 克制分层阴影(色相一致的 tinted shadow,非荧光发光) */ /* 克制分层阴影(色相一致的 tinted shadow,非荧光发光) */
--shadow-soft: 0 1px 2px hsl(240 9% 11% / 0.05), --shadow-soft: 0 1px 2px hsl(240 9% 11% / 0.05),
0 8px 24px hsl(240 9% 11% / 0.06); 0 8px 24px hsl(240 9% 11% / 0.06);
@@ -44,9 +44,9 @@
--accent: 222 25% 14%; --accent: 222 25% 14%;
--accent-foreground: 220 20% 92%; --accent-foreground: 220 20% 92%;
--border: 222 22% 17%; --border: 222 22% 17%;
--primary: 217 91% 60%; --primary: 216 68% 62%;
--primary-foreground: 0 0% 100%; --primary-foreground: 0 0% 100%;
--ring: 217 91% 60%; --ring: 216 68% 62%;
--shadow-soft: 0 1px 2px hsl(222 47% 2% / 0.5), --shadow-soft: 0 1px 2px hsl(222 47% 2% / 0.5),
0 8px 24px hsl(222 47% 2% / 0.45); 0 8px 24px hsl(222 47% 2% / 0.45);
--shadow-lift: 0 1px 2px hsl(222 47% 2% / 0.5), --shadow-lift: 0 1px 2px hsl(222 47% 2% / 0.5),
@@ -60,24 +60,6 @@
body { body {
@apply bg-background text-foreground antialiased; @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 { ::selection {
background: hsl(var(--primary) / 0.16); background: hsl(var(--primary) / 0.16);
@@ -96,19 +78,14 @@
@layer components { @layer components {
.container-page { .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 { .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 { .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]; @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), box-shadow: 0 1px 2px hsl(221 45% 25% / 0.25);
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);
} }
.btn-ghost { .btn-ghost {
@apply btn border border-border bg-card/60 text-foreground hover:border-foreground/25 hover:bg-accent active:scale-[0.98]; @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 { .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); box-shadow: var(--shadow-lift);
} }
.screenshot > img { .screenshot > img {
@apply block h-auto w-full; @apply block h-auto w-full;
} }
/* 图标容器:统一的蓝色徽标容器 */ /* 图标容器:中性底、统一描边宽度(Phosphor duotone) */
.icon-chip { .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 <section
id="cta" id="cta"
aria-labelledby="cta-title" aria-labelledby="cta-title"
className="py-24" className="pt-20 pb-28"
> >
<div className="container-page"> <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 <div
aria-hidden="true" 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 <h2
aria-hidden="true" id="cta-title"
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-primary/40 to-transparent" className="text-3xl font-bold tracking-[-0.03em] md:text-4xl"
/> >
<div className="relative"> {t.cta.title}
<h2 </h2>
id="cta-title" <p className="mt-4 max-w-[52ch] text-base text-muted-foreground md:text-lg">
className="text-3xl font-bold tracking-[-0.03em] md:text-5xl" {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} {t.cta.source}
</h2> </a>
<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>
</div> </div>
</div> </div>
</div> </div>
+36 -20
View File
@@ -8,27 +8,30 @@ const icons: Record<string, Icon> = {
linux: LinuxLogo, linux: LinuxLogo,
}; };
const VERSION = "0.7.4"; const VERSION = "0.7.12";
const GITHUB = "https://github.com/billowliu2/KimiSwitch"; const GITHUB = "https://github.com/billowliu2/KimiSwitch";
const MIRROR_RELEASES = "https://git.codingplan.site/admin/KimiCodeSwitch/releases"; const MIRROR_RELEASES = "https://git.codingplan.site/admin/KimiCodeSwitch/releases";
const dl = (file: string) => `${GITHUB}/releases/download/v${VERSION}/${file}`; const dl = (file: string) => `${GITHUB}/releases/download/v${VERSION}/${file}`;
/** Per-platform download assets for the current release (names match the CI /** Per-platform download assets for the current release (names match the CI
* release workflow exactly — verified against the published v0.7.4 assets: * release workflow exactly — verified against the published v0.7.12 assets:
* tauri-bundler on CI produces `Kimi.Switch_…` (dots) on every platform). */ * 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 }[]> = { const assets: Record<string, { label: string; href: string }[]> = {
windows: [ 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 }, { label: "镜像", href: MIRROR_RELEASES },
], ],
macos: [ 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") }, { label: "安装脚本", href: dl("install-macos.sh") },
], ],
linux: [ linux: [
{ label: ".deb", href: dl("Kimi.Switch_0.7.4_amd64.deb") }, { label: ".deb", href: dl(`Kimi.Switch_${VERSION}_amd64.deb`) },
{ label: ".AppImage", href: dl("Kimi.Switch_0.7.4_amd64.AppImage") }, { label: ".AppImage", href: dl(`Kimi.Switch_${VERSION}_amd64.AppImage`) },
{ label: ".rpm", href: dl("Kimi.Switch-0.7.4-1.x86_64.rpm") }, { label: ".rpm", href: dl(`Kimi.Switch-${VERSION}-1.x86_64.rpm`) },
], ],
}; };
@@ -40,23 +43,36 @@ export default function Download() {
title={t.download.title} title={t.download.title}
subtitle={t.download.subtitle} 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) => { {t.download.items.map((p) => {
const Icon = icons[p.id] ?? WindowsLogo; const Icon = icons[p.id] ?? WindowsLogo;
const links = assets[p.id] ?? []; const links = assets[p.id] ?? [];
const featured = p.id === "windows";
return ( return (
<div key={p.id} className="card flex flex-col"> <div
<div className="mb-3 flex items-center justify-between"> key={p.id}
<div className="icon-chip"> className={`card flex flex-col ${
<Icon className="h-5 w-5" weight="duotone" /> 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> </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"> <p className={`text-sm text-muted-foreground ${featured ? "max-w-[65ch]" : ""}`}>
{t.download.ready} {p.note}
</span> </p>
</div> </div>
<h3 className="mb-2 text-lg font-semibold tracking-tight">{p.name}</h3> <div className="mt-4 flex shrink-0 flex-wrap items-start gap-2 md:mt-0">
<p className="text-sm text-muted-foreground">{p.note}</p>
<div className="mt-4 flex flex-wrap gap-2">
{links.map((l) => ( {links.map((l) => (
<a <a
key={l.label} key={l.label}
@@ -74,7 +90,7 @@ export default function Download() {
})} })}
</div> </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" /> <DownloadSimple className="h-4 w-4 text-primary" weight="duotone" />
{t.download.autoUpdate} {t.download.autoUpdate}
</p> </p>
+2
View File
@@ -45,6 +45,8 @@ export default function Features() {
key={f.id} key={f.id}
style={{ transitionDelay: `${i * 60}ms` }} style={{ transitionDelay: `${i * 60}ms` }}
className={`card overflow-hidden p-0 transition-all duration-500 ease-out hover:border-primary/50 ${ className={`card overflow-hidden p-0 transition-all duration-500 ease-out hover:border-primary/50 ${
i % 2 === 1 ? "sm:mt-12" : ""
} ${
gridInView gridInView
? "motion-safe:opacity-100 motion-safe:translate-y-0" ? "motion-safe:opacity-100 motion-safe:translate-y-0"
: "motion-safe:opacity-0 motion-safe:translate-y-4" : "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" aria-labelledby="hero-title"
className="relative overflow-hidden pt-16 pb-20 md:pt-24" 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 className="container-page relative grid grid-cols-1 gap-10 md:grid-cols-2 md:items-center">
<div> <div>
<div {...reveal(0)}> <div {...reveal(0)}>
@@ -87,7 +78,7 @@ export default function Hero() {
<div className="screenshot max-h-[420px]"> <div className="screenshot max-h-[420px]">
<img <img
src={asset("screenshots/hero.png")} 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" loading="eager"
/> />
</div> </div>
+12 -7
View File
@@ -16,18 +16,23 @@ export default function Privacy() {
title={t.privacy.title} title={t.privacy.title}
subtitle={t.privacy.subtitle} 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) => { {t.privacy.items.map((it) => {
const Icon = icons[it.id] ?? Database; const Icon = icons[it.id] ?? Database;
return ( return (
<div key={it.id} className="card"> <div key={it.id} className="flex items-start gap-5 p-6 md:p-7">
<div className="icon-chip mb-3"> <div className="icon-chip shrink-0">
<Icon className="h-5 w-5" weight="duotone" /> <Icon className="h-5 w-5" weight="duotone" />
</div> </div>
<h3 className="mb-2 text-lg font-semibold tracking-tight">{it.title}</h3> <div className="min-w-0">
<p className="text-sm leading-relaxed text-muted-foreground"> <h3 className="text-base font-semibold tracking-tight">
{it.desc} {it.title}
</p> </h3>
<p className="mt-1 max-w-[65ch] text-sm leading-relaxed text-muted-foreground">
{it.desc}
</p>
</div>
</div> </div>
); );
})} })}
+7 -1
View File
@@ -21,12 +21,18 @@ export default function Showcase() {
key={s.src} key={s.src}
style={{ transitionDelay: `${i * 60}ms` }} style={{ transitionDelay: `${i * 60}ms` }}
className={`transition-all duration-500 ease-out ${ className={`transition-all duration-500 ease-out ${
i === 0 ? "sm:col-span-2" : ""
} ${
gridInView gridInView
? "motion-safe:opacity-100 motion-safe:translate-y-0" ? "motion-safe:opacity-100 motion-safe:translate-y-0"
: "motion-safe:opacity-0 motion-safe:translate-y-4" : "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 <img
src={asset(s.src)} src={asset(s.src)}
alt={`Kimi Switch - ${s.title}`} alt={`Kimi Switch - ${s.title}`}