Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5864d661c1 | ||
|
|
34a9c4c2d9 | ||
|
|
4f0b0cae12 | ||
|
|
35fd2e7b0e | ||
|
|
86112ee90b | ||
|
|
e349fa5971 | ||
|
|
1de8b9ee4c | ||
|
|
36fc9c50a8 | ||
|
|
1a11cfbaaf | ||
|
|
81699e757d | ||
|
|
4de13ad353 | ||
|
|
5138a295e0 | ||
|
|
a02179c9da | ||
|
|
86a128efea | ||
|
|
6a7c8f5393 | ||
|
|
8b597496b3 | ||
|
|
8ce3f290ed | ||
|
|
8639188968 | ||
|
|
45ec073e6f |
No files matched your search
@@ -69,6 +69,45 @@ jobs:
|
||||
libgtk-3-dev \
|
||||
libayatana-appindicator3-dev
|
||||
|
||||
- name: Compose release body
|
||||
id: notes
|
||||
shell: bash
|
||||
run: |
|
||||
FILE="release-notes-${{ github.ref_name }}.md"
|
||||
{
|
||||
echo 'body<<RELEASE_BODY_EOF'
|
||||
if [ -f "$FILE" ]; then
|
||||
cat "$FILE"
|
||||
else
|
||||
echo "## ${{ github.ref_name }}"
|
||||
echo
|
||||
echo "见提交记录(未找到 $FILE)。"
|
||||
fi
|
||||
echo
|
||||
cat <<'TAIL_EOF'
|
||||
### Downloads by platform
|
||||
|
||||
- **Windows**: `.msi` installer
|
||||
- **macOS**: `.dmg` + `.app`(未签名,仅 Apple Silicon / M 系列芯片)
|
||||
- **Linux**: `.deb` / `.AppImage` / `.rpm`
|
||||
|
||||
### macOS 安装说明(无开发者账号签名,需一次手动绕过)
|
||||
|
||||
1. 下载 `Kimi.Switch_*.dmg`,双击挂载后把 `Kimi Switch.app` 拖入「应用程序」文件夹
|
||||
2. 下载本 Release 附带的 `install-macos.sh`,在终端运行:
|
||||
|
||||
```bash
|
||||
bash install-macos.sh
|
||||
```
|
||||
|
||||
脚本会自动清除「下载隔离」属性并启动应用,之后可正常使用。
|
||||
3. 手动替代方案:右键 `Kimi Switch.app` → 打开 → 弹窗中点「打开」(或 系统设置 → 隐私与安全性 → 仍要打开)
|
||||
|
||||
Full changelog: see commit history.
|
||||
TAIL_EOF
|
||||
echo 'RELEASE_BODY_EOF'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and upload release
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
@@ -76,36 +115,7 @@ jobs:
|
||||
with:
|
||||
tagName: ${{ github.ref_name }}
|
||||
releaseName: ${{ github.ref_name }}
|
||||
releaseBody: |
|
||||
## ${{ github.ref_name }}
|
||||
|
||||
### 本次更新
|
||||
|
||||
- **模型数据同步(models.dev 2026-08-27)**:7343 个模型 / 203 个供应商
|
||||
- **新增 GLM-5.3 / GLM-5.3-Flash 全线接入**:智谱官方、OpenRouter、Cloudflare、DeepInfra、HuggingFace、火山引擎等约 20 条渠道;GLM-5.3-Flash 定价 $0.075 / $0.25(输入/输出,每百万 token)
|
||||
- 新增 coding plan 渠道:`zhipuai-coding-plan/glm-5.3`、`glm-5.3-highspeed`、`glm-5.3-flash`
|
||||
- 新增 Qwen3.8 系列、火山引擎豆包 Seed 2.x 全系、DeepSeek-V4 GA 版、MiniMax-M2.7/M3 等共 74 个模型条目
|
||||
- 价格更新 58 处、能力信息修正 56 处(详见 release-notes-v0.7.9.md)
|
||||
|
||||
### Downloads by platform
|
||||
|
||||
- **Windows**: `.msi` installer
|
||||
- **macOS**: `.dmg` + `.app`(未签名,仅 Apple Silicon / M 系列芯片)
|
||||
- **Linux**: `.deb` / `.AppImage` / `.rpm`
|
||||
|
||||
### macOS 安装说明(无开发者账号签名,需一次手动绕过)
|
||||
|
||||
1. 下载 `Kimi.Switch_*.dmg`,双击挂载后把 `Kimi Switch.app` 拖入「应用程序」文件夹
|
||||
2. 下载本 Release 附带的 `install-macos.sh`,在终端运行:
|
||||
|
||||
```bash
|
||||
bash install-macos.sh
|
||||
```
|
||||
|
||||
脚本会自动清除「下载隔离」属性并启动应用,之后可正常使用。
|
||||
3. 手动替代方案:右键 `Kimi Switch.app` → 打开 → 弹窗中点「打开」(或 系统设置 → 隐私与安全性 → 仍要打开)
|
||||
|
||||
Full changelog: see commit history.
|
||||
releaseBody: ${{ steps.notes.outputs.body }}
|
||||
prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') }}
|
||||
overwrite: true
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
|
||||

|
||||
|
||||
按工作区隔离浏览 Kimi Code 会话,支持活跃/已归档/全部筛选;流式逐行预览(20MB 字节上限,500 字符折叠);归档/取消归档/批量删除。
|
||||
按工作区隔离浏览 Kimi Code 会话,支持活跃/已归档/全部筛选;流式逐行预览(20MB 字节上限,500 字符折叠);归档/取消归档/批量删除。归档写入 `state.json` 元数据(与 CLI v2 一致),旧版本物理归档目录 `.kcd-archive/` 仍可识别与恢复。
|
||||
|
||||
## 架构总览
|
||||
|
||||
@@ -239,7 +239,7 @@
|
||||
|
||||
- 按工作区隔离浏览 Kimi Code 会话,支持活跃/已归档/全部筛选
|
||||
- 流式逐行预览(20MB 字节上限,500 字符折叠可展开)
|
||||
- 归档 / 取消归档 / 批量删除
|
||||
- 归档 / 取消归档 / 批量删除(归档写入 `state.json` 元数据,与 CLI v2 一致;旧版本物理归档目录 `.kcd-archive/` 仍可识别与恢复)
|
||||
- 早期版本的"闪崩"已通过流式读取 + 限制解决
|
||||
|
||||
### 设置面板
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "kimiswitch",
|
||||
"private": true,
|
||||
"version": "0.7.10",
|
||||
"version": "0.7.15",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+81007
-77499
File diff suppressed because it is too large.
Load diff
@@ -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 个供应商**
|
||||
@@ -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 供应商
|
||||
@@ -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 错误本地化单元测试
|
||||
@@ -0,0 +1,10 @@
|
||||
# KimiSwitch v0.7.14
|
||||
|
||||
## 修复
|
||||
|
||||
- **关闭「启用用量查询」后不再残留查询行为**:此前关闭开关后前端仍会发起查询,卡片上持续显示「查询失败 · 用量查询已关闭」的红色错误行。现在关闭后立即停止查询、隐藏整行用量信息(卡片头部与底部两种展示均生效),重新开启后自动恢复
|
||||
- 在途的查询结果在开关切换瞬间作废,且不再写入共享缓存,避免过期的失败状态在 5 分钟内复活
|
||||
|
||||
## 数据
|
||||
|
||||
- models-dev 快照同步(上游格式与价格更新)
|
||||
@@ -0,0 +1,16 @@
|
||||
# KimiSwitch v0.7.15
|
||||
|
||||
## 新功能
|
||||
|
||||
- **用量统计新增「昨天」选项卡**:时间范围现为 今天 / 昨天 / 7 天 / 30 天 / 全部,「昨天」按本地时区严格统计昨日 0 点至今日 0 点(不渗入今天),顶部概览卡同步扩展为 5 张
|
||||
- **新增「模型用量统计」趋势选项卡**:在「供应商模型用量趋势」之后,不按天、跨供应商按模型名汇总(同一模型经不同供应商合并为一行),横向条形图按 Token 降序,悬停显示请求数 / Token / 缓存命中 / 费用;子代理模型仍单独成行
|
||||
|
||||
## 修复
|
||||
|
||||
- **opencode Go 套餐子代理请求 400**:Go 网关(opencode.ai/zen/go)近期强制要求 `x-opencode-session` 请求头用于路由与提示词缓存,缺失即 400 拒绝。现在导出配置时,凡指向 Go 端点的供应商会自动补写 `custom_headers.x-opencode-session`(已有用户自定义值保留不覆盖);按量计费的 `zen/v1` 端点不受影响
|
||||
- 定价测试基准条目更新:models.dev 上游下架 `moonshotai/kimi-k2.5` 官方条目,测试基准切换到 `moonshotai/kimi-k3`
|
||||
|
||||
## 数据
|
||||
|
||||
- models-dev 快照同步至 2026-09-09(7583 模型 / 213 供应商),并补齐 `public/` 前端静态目录(原停留在 08-27)
|
||||
- 注意:k2.5 官方定价条目下架后,`kimi/k2.5` 费用估算暂解析到 302ai 转售价格(0.66 / 3.3),略高于原官方价
|
||||
Generated
+1
-1
@@ -1978,7 +1978,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kimiswitch"
|
||||
version = "0.7.10"
|
||||
version = "0.7.14"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "kimiswitch"
|
||||
version = "0.7.10"
|
||||
version = "0.7.15"
|
||||
description = "Kimi Switch - model config manager"
|
||||
authors = ["codingplan.site"]
|
||||
edition = "2021"
|
||||
|
||||
+121
-58
@@ -690,8 +690,8 @@ pub async fn query_provider_usage(
|
||||
|
||||
let base_url = resolve_base_url(provider);
|
||||
|
||||
// NewAPI template: query the gateway's own /api/user/self with the
|
||||
// web-console access token, bypassing usage_kinds entirely.
|
||||
// NewAPI template: query the gateway's own usage endpoints with its
|
||||
// dedicated credentials, bypassing usage_kinds entirely.
|
||||
if let Some(cfg) = &provider.usage_config {
|
||||
if cfg.template_type == UsageConfig::TEMPLATE_NEWAPI {
|
||||
// Err = transient (network) → propagate for retry, same semantics
|
||||
@@ -1014,6 +1014,9 @@ pub struct KimiWebState {
|
||||
window: Mutex<Option<tauri::WebviewWindow>>,
|
||||
/// The `kimi web` child spawned by this app (None when reusing a server).
|
||||
child: Mutex<Option<std::process::Child>>,
|
||||
/// Serializes `open_kimi_web_embedded` invocations: a rapid double-click
|
||||
/// queues here instead of racing the window/server setup.
|
||||
launch_lock: tokio::sync::Mutex<()>,
|
||||
}
|
||||
|
||||
const KIMI_WEB_PORT: u16 = 58627;
|
||||
@@ -1064,7 +1067,13 @@ pub async fn open_kimi_web_embedded(
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, KimiWebState>,
|
||||
) -> Result<(), String> {
|
||||
// Singleton: focus the existing window instead of creating a second one.
|
||||
// Serialize concurrent invocations (rapid double-click): a second call
|
||||
// waits here until the first has finished building the window, then
|
||||
// re-checks the singleton below and focuses it instead.
|
||||
let _guard = state.launch_lock.lock().await;
|
||||
|
||||
// Singleton (re-checked after acquiring the lock): focus the existing
|
||||
// window instead of creating a second one.
|
||||
if let Ok(guard) = state.window.lock() {
|
||||
if let Some(w) = guard.as_ref() {
|
||||
if w.is_visible().unwrap_or(false) {
|
||||
@@ -1075,8 +1084,21 @@ pub async fn open_kimi_web_embedded(
|
||||
}
|
||||
}
|
||||
|
||||
// Reuse an already-running server, otherwise spawn `kimi web --no-open`.
|
||||
if !kimi_web_alive(Duration::from_millis(800)).await {
|
||||
// Reuse the server this app spawned earlier (still running), or a server
|
||||
// already running outside the app; only spawn when neither is present.
|
||||
// `spawned_by_me` gates every failure-path kill, so a reused server is
|
||||
// never terminated.
|
||||
let mut spawned_by_me = false;
|
||||
let mut child_running = false;
|
||||
if let Ok(mut guard) = state.child.lock() {
|
||||
// try_wait: Ok(None) = still running. Exited or unwaitable children
|
||||
// are treated as gone so a fresh server is spawned below.
|
||||
child_running = guard
|
||||
.as_mut()
|
||||
.map(|child| child.try_wait().map(|st| st.is_none()).unwrap_or(false))
|
||||
.unwrap_or(false);
|
||||
}
|
||||
if !child_running && !kimi_web_alive(Duration::from_millis(800)).await {
|
||||
let mut cmd = std::process::Command::new("kimi");
|
||||
cmd.args(["web", "--no-open", "--port", &KIMI_WEB_PORT.to_string()]);
|
||||
#[cfg(windows)]
|
||||
@@ -1091,6 +1113,7 @@ pub async fn open_kimi_web_embedded(
|
||||
format!("failed to start `kimi web`: {e}")
|
||||
}
|
||||
})?;
|
||||
spawned_by_me = true;
|
||||
if let Ok(mut guard) = state.child.lock() {
|
||||
*guard = Some(child);
|
||||
}
|
||||
@@ -1098,57 +1121,79 @@ pub async fn open_kimi_web_embedded(
|
||||
|
||||
// Wait for the server to come up (fresh start takes a moment).
|
||||
if !kimi_web_alive(Duration::from_secs(8)).await {
|
||||
kill_spawned_kimi_web(&state);
|
||||
if spawned_by_me {
|
||||
kill_spawned_kimi_web(&state);
|
||||
}
|
||||
return Err(format!("kimi web did not come up within 8s ({KIMI_WEB_ORIGIN})"));
|
||||
}
|
||||
|
||||
// Create the window on the main thread (required on macOS).
|
||||
// Build the URL, then create the window on the main thread (required on
|
||||
// macOS). The build result is awaited while still holding `launch_lock`,
|
||||
// so a concurrent second invocation blocks here and then lands on the
|
||||
// singleton re-check above once the window exists.
|
||||
let url: tauri::Url = match kimi_web_url().parse() {
|
||||
Ok(u) => u,
|
||||
Err(_) => return Err("invalid kimi web url".to_string()),
|
||||
};
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let builder_app = app.clone();
|
||||
let _ = app.run_on_main_thread(move || {
|
||||
match tauri::WebviewWindowBuilder::new(
|
||||
&builder_app,
|
||||
KIMI_WEB_WINDOW_LABEL,
|
||||
tauri::WebviewUrl::External(url),
|
||||
)
|
||||
.title("Kimi Code WebUI")
|
||||
.inner_size(1100.0, 750.0)
|
||||
.resizable(true)
|
||||
.center()
|
||||
.build()
|
||||
{
|
||||
Ok(window) => {
|
||||
// Track the window for the singleton check.
|
||||
if let Ok(mut guard) = builder_app.state::<KimiWebState>().window.lock() {
|
||||
*guard = Some(window.clone());
|
||||
}
|
||||
// Clean up when the window closes: forget it and stop the
|
||||
// `kimi web` server we spawned (reused servers are untouched).
|
||||
let w = window.clone();
|
||||
window.on_window_event(move |event| match event {
|
||||
tauri::WindowEvent::Destroyed => {
|
||||
let app = w.app_handle().clone();
|
||||
let state = app.state::<KimiWebState>();
|
||||
if let Ok(mut guard) = state.window.lock() {
|
||||
*guard = None;
|
||||
}
|
||||
kill_spawned_kimi_web(&state);
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
// Window creation failed (e.g. rapid double-click racing
|
||||
// the singleton check): don't leak the server we spawned.
|
||||
kill_spawned_kimi_web(&builder_app.state::<KimiWebState>());
|
||||
let (tx, rx) = tokio::sync::oneshot::channel::<Result<(), String>>();
|
||||
let builder_app = app.clone();
|
||||
app.run_on_main_thread(move || {
|
||||
let result = build_kimi_web_window(&builder_app, &url);
|
||||
let _ = tx.send(result);
|
||||
})
|
||||
.map_err(|e| format!("failed to queue window creation: {e}"))?;
|
||||
match rx.await {
|
||||
Ok(result) => {
|
||||
if let Err(e) = result {
|
||||
// Window creation failed (e.g. label already taken): only stop
|
||||
// the server when this invocation spawned it.
|
||||
if spawned_by_me {
|
||||
kill_spawned_kimi_web(&state);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
// Sender dropped without a result (app shutting down, build never
|
||||
// ran): never kill the server — a later invocation may reuse it.
|
||||
Err(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the embedded WebUI window on the main thread and track it for the
|
||||
/// singleton check. On success, wires close/destroy events to forget the
|
||||
/// window and stop the `kimi web` server this app spawned (reused servers are
|
||||
/// untouched).
|
||||
fn build_kimi_web_window(app: &tauri::AppHandle, url: &tauri::Url) -> Result<(), String> {
|
||||
let window = tauri::WebviewWindowBuilder::new(
|
||||
app,
|
||||
KIMI_WEB_WINDOW_LABEL,
|
||||
tauri::WebviewUrl::External(url.clone()),
|
||||
)
|
||||
.title("Kimi Code WebUI")
|
||||
.inner_size(1100.0, 750.0)
|
||||
.resizable(true)
|
||||
.center()
|
||||
.build()
|
||||
.map_err(|e| format!("failed to create kimi web window: {e}"))?;
|
||||
|
||||
// Track the window for the singleton check.
|
||||
if let Ok(mut guard) = app.state::<KimiWebState>().window.lock() {
|
||||
*guard = Some(window.clone());
|
||||
}
|
||||
// Clean up when the window closes: forget it and stop the `kimi web`
|
||||
// server we spawned (reused servers are untouched).
|
||||
let w = window.clone();
|
||||
window.on_window_event(move |event| match event {
|
||||
tauri::WindowEvent::Destroyed => {
|
||||
let app = w.app_handle().clone();
|
||||
let state = app.state::<KimiWebState>();
|
||||
if let Ok(mut guard) = state.window.lock() {
|
||||
*guard = None;
|
||||
}
|
||||
kill_spawned_kimi_web(&state);
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@@ -1293,24 +1338,42 @@ pub async fn kimi_oauth_poll(
|
||||
// Experimental feature env-var probe (Kimi Code secondary model etc.)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Read the Kimi Code experimental-feature environment variables that are
|
||||
/// currently set (non-empty) in this process's environment. The frontend
|
||||
/// uses the result to mark config.toml `[experimental]` toggles as locked:
|
||||
/// env vars outrank the config file in Kimi Code's flag resolution.
|
||||
/// Read the Kimi Code environment variables that are currently set (non-empty)
|
||||
/// in this process's environment. Every `[experimental]` feature env var is
|
||||
/// probed — the frontend uses a per-feature env var to mark the matching
|
||||
/// config.toml `[experimental]` toggle as locked, since a single-feature env
|
||||
/// var outranks the config file in Kimi Code's flag resolution. The master
|
||||
/// `KIMI_CODE_EXPERIMENTAL_FLAG` (0.40.1+: only force-ONs flags without an
|
||||
/// explicit config entry; never locks a toggle) and `KIMI_CODE_LEGACY_FLAG`
|
||||
/// are probed too — the latter lets AgentSettingsPanel decide whether to
|
||||
/// dual-write the v1 engine's loop_control keys.
|
||||
///
|
||||
/// The flag id → env var mapping mirrors `EXPERIMENTAL_FLAGS` in
|
||||
/// src/lib/subagent-settings.ts; keep both lists in sync.
|
||||
///
|
||||
/// Truthy values per the CLI: `1` / `true` / `yes` / `on` (case-insensitive);
|
||||
/// the raw values are returned and the truthy check happens on the frontend.
|
||||
#[tauri::command]
|
||||
pub fn get_experimental_env_status() -> HashMap<String, String> {
|
||||
const VARS: [&str; 5] = [
|
||||
"KIMI_CODE_EXPERIMENTAL_FLAG",
|
||||
"KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL",
|
||||
"KIMI_CODE_EXPERIMENTAL_TOOL_SELECT",
|
||||
"KIMI_CODE_EXPERIMENTAL_ACP_V2",
|
||||
"KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL",
|
||||
const VARS: [(&str, &str); 11] = [
|
||||
("secondary-model", "KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL"),
|
||||
("tool-select", "KIMI_CODE_EXPERIMENTAL_TOOL_SELECT"),
|
||||
(
|
||||
"persistence_minidb_readmodel",
|
||||
"KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL",
|
||||
),
|
||||
("tower", "KIMI_CODE_EXPERIMENTAL_TOWER"),
|
||||
("subagent_fork", "KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK"),
|
||||
("wait_for", "KIMI_CODE_EXPERIMENTAL_WAIT_FOR"),
|
||||
("auto_session_title", "KIMI_CODE_EXPERIMENTAL_AUTO_SESSION_TITLE"),
|
||||
("remote-control", "KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL"),
|
||||
("search_worker", "KIMI_CODE_EXPERIMENTAL_SEARCH_WORKER"),
|
||||
// Non-flag probes consumed by the frontend:
|
||||
("master", "KIMI_CODE_EXPERIMENTAL_FLAG"),
|
||||
("legacy", "KIMI_CODE_LEGACY_FLAG"),
|
||||
];
|
||||
let mut out = HashMap::new();
|
||||
for name in VARS {
|
||||
for (_, name) in VARS {
|
||||
if let Ok(value) = std::env::var(name) {
|
||||
if !value.trim().is_empty() {
|
||||
out.insert(name.to_string(), value);
|
||||
|
||||
+489
-55
@@ -102,6 +102,9 @@ pub struct RangeStats {
|
||||
pub totals: TotalsRow,
|
||||
pub daily: Vec<DailyRow>,
|
||||
pub models: Vec<ModelRow>,
|
||||
/// Model totals keyed by bare model name (provider prefix stripped), so the
|
||||
/// same model served by multiple providers merges into one row.
|
||||
pub models_by_name: Vec<ModelRow>,
|
||||
pub recent: Vec<RecentRow>,
|
||||
pub recent_total: usize,
|
||||
pub recent_limit: usize,
|
||||
@@ -976,6 +979,7 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats {
|
||||
totals: TotalsRow::default(),
|
||||
daily: vec![],
|
||||
models: vec![],
|
||||
models_by_name: vec![],
|
||||
recent: vec![],
|
||||
recent_total: 0,
|
||||
recent_limit: 500,
|
||||
@@ -985,6 +989,7 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats {
|
||||
let mut totals = TotalsRow::default();
|
||||
let mut by_day: HashMap<String, (TotalsRow, HashMap<String, TotalsRow>, HashMap<String, HashMap<String, u64>>)> = HashMap::new();
|
||||
let mut by_model: HashMap<String, (ModelRow, TotalsRow)> = HashMap::new();
|
||||
let mut by_name: HashMap<String, (ModelRow, TotalsRow)> = HashMap::new();
|
||||
|
||||
for r in &filtered {
|
||||
totals.add(r);
|
||||
@@ -1017,6 +1022,28 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats {
|
||||
entry.1.add(r);
|
||||
entry.0.cost_estimated = entry.0.cost_estimated || r.cost_estimated;
|
||||
entry.0.is_secondary = entry.0.is_secondary || r.is_secondary;
|
||||
|
||||
// Provider-agnostic bucket: secondary (subagent) records keep the
|
||||
// stable "__secondary__" marker; everything else keys on the bare
|
||||
// model name so the same model across providers merges into one row.
|
||||
let nk = if r.is_secondary { "__secondary__".to_string() } else { r.model_resolved.clone() };
|
||||
let name_entry = by_name.entry(nk.clone()).or_insert_with(|| {
|
||||
let m = ModelRow {
|
||||
model: nk.clone(),
|
||||
model_display: nk.clone(),
|
||||
model_resolved: nk.clone(),
|
||||
price_id: r.price_id.clone(),
|
||||
cost_estimated: r.cost_estimated,
|
||||
is_secondary: r.is_secondary,
|
||||
requests: 0, input_other: 0, output: 0,
|
||||
input_cache_read: 0, input_cache_creation: 0,
|
||||
cost_usd: 0.0, total_tokens: 0, cache_hit_rate: 0.0,
|
||||
};
|
||||
(m, TotalsRow::default())
|
||||
});
|
||||
name_entry.1.add(r);
|
||||
name_entry.0.cost_estimated = name_entry.0.cost_estimated || r.cost_estimated;
|
||||
name_entry.0.is_secondary = name_entry.0.is_secondary || r.is_secondary;
|
||||
}
|
||||
|
||||
let total_input = totals.input_other + totals.input_cache_read + totals.input_cache_creation;
|
||||
@@ -1063,6 +1090,17 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats {
|
||||
}).collect();
|
||||
models.sort_by(|a, b| b.total_tokens.cmp(&a.total_tokens));
|
||||
|
||||
let mut models_by_name: Vec<ModelRow> = by_name.into_iter().map(|(_, (mut m, t))| {
|
||||
let ti = t.input_other + t.input_cache_read + t.input_cache_creation;
|
||||
m.cache_hit_rate = if ti > 0 { t.input_cache_read as f64 / ti as f64 } else { 0.0 };
|
||||
m.requests = t.requests;
|
||||
m.input_other = t.input_other; m.output = t.output;
|
||||
m.input_cache_read = t.input_cache_read; m.input_cache_creation = t.input_cache_creation;
|
||||
m.cost_usd = t.cost_usd; m.total_tokens = t.total_tokens;
|
||||
m
|
||||
}).collect();
|
||||
models_by_name.sort_by(|a, b| b.total_tokens.cmp(&a.total_tokens));
|
||||
|
||||
let recent_total = filtered.len();
|
||||
let recent_limit = 500;
|
||||
let recent: Vec<RecentRow> = filtered.iter().take(recent_limit).map(|r| RecentRow {
|
||||
@@ -1081,6 +1119,7 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats {
|
||||
totals,
|
||||
daily,
|
||||
models,
|
||||
models_by_name,
|
||||
recent,
|
||||
recent_total,
|
||||
recent_limit,
|
||||
@@ -1109,24 +1148,40 @@ impl TotalsRow {
|
||||
fn filter_by_range<'a>(records: &'a [UsageRecord], range: &str, now_ms: u64) -> Vec<&'a UsageRecord> {
|
||||
if range == "all" { return records.iter().collect(); }
|
||||
let start = range_start(range, now_ms);
|
||||
records.iter().filter(|r| r.time >= start).collect()
|
||||
match range_end(range, now_ms) {
|
||||
Some(end) => records.iter().filter(|r| r.time >= start && r.time < end).collect(),
|
||||
None => records.iter().filter(|r| r.time >= start).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Upper bound (exclusive) for bounded ranges. Only "yesterday" has one —
|
||||
/// it must not bleed into today.
|
||||
fn range_end(range: &str, now_ms: u64) -> Option<u64> {
|
||||
match range {
|
||||
"yesterday" => Some(local_midnight_ms(now_ms, 0)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// LOCAL midnight `days_ago` days before today, in epoch ms. Not UTC midnight:
|
||||
/// without local-midnight anchoring, users east of UTC see day boundaries at
|
||||
/// the wrong hour (e.g. UTC+8 users get cutoff at local 08:00).
|
||||
fn local_midnight_ms(now_ms: u64, days_ago: i64) -> u64 {
|
||||
let now_utc = DateTime::from_timestamp((now_ms / 1000) as i64, 0).unwrap_or_default();
|
||||
let now_local = now_utc.with_timezone(&Local);
|
||||
let date = now_local.date_naive() - chrono::Duration::days(days_ago);
|
||||
let day_start = date.and_hms_opt(0, 0, 0).unwrap_or_default();
|
||||
Local
|
||||
.from_local_datetime(&day_start)
|
||||
.single()
|
||||
.map(|dt| dt.timestamp() as u64 * 1000)
|
||||
.unwrap_or_else(|| day_start.and_utc().timestamp() as u64 * 1000)
|
||||
}
|
||||
|
||||
fn range_start(range: &str, now_ms: u64) -> u64 {
|
||||
match range {
|
||||
"today" => {
|
||||
// LOCAL midnight today — not UTC midnight. Without this, users east of UTC see
|
||||
// "today" begin at the wrong hour (e.g. UTC+8 users get cutoff at local 08:00).
|
||||
let now_utc = DateTime::from_timestamp((now_ms / 1000) as i64, 0).unwrap_or_default();
|
||||
let now_local = now_utc.with_timezone(&Local);
|
||||
let today_date = now_local.date_naive();
|
||||
let today_start = today_date.and_hms_opt(0, 0, 0).unwrap_or_default();
|
||||
Local
|
||||
.from_local_datetime(&today_start)
|
||||
.single()
|
||||
.map(|dt| dt.timestamp() as u64 * 1000)
|
||||
.unwrap_or_else(|| today_start.and_utc().timestamp() as u64 * 1000)
|
||||
}
|
||||
"today" => local_midnight_ms(now_ms, 0),
|
||||
"yesterday" => local_midnight_ms(now_ms, 1),
|
||||
"7d" => now_ms - 7 * 24 * 3600 * 1000,
|
||||
"30d" => now_ms - 30 * 24 * 3600 * 1000,
|
||||
_ => now_ms - 30 * 24 * 3600 * 1000,
|
||||
@@ -1234,6 +1289,49 @@ fn read_state_safe(session_dir: &Path) -> (Option<String>, Option<String>, Optio
|
||||
(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 {
|
||||
let re = Regex::new(r"^wd_(.+)_[0-9a-fA-F]{8,}$").unwrap();
|
||||
if let Some(caps) = re.captures(id) {
|
||||
@@ -1243,7 +1341,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();
|
||||
if !dir.exists() { return sessions; }
|
||||
let re = session_re();
|
||||
@@ -1251,6 +1353,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; }
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
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 (bytes, files) = file_size_approx(&entry.path());
|
||||
let mtime = entry.path().metadata().ok().and_then(|m| m.modified().ok())
|
||||
@@ -1311,19 +1415,33 @@ fn list_sessions_cmd(home: &Path, status: &str, workspace_filter: Option<String>
|
||||
|
||||
let active_dir = root.join(wid);
|
||||
let arch_dir = archive_root.join(wid);
|
||||
let active_all = list_sessions_in_dir(&active_dir, wid, "active");
|
||||
let arch_all = list_sessions_in_dir(&arch_dir, wid, "archived");
|
||||
let active_list = if status == "archived" { vec![] } else { active_all.clone() };
|
||||
let arch_list = if status == "active" { vec![] } else { arch_all.clone() };
|
||||
// Merge both sources, deduping by session id (ids are UUIDs and never
|
||||
// reused; a stale legacy copy under .kcd-archive loses to the entry at
|
||||
// the active root). Status comes from each row, so metadata-archived
|
||||
// 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 {
|
||||
id: wid.clone(), name, root: root_path,
|
||||
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(arch_list);
|
||||
all_sessions.extend(listed);
|
||||
}
|
||||
|
||||
all_sessions.sort_by(|a, b| {
|
||||
@@ -1356,38 +1474,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> {
|
||||
let src = assert_safe_path(home, workspace_id, session_id)?;
|
||||
let dest = sessions_root(home).join(".kcd-archive").join(workspace_id).join(session_id);
|
||||
if !src.exists() { return Err("session not found".into()); }
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!("mkdir: {}", e))?;
|
||||
let dir = assert_safe_path(home, workspace_id, session_id)?;
|
||||
if !dir.exists() { return Err("session not found".into()); }
|
||||
// Metadata archive, matching upstream kimi-code v2: write `archived: true`
|
||||
// + `archivedAt` into state.json, leave the session directory in place, and
|
||||
// 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(|_| {
|
||||
// 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);
|
||||
set_session_archived_meta(&dir, true)?;
|
||||
Ok(ActionResponse {
|
||||
ok: true, workspace_id: workspace_id.to_string(),
|
||||
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> {
|
||||
let src = sessions_root(home).join(".kcd-archive").join(workspace_id).join(session_id);
|
||||
let dest = assert_safe_path(home, workspace_id, session_id)?;
|
||||
if !src.exists() { return Err("archived session not found".into()); }
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!("mkdir: {}", e))?;
|
||||
// 1) Legacy recovery: sessions physically moved to .kcd-archive by older
|
||||
// KimiSwitch versions are moved back, then their metadata flag is
|
||||
// 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(|_| {
|
||||
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))?;
|
||||
// 2) Metadata-archived session: still at the active root, just clear the flag.
|
||||
if !dest.exists() { return Err("archived session not found".into()); }
|
||||
set_session_archived_meta(&dest, false)?;
|
||||
Ok(ActionResponse {
|
||||
ok: true, workspace_id: workspace_id.to_string(),
|
||||
session_id: session_id.to_string(), status: Some("active".into()),
|
||||
@@ -1398,6 +1535,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> {
|
||||
let active = assert_safe_path(home, workspace_id, 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 {
|
||||
Some("archived") if archived.exists() => archived,
|
||||
Some("active") if active.exists() => active,
|
||||
@@ -1440,11 +1580,11 @@ fn delete_workspace_cmd(home: &Path, workspace_id: &str, _confirm: bool, _force:
|
||||
let arch_dir = root.join(".kcd-archive").join(workspace_id);
|
||||
|
||||
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 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()); }
|
||||
}
|
||||
|
||||
@@ -1546,6 +1686,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 session_dir = match status_hint {
|
||||
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 }
|
||||
else { return Err("session not found".into()); }
|
||||
};
|
||||
@@ -1666,7 +1810,11 @@ fn get_session_preview_cmd(home: &Path, workspace_id: &str, session_id: &str, st
|
||||
Ok(PreviewResult {
|
||||
workspace_id: workspace_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,
|
||||
message_count: messages.len(),
|
||||
truncated,
|
||||
@@ -1743,7 +1891,7 @@ pub fn get_summary(home_override: Option<String>, range: Option<String>, refresh
|
||||
|
||||
let all_model_count = all_models.len();
|
||||
let mut range_totals = HashMap::new();
|
||||
for r_k in ["today", "7d", "30d", "all"] {
|
||||
for r_k in ["today", "yesterday", "7d", "30d", "all"] {
|
||||
let s = aggregate(&records, r_k, now_ms);
|
||||
range_totals.insert(r_k.to_string(), s.totals);
|
||||
}
|
||||
@@ -1884,10 +2032,10 @@ mod pricing_tests {
|
||||
let idx = models_dev_cost_index();
|
||||
// The compiled-in snapshot must carry real models.dev prices.
|
||||
assert!(idx.len() > 1000, "expected >1000 priced models, got {}", idx.len());
|
||||
let kimi = idx.get("moonshotai/kimi-k2.5").copied().expect("kimi-k2.5 present");
|
||||
assert_eq!(kimi.input, 0.6);
|
||||
assert_eq!(kimi.output, 3.0);
|
||||
assert_eq!(kimi.cache_read, 0.1);
|
||||
let kimi = idx.get("moonshotai/kimi-k3").copied().expect("kimi-k3 present");
|
||||
assert_eq!(kimi.input, 3.0);
|
||||
assert_eq!(kimi.output, 15.0);
|
||||
assert_eq!(kimi.cache_read, 0.3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1899,9 +2047,9 @@ mod pricing_tests {
|
||||
assert_eq!(input, 0.6);
|
||||
assert_eq!(output, 2.2);
|
||||
|
||||
let (id, _, _, _, est) = match_price("kimi/k2.5");
|
||||
let (id, _, _, _, est) = match_price("kimi/k3");
|
||||
assert!(!est);
|
||||
assert_eq!(id, "moonshotai/kimi-k2.5");
|
||||
assert_eq!(id, "moonshotai/kimi-k3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1998,3 +2146,289 @@ mod pricing_tests {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod range_and_model_totals_tests {
|
||||
use super::*;
|
||||
|
||||
fn rec(time: u64, model: &str, resolved: &str, secondary: bool) -> UsageRecord {
|
||||
UsageRecord {
|
||||
time,
|
||||
model: model.to_string(),
|
||||
input_other: 100,
|
||||
output: 50,
|
||||
input_cache_read: 50,
|
||||
input_cache_creation: 0,
|
||||
cost_usd: 0.01,
|
||||
cost_estimated: false,
|
||||
price_id: String::new(),
|
||||
model_resolved: resolved.to_string(),
|
||||
model_display: model.to_string(),
|
||||
provider: None,
|
||||
from_env: false,
|
||||
is_secondary: secondary,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yesterday_range_is_bounded_to_yesterday() {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64;
|
||||
let today_start = local_midnight_ms(now, 0);
|
||||
let yesterday_start = local_midnight_ms(now, 1);
|
||||
assert!(yesterday_start < today_start, "yesterday midnight precedes today's");
|
||||
assert_eq!(range_end("yesterday", now), Some(today_start));
|
||||
assert_eq!(range_end("today", now), None);
|
||||
assert_eq!(range_end("7d", now), None);
|
||||
|
||||
let records = vec![
|
||||
rec(yesterday_start + 1, "a/m1", "m1", false), // yesterday → in
|
||||
rec(today_start + 1, "a/m1", "m1", false), // today → out
|
||||
rec(yesterday_start.saturating_sub(1), "a/m1", "m1", false), // before → out
|
||||
];
|
||||
let stats = aggregate(&records, "yesterday", now);
|
||||
assert_eq!(stats.totals.requests, 1, "only yesterday's record counts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn models_by_name_merges_providers_and_keeps_secondary() {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64;
|
||||
let records = vec![
|
||||
rec(now, "openai/gpt-x", "gpt-x", false),
|
||||
rec(now, "azure/gpt-x", "gpt-x", false),
|
||||
rec(now, "__secondary__", "__secondary__", true),
|
||||
];
|
||||
let stats = aggregate(&records, "all", now);
|
||||
|
||||
// by_model keeps provider-qualified rows separate
|
||||
assert_eq!(stats.models.len(), 3);
|
||||
// by_name merges the two gpt-x rows, subagent stays its own row
|
||||
assert_eq!(stats.models_by_name.len(), 2);
|
||||
let gptx = stats.models_by_name.iter().find(|m| m.model == "gpt-x").expect("merged gpt-x row");
|
||||
assert_eq!(gptx.requests, 2);
|
||||
assert_eq!(gptx.total_tokens, 2 * 200);
|
||||
let sub = stats.models_by_name.iter().find(|m| m.model == "__secondary__").expect("secondary row");
|
||||
assert!(sub.is_secondary);
|
||||
// sorted by total_tokens desc — every record has equal tokens here, so
|
||||
// just assert the set is complete and totals add up.
|
||||
let total: u64 = stats.models_by_name.iter().map(|m| m.total_tokens).sum();
|
||||
assert_eq!(total, 3 * 200);
|
||||
}
|
||||
}
|
||||
@@ -236,6 +236,30 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// opencode Console Go (the "Go 套餐" gateway) rejects requests that lack an
|
||||
/// `x-opencode-session` header (400: required for routing and prompt-cache
|
||||
/// affinity). When exporting a provider pointing at the Go endpoint, ensure
|
||||
/// the header exists; an existing user-set value is kept as-is.
|
||||
fn ensure_opencode_go_session_header(provider: &Provider, pt: &mut Table) {
|
||||
let is_go = provider
|
||||
.base_url
|
||||
.as_deref()
|
||||
.map(|u| u.trim_end_matches('/').starts_with("https://opencode.ai/zen/go"))
|
||||
.unwrap_or(false);
|
||||
if !is_go {
|
||||
return;
|
||||
}
|
||||
let mut headers = pt
|
||||
.get("custom_headers")
|
||||
.and_then(|v| v.as_table())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
headers
|
||||
.entry("x-opencode-session".to_string())
|
||||
.or_insert_with(|| TomlValue::String("kimi-switch".to_string()));
|
||||
pt.insert("custom_headers".to_string(), TomlValue::Table(headers));
|
||||
}
|
||||
|
||||
/// Export Kimi Switch's internal `Config` to a Kimi Code TOML config value.
|
||||
///
|
||||
/// If `existing` is provided, unknown top-level sections (e.g. `services`) are
|
||||
@@ -316,6 +340,7 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
|
||||
pt.insert(k, v);
|
||||
}
|
||||
}
|
||||
ensure_opencode_go_session_header(provider, &mut pt);
|
||||
providers_table.insert(name.clone(), TomlValue::Table(pt));
|
||||
}
|
||||
root.insert("providers".to_string(), TomlValue::Table(providers_table));
|
||||
@@ -591,6 +616,70 @@ api_key = ""
|
||||
assert!(caps.iter().any(|v| v.as_str() == Some("thinking")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kimi_code_export_adds_opencode_go_session_header() {
|
||||
let make_provider = |base_url: &str, raw_other: Value| Provider {
|
||||
name: "p".to_string(),
|
||||
provider_type: ProviderType::Openai,
|
||||
base_url: Some(base_url.to_string()),
|
||||
api_key: Some("sk-test".to_string()),
|
||||
env: IndexMap::new(),
|
||||
note: None,
|
||||
official_url: None,
|
||||
managed: false,
|
||||
enabled: true,
|
||||
active: false,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
raw_other,
|
||||
usage_kinds: None,
|
||||
usage_config: None,
|
||||
};
|
||||
let export = |p: Provider| {
|
||||
let mut providers = IndexMap::new();
|
||||
providers.insert("p".to_string(), p);
|
||||
let config = Config {
|
||||
default_model: None,
|
||||
providers,
|
||||
models: IndexMap::new(),
|
||||
raw_other: Value::Null,
|
||||
imported_section_keys: Vec::new(),
|
||||
};
|
||||
let exported = config_to_kimi_code(&config, None);
|
||||
let root = exported.as_table().unwrap().clone();
|
||||
root.get("providers")
|
||||
.and_then(|v| v.as_table())
|
||||
.and_then(|t| t.get("p"))
|
||||
.and_then(|v| v.as_table().cloned())
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// Go endpoint without headers → header injected.
|
||||
let p = export(make_provider("https://opencode.ai/zen/go/v1", Value::Null));
|
||||
let headers = p.get("custom_headers").unwrap().as_table().unwrap();
|
||||
assert_eq!(
|
||||
headers.get("x-opencode-session").and_then(|v| v.as_str()),
|
||||
Some("kimi-switch")
|
||||
);
|
||||
|
||||
// Existing user-set value is preserved, not overwritten.
|
||||
let raw = serde_json::json!({
|
||||
"custom_headers": { "x-opencode-session": "my-session" }
|
||||
});
|
||||
let p = export(make_provider("https://opencode.ai/zen/go/v1", raw));
|
||||
let headers = p.get("custom_headers").unwrap().as_table().unwrap();
|
||||
assert_eq!(
|
||||
headers.get("x-opencode-session").and_then(|v| v.as_str()),
|
||||
Some("my-session")
|
||||
);
|
||||
|
||||
// PAYG zen endpoint and unrelated providers are left alone.
|
||||
let p = export(make_provider("https://opencode.ai/zen/v1", Value::Null));
|
||||
assert!(p.get("custom_headers").is_none());
|
||||
let p = export(make_provider("https://api.deepseek.com/v1", Value::Null));
|
||||
assert!(p.get("custom_headers").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kimi_code_export_syncs_raw_other_top_level_sections() {
|
||||
// Regression: UI-managed top-level sections ([thinking],
|
||||
|
||||
@@ -97,6 +97,11 @@ pub fn detect_provider(base_url: &str) -> Vec<UsageKind> {
|
||||
if url.contains("openrouter.ai") {
|
||||
kinds.push(UsageKind::BalanceOpenrouter);
|
||||
}
|
||||
// codingplan.site 检测为 NewAPI 实例(本工具仅支持 NewAPI 查询);
|
||||
// 家族匹配意味着 ai. 子域也自然命中该规则。
|
||||
if url.contains("codingplan.site") {
|
||||
kinds.push(UsageKind::BalanceNewapi);
|
||||
}
|
||||
if url.contains("api.stepfun.com") {
|
||||
kinds.push(UsageKind::BalanceStepfun);
|
||||
}
|
||||
@@ -126,6 +131,19 @@ pub fn detect_provider(base_url: &str) -> Vec<UsageKind> {
|
||||
kinds
|
||||
}
|
||||
|
||||
/// BalanceNewapi 模板所需凭据(web 控制台 access token + user id)。
|
||||
/// 两者都必须存在且非空;返回 None 表示缺少凭据(确定性配置错误)。
|
||||
fn newapi_creds(
|
||||
usage_config: Option<&crate::models::UsageConfig>,
|
||||
) -> Option<(&str, &str)> {
|
||||
usage_config.and_then(|c| {
|
||||
c.access_token
|
||||
.as_deref()
|
||||
.zip(c.user_id.as_deref())
|
||||
.filter(|(t, u)| !t.is_empty() && !u.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
/// 按 kind 路由到对应查询实现。`base_url` 用于消歧同一家供应商的
|
||||
/// 国内/海外站(SiliconFlow .cn/.com、MiniMax .com/.io、智谱 bigmodel/z.ai)。
|
||||
/// `usage_config` 仅 BalanceNewapi 分支读取(access_token / user_id),
|
||||
@@ -155,16 +173,13 @@ pub async fn query_kind(
|
||||
balance::query_kimi(api_key, !lower.contains("moonshot.ai"), timeout).await
|
||||
}
|
||||
UsageKind::BalanceNewapi => {
|
||||
let (token, uid) = usage_config
|
||||
.and_then(|c| {
|
||||
c.access_token
|
||||
.as_deref()
|
||||
.zip(c.user_id.as_deref())
|
||||
.filter(|(t, u)| !t.is_empty() && !u.is_empty())
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
"newapi template requires accessToken and userId".to_string()
|
||||
})?;
|
||||
// 缺凭据是确定性配置错误 → Ok(failure)(前端按前缀本地化提示),
|
||||
// 而非 Err(Err 语义 = 瞬时网络错误,见 commands.rs newapi 分支)。
|
||||
let Some((token, uid)) = newapi_creds(usage_config) else {
|
||||
return Ok(UsageResult::failure(
|
||||
"newapi template requires accessToken and userId".to_string(),
|
||||
));
|
||||
};
|
||||
let url = usage_config
|
||||
.and_then(|c| c.base_url.as_deref())
|
||||
.filter(|s| !s.is_empty())
|
||||
@@ -262,6 +277,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_provider_codingplan_site_family() {
|
||||
// codingplan.site 是 NewAPI 实例(本工具仅支持 NewAPI 查询):
|
||||
// 主域与 ai. 子域都命中家族规则,且只映射到 BalanceNewapi。
|
||||
assert_eq!(
|
||||
detect_provider("https://codingplan.site"),
|
||||
vec![UsageKind::BalanceNewapi]
|
||||
);
|
||||
assert_eq!(
|
||||
detect_provider("https://codingplan.site/v1"),
|
||||
vec![UsageKind::BalanceNewapi]
|
||||
);
|
||||
assert_eq!(
|
||||
detect_provider("https://ai.codingplan.site"),
|
||||
vec![UsageKind::BalanceNewapi]
|
||||
);
|
||||
assert_eq!(
|
||||
detect_provider("https://ai.codingplan.site/v1"),
|
||||
vec![UsageKind::BalanceNewapi]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_kind_string_roundtrip() {
|
||||
use std::str::FromStr;
|
||||
@@ -272,4 +309,30 @@ mod tests {
|
||||
assert!(UsageKind::from_str("balance:unknown").is_err());
|
||||
assert!(UsageKind::from_str("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newapi_creds_requires_both_and_nonempty() {
|
||||
use crate::models::UsageConfig;
|
||||
let cfg = |token: Option<&str>, uid: Option<&str>| UsageConfig {
|
||||
enabled: true,
|
||||
template_type: "newapi".to_string(),
|
||||
base_url: None,
|
||||
access_token: token.map(str::to_string),
|
||||
user_id: uid.map(str::to_string),
|
||||
auto_query_interval_minutes: None,
|
||||
timeout_seconds: None,
|
||||
};
|
||||
// 缺 config / 缺任一项 / 空字符串 → None(确定性配置错误)
|
||||
assert_eq!(newapi_creds(None), None);
|
||||
assert_eq!(newapi_creds(Some(&cfg(None, None))), None);
|
||||
assert_eq!(newapi_creds(Some(&cfg(Some("tok"), None))), None);
|
||||
assert_eq!(newapi_creds(Some(&cfg(None, Some("uid")))), None);
|
||||
assert_eq!(newapi_creds(Some(&cfg(Some(""), Some("uid")))), None);
|
||||
assert_eq!(newapi_creds(Some(&cfg(Some("tok"), Some("")))), None);
|
||||
// 两项齐全 → Some
|
||||
assert_eq!(
|
||||
newapi_creds(Some(&cfg(Some("tok"), Some("uid")))),
|
||||
Some(("tok", "uid"))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"productName": "Kimi Switch",
|
||||
"version": "0.7.10",
|
||||
"version": "0.7.15",
|
||||
"identifier": "com.kimiswitch.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useTranslation } from "../i18n";
|
||||
import type { TranslationKey } from "../i18n/zh";
|
||||
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";
|
||||
|
||||
interface AgentSettingsPanelProps {
|
||||
@@ -9,12 +16,12 @@ interface AgentSettingsPanelProps {
|
||||
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> = {
|
||||
low: "thinkingLow",
|
||||
medium: "thinkingMedium",
|
||||
high: "thinkingHigh",
|
||||
max: "thinkingMax",
|
||||
};
|
||||
|
||||
const PERMISSION_DECISIONS = ["allow", "deny", "ask"] as const;
|
||||
@@ -40,9 +47,23 @@ const COMMON_EVENTS = [
|
||||
export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
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>) => {
|
||||
onChange(setAgentSettings(rawOther, patch));
|
||||
onChange(setAgentSettings(rawOther, patch, { legacyV1 }));
|
||||
};
|
||||
|
||||
const updateThinking = (patch: Partial<AgentSettings["thinking"]>) => {
|
||||
@@ -95,7 +116,7 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
|
||||
label={t("thinkingKeep")}
|
||||
checked={settings.thinking?.keep === "all"}
|
||||
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>
|
||||
</Card>
|
||||
@@ -136,6 +157,28 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
|
||||
</Card>
|
||||
|
||||
<Card title={t("permissionRules")}>
|
||||
{/* kimi-code `[permission] dangerous_command_guard`. The env
|
||||
var KIMI_CODE_DANGEROUS_COMMAND_GUARD (literal "true"/"false")
|
||||
outranks this config at runtime; no env-lock UI here (deliberate
|
||||
minimal scope). Absent key = upstream default on. Since
|
||||
kimi-code 0.41.0 the guard only gates Manual/YOLO modes —
|
||||
Auto (never-ask) mode no longer intercepts dangerous
|
||||
commands. */}
|
||||
<Checkbox
|
||||
label={t("permissionDangerousGuard")}
|
||||
checked={settings.permission?.dangerous_command_guard ?? true}
|
||||
onChange={(checked) =>
|
||||
update({
|
||||
permission: {
|
||||
...settings.permission,
|
||||
dangerous_command_guard: checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-content-muted">
|
||||
{t("permissionDangerousGuardDesc")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{(settings.permission?.rules ?? []).map((rule, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
|
||||
@@ -69,7 +69,8 @@ function ProviderCard({
|
||||
agent,
|
||||
provider.name,
|
||||
provider.usageKinds,
|
||||
provider.usageConfig?.autoQueryIntervalMinutes
|
||||
provider.usageConfig?.autoQueryIntervalMinutes,
|
||||
provider.usageConfig?.enabled === false
|
||||
);
|
||||
const providerModels = Object.values(models).filter(
|
||||
(m) => m.provider === provider.name
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
forcedEnvValue,
|
||||
getExperimentalFlags,
|
||||
getSubagentModelPool,
|
||||
isExperimentalFlagSet,
|
||||
isFlagLockedByEnv,
|
||||
isMasterEnvOn,
|
||||
removeSubagentPoolEntry,
|
||||
@@ -33,12 +34,12 @@ interface SubagentSettingsPageProps {
|
||||
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> = {
|
||||
low: "thinkingLow",
|
||||
medium: "thinkingMedium",
|
||||
high: "thinkingHigh",
|
||||
max: "thinkingMax",
|
||||
};
|
||||
|
||||
const FLAG_LABELS: Record<string, { name: TranslationKey; desc: TranslationKey }> = {
|
||||
@@ -55,6 +56,8 @@ const FLAG_LABELS: Record<string, { name: TranslationKey; desc: TranslationKey }
|
||||
name: "flagAutoSessionTitle",
|
||||
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. */
|
||||
@@ -160,6 +163,8 @@ export function SubagentSettingsPage({
|
||||
const [poolErrors, setPoolErrors] = useState<PoolValidationError[]>([]);
|
||||
/** Local draft for the "add pool entry" select (reset after each pick). */
|
||||
const [addSelection, setAddSelection] = useState("");
|
||||
/** WebUI-open button in flight; disables both buttons while non-null. */
|
||||
const [webuiBusy, setWebuiBusy] = useState<"embedded" | "browser" | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
invoke<ExperimentalEnvStatus>("get_experimental_env_status")
|
||||
@@ -183,13 +188,18 @@ export function SubagentSettingsPage({
|
||||
const secondaryFlag = EXPERIMENTAL_FLAGS[0]; // "secondary-model"
|
||||
const otherFlags = EXPERIMENTAL_FLAGS.slice(1);
|
||||
|
||||
/** Effective state of the secondary-model flag (env overrides config). */
|
||||
const secondaryEnabled = masterOn
|
||||
? true
|
||||
: isFlagLockedByEnv(env, secondaryFlag)
|
||||
? forcedEnvValue(env, secondaryFlag)
|
||||
: flags["secondary-model"] === true;
|
||||
const secondaryLocked = masterOn || isFlagLockedByEnv(env, secondaryFlag);
|
||||
/** Effective state of the secondary-model flag. Mirrors the kimi-code
|
||||
* 0.40.1 priority: single-flag env > explicit [experimental] value >
|
||||
* master env (force-on only) > upstream default. The master env no
|
||||
* longer locks the toggle — only the flag's own env var does. */
|
||||
const secondaryEnabled = isFlagLockedByEnv(env, secondaryFlag)
|
||||
? forcedEnvValue(env, secondaryFlag)
|
||||
: isExperimentalFlagSet(rawOther, secondaryFlag.id)
|
||||
? flags["secondary-model"] === true
|
||||
: masterOn
|
||||
? true
|
||||
: (secondaryFlag.defaultEnabled ?? false);
|
||||
const secondaryLocked = isFlagLockedByEnv(env, secondaryFlag);
|
||||
|
||||
const aliasList = Object.keys(models).sort();
|
||||
|
||||
@@ -235,7 +245,9 @@ export function SubagentSettingsPage({
|
||||
? `${Math.round(n / 1000)}K`
|
||||
: String(n);
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -296,16 +308,35 @@ export function SubagentSettingsPage({
|
||||
|
||||
const renderFlagRow = (flag: ExperimentalFlagDef) => {
|
||||
const labels = FLAG_LABELS[flag.id];
|
||||
const locked = masterOn || isFlagLockedByEnv(env, flag);
|
||||
const on = masterOn
|
||||
? true
|
||||
: isFlagLockedByEnv(env, flag)
|
||||
? forcedEnvValue(env, flag)
|
||||
: flags[flag.id] === true;
|
||||
// Only the flag's own env var locks the toggle. The master env force-ons
|
||||
// undefined flags but an explicit config entry outranks it, so the user
|
||||
// can still flip values here while it is set.
|
||||
const locked = isFlagLockedByEnv(env, flag);
|
||||
// Resolution order mirrors kimi-code 0.40.1 flagService:
|
||||
// single-flag env > explicit [experimental] value (true *or* false) >
|
||||
// master env (on-only) > upstream default (`defaultEnabled`).
|
||||
const explicitlySet = isExperimentalFlagSet(rawOther, flag.id);
|
||||
const on = locked
|
||||
? forcedEnvValue(env, flag)
|
||||
: explicitlySet
|
||||
? flags[flag.id] === true
|
||||
: masterOn
|
||||
? true
|
||||
: (flag.defaultEnabled ?? false);
|
||||
// Turning the flag off only sticks via an explicit `false` when the
|
||||
// fallback (master env or the upstream default) would otherwise keep it on.
|
||||
const explicitFalse = masterOn || flag.defaultEnabled === true;
|
||||
return (
|
||||
<div key={flag.id} className="flex items-center gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<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">
|
||||
{t(labels.desc)}
|
||||
<code className="ml-2">{flag.envVar}</code>
|
||||
@@ -321,7 +352,9 @@ export function SubagentSettingsPage({
|
||||
disabled={locked}
|
||||
ariaLabel={t(labels.name)}
|
||||
onChange={(checked) =>
|
||||
onChange(setExperimentalFlag(rawOther, flag.id, checked))
|
||||
onChange(
|
||||
setExperimentalFlag(rawOther, flag.id, checked, { explicitFalse })
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -355,29 +388,37 @@ export function SubagentSettingsPage({
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={webuiBusy !== null}
|
||||
onClick={async () => {
|
||||
setWebuiBusy("embedded");
|
||||
try {
|
||||
await invoke("open_kimi_web_embedded");
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setWebuiBusy(null);
|
||||
}
|
||||
}}
|
||||
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{t("openWebUIEmbedded")}
|
||||
{webuiBusy === "embedded" ? t("webuiOpening") : t("openWebUIEmbedded")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={webuiBusy !== null}
|
||||
onClick={async () => {
|
||||
setWebuiBusy("browser");
|
||||
try {
|
||||
await invoke("open_kimi_web");
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setWebuiBusy(null);
|
||||
}
|
||||
}}
|
||||
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{t("openWebUIBrowser")}
|
||||
{webuiBusy === "browser" ? t("webuiOpening") : t("openWebUIBrowser")}
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -534,6 +575,16 @@ export function SubagentSettingsPage({
|
||||
<div className="border-t border-border" />
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 text-sm text-content-primary">
|
||||
{t(FLAG_LABELS[secondaryFlag.id].name)}
|
||||
{!secondaryLocked &&
|
||||
!isExperimentalFlagSet(rawOther, secondaryFlag.id) &&
|
||||
secondaryFlag.defaultEnabled && (
|
||||
<span className="shrink-0 text-xs text-blue-600 dark:text-blue-400 border border-blue-500/30 rounded px-1">
|
||||
{t("flagDefaultOn")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-content-muted">
|
||||
{t(FLAG_LABELS[secondaryFlag.id].desc)}
|
||||
<code className="ml-2">{secondaryFlag.envVar}</code>
|
||||
@@ -549,7 +600,11 @@ export function SubagentSettingsPage({
|
||||
disabled={secondaryLocked}
|
||||
ariaLabel={t(FLAG_LABELS[secondaryFlag.id].name)}
|
||||
onChange={(checked) =>
|
||||
onChange(setExperimentalFlag(rawOther, secondaryFlag.id, checked))
|
||||
onChange(
|
||||
setExperimentalFlag(rawOther, secondaryFlag.id, checked, {
|
||||
explicitFalse: masterOn || secondaryFlag.defaultEnabled === true,
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { ArrowLeft, Eye, EyeOff, Loader2, Play } from "lucide-react";
|
||||
import { useTranslation } from "../i18n";
|
||||
import type { TranslationKey } from "../i18n/zh";
|
||||
import { localizeUsageError, planLabel } from "../lib/usage-display";
|
||||
import { formatAmount, localizeUsageError, planLabel } from "../lib/usage-display";
|
||||
import type { Agent, Provider, UsageConfig } from "../types";
|
||||
|
||||
interface UsageResult {
|
||||
@@ -134,7 +134,7 @@ export function UsageConfigModal({ open, agent, provider, onClose, onSave }: Usa
|
||||
.map((d) => {
|
||||
const parts: string[] = [];
|
||||
if (d.planName) parts.push(planLabel(d.planName, t));
|
||||
if (d.remaining != null) parts.push(`${t("usageRemaining")} ${d.remaining}${d.unit ?? ""}`);
|
||||
if (d.remaining != null) parts.push(`${t("usageRemaining")} ${formatAmount(d.remaining, d.unit)}`);
|
||||
if (d.used != null && d.total != null && d.total > 0)
|
||||
parts.push(`${Math.round((d.used / d.total) * 100)}%`);
|
||||
return parts.join(" ");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Loader2, RefreshCw } from "lucide-react";
|
||||
import { useTranslation } from "../i18n";
|
||||
import { localizeUsageError, planLabel } from "../lib/usage-display";
|
||||
import { formatAmount, localizeUsageError, planLabel } from "../lib/usage-display";
|
||||
import type { UsageData, UsageQueryState } from "../hooks/useUsageQuery";
|
||||
|
||||
interface UsageFooterProps {
|
||||
@@ -36,17 +36,6 @@ export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) {
|
||||
return null;
|
||||
};
|
||||
|
||||
const formatAmount = (
|
||||
value: number | null | undefined,
|
||||
unit?: string | null
|
||||
): string => {
|
||||
if (value == null) return "—";
|
||||
const symbol =
|
||||
unit === "CNY" ? "¥" : unit === "USD" ? "$" : unit ? `${unit} ` : "";
|
||||
const num = Number.isInteger(value) ? String(value) : value.toFixed(2);
|
||||
return `${symbol}${num}`;
|
||||
};
|
||||
|
||||
const formatReset = (resetsAt?: string | null): string | null => {
|
||||
if (!resetsAt) return null;
|
||||
const ms = new Date(resetsAt).getTime() - now;
|
||||
|
||||
@@ -8,10 +8,11 @@ import { fmtInt, fmtPct, fmtTime, fmtTokens } from "../../lib/dashboard-format";
|
||||
import { DailyBars } from "./DailyBars";
|
||||
import { DailyDetailModal } from "./DailyDetailModal";
|
||||
import { Heatmap } from "./Heatmap";
|
||||
import { ModelTotalsChart } from "./ModelTotalsChart";
|
||||
import { TrendLineChart } from "./TrendLineChart";
|
||||
import type { DailyRow, HeatmapCell } from "../../types/dashboard";
|
||||
|
||||
const RANGES: DashboardRange[] = ["today", "7d", "30d", "all"];
|
||||
const RANGES: DashboardRange[] = ["today", "yesterday", "7d", "30d", "all"];
|
||||
|
||||
/** Per-request cache hit rate: cached tokens / total input tokens. */
|
||||
function cacheHitOf(r: { inputOther: number; inputCacheRead: number }): number {
|
||||
@@ -59,13 +60,14 @@ export function DashboardPage() {
|
||||
const { range, changeRange, data, loading, error, refresh, loadStats } = useDashboard();
|
||||
const [showAllModels, setShowAllModels] = useState(false);
|
||||
const [recentPage, setRecentPage] = useState(1);
|
||||
const [trendTab, setTrendTab] = useState<"daily" | "model" | "provider">("daily");
|
||||
const [trendTab, setTrendTab] = useState<"daily" | "model" | "provider" | "totals">("daily");
|
||||
const [heatmapSelected, setHeatmapSelected] = useState<DailyRow | null>(null);
|
||||
|
||||
const daily = data?.stats.daily ?? [];
|
||||
|
||||
const RANGE_LABELS: Record<DashboardRange, string> = {
|
||||
today: t("rangeToday"),
|
||||
yesterday: t("rangeYesterday"),
|
||||
"7d": t("range7d"),
|
||||
"30d": t("range30d"),
|
||||
all: t("rangeAll"),
|
||||
@@ -192,7 +194,7 @@ export function DashboardPage() {
|
||||
</div>
|
||||
|
||||
{/* Range overview cards */}
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{RANGES.map((r) => {
|
||||
const tot = rangeTotals[r];
|
||||
if (!tot) return null;
|
||||
@@ -272,13 +274,26 @@ export function DashboardPage() {
|
||||
>
|
||||
{t("tabProviderTrend")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTrendTab("totals")}
|
||||
className={`px-3 py-2 -mb-px border-b-2 text-sm font-medium transition-colors ${
|
||||
trendTab === "totals"
|
||||
? "border-blue-500 text-content-primary"
|
||||
: "border-transparent text-content-muted hover:text-content-primary"
|
||||
}`}
|
||||
>
|
||||
{t("tabModelTotals")}
|
||||
</button>
|
||||
</div>
|
||||
<span className="pb-2 text-xs text-content-muted">
|
||||
{trendTab === "daily"
|
||||
? t("trendLineSub", { n: trendDaily.length })
|
||||
: daily.length > 30
|
||||
? t("cardDailyTrendSubCapped", { n: daily.length })
|
||||
: t("cardDailyTrendSub", { n: daily.length })}
|
||||
: trendTab === "totals"
|
||||
? t("cardModelUsageSub", { n: data.stats.modelsByName.length })
|
||||
: daily.length > 30
|
||||
? t("cardDailyTrendSubCapped", { n: daily.length })
|
||||
: t("cardDailyTrendSub", { n: daily.length })}
|
||||
</span>
|
||||
</div>
|
||||
{/* Active tab content */}
|
||||
@@ -287,6 +302,10 @@ export function DashboardPage() {
|
||||
<div className="flex-1 min-h-[240px]">
|
||||
<TrendLineChart daily={trendDaily} />
|
||||
</div>
|
||||
) : trendTab === "totals" ? (
|
||||
<div className="flex-1 min-h-[240px]">
|
||||
<ModelTotalsChart rows={data.stats.modelsByName} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-[240px]">
|
||||
<DailyBars
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useTranslation } from "../../i18n";
|
||||
import { useCurrency } from "../../hooks/useCurrency";
|
||||
import { fmtInt, fmtPct, fmtTokens } from "../../lib/dashboard-format";
|
||||
import { modelColor } from "./DailyBars";
|
||||
import type { ModelRow } from "../../types/dashboard";
|
||||
|
||||
interface ModelTotalsChartProps {
|
||||
/** Provider-agnostic model totals, pre-sorted by totalTokens desc. */
|
||||
rows: ModelRow[];
|
||||
}
|
||||
|
||||
/** Horizontal ranked bars: one row per bare model name, providers merged. */
|
||||
export function ModelTotalsChart({ rows }: ModelTotalsChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const { money } = useCurrency();
|
||||
|
||||
if (!rows.length) {
|
||||
return (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-content-muted">
|
||||
{t("noData")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const max = Math.max(...rows.map((m) => m.totalTokens || 0), 1);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-1 overflow-y-auto">
|
||||
{rows.map((m, i) => {
|
||||
const color = i < 10 ? modelColor(i) : "#6b7280";
|
||||
const name = m.isSecondary ? t("subagentModel") : m.modelDisplay || m.model;
|
||||
const tooltip = [
|
||||
name,
|
||||
`${t("kpiRequests")}: ${fmtInt(m.requests)}`,
|
||||
`${t("totalTokens")}: ${fmtInt(m.totalTokens)}`,
|
||||
`${t("colCacheHit")}: ${fmtPct(m.cacheHitRate)}`,
|
||||
`${t("cost")}: ${money(m.costUsd)}`,
|
||||
].join("\n");
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m.model}
|
||||
title={tooltip}
|
||||
className="group flex items-center gap-2 rounded px-1 py-1 cursor-help hover:bg-hover"
|
||||
>
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 rounded-sm shrink-0"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className="w-40 shrink-0 truncate text-xs text-content-primary">
|
||||
{name}
|
||||
{m.costEstimated && (
|
||||
<span className="ml-1 text-[10px] text-yellow-600">({t("estimate")})</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex h-4 flex-1 items-center overflow-hidden rounded-sm bg-hover-2/50">
|
||||
<div
|
||||
className="h-full rounded-sm transition-[width] group-hover:opacity-85"
|
||||
style={{
|
||||
width: `${Math.max(2, (m.totalTokens / max) * 100)}%`,
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-16 shrink-0 text-right text-xs text-content-muted">
|
||||
{fmtTokens(m.totalTokens)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export type UsageKind =
|
||||
| "balance:stepfun"
|
||||
| "balance:novita"
|
||||
| "balance:kimi"
|
||||
| "balance:newapi"
|
||||
| "plan:kimi_coding"
|
||||
| "plan:zhipu"
|
||||
| "plan:minimax"
|
||||
@@ -30,6 +31,7 @@ export const SUPPORTED_USAGE_KINDS: ReadonlySet<string> = new Set<UsageKind>([
|
||||
"balance:stepfun",
|
||||
"balance:novita",
|
||||
"balance:kimi",
|
||||
"balance:newapi",
|
||||
"plan:kimi_coding",
|
||||
"plan:zhipu",
|
||||
"plan:minimax",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { SummaryResult } from "../types/dashboard";
|
||||
|
||||
export type DashboardRange = "today" | "7d" | "30d" | "all";
|
||||
export type DashboardRange = "today" | "yesterday" | "7d" | "30d" | "all";
|
||||
|
||||
const RANGE_STORAGE_KEY = "kimi-switch-dashboard-range";
|
||||
|
||||
@@ -10,7 +10,7 @@ export function useDashboard() {
|
||||
const [range, setRange] = useState<DashboardRange>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(RANGE_STORAGE_KEY) as DashboardRange | null;
|
||||
if (stored === "today" || stored === "7d" || stored === "30d" || stored === "all") {
|
||||
if (stored === "today" || stored === "yesterday" || stored === "7d" || stored === "30d" || stored === "all") {
|
||||
return stored;
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -62,7 +62,8 @@ export function useUsageQuery(
|
||||
agent: Agent,
|
||||
providerName: string,
|
||||
usageKinds?: string[],
|
||||
autoIntervalMinutes?: number
|
||||
autoIntervalMinutes?: number,
|
||||
disabled?: boolean
|
||||
): UsageQueryState {
|
||||
const supported = (usageKinds?.length ?? 0) > 0;
|
||||
// Cache key includes the agent so a Kimi Code provider and a Pi provider
|
||||
@@ -79,12 +80,13 @@ export function useUsageQuery(
|
||||
|
||||
const runQuery = useCallback(
|
||||
async (forceRefresh: boolean) => {
|
||||
if (disabled) return;
|
||||
const gen = ++genRef.current;
|
||||
setStatus("loading");
|
||||
setError(undefined);
|
||||
const finish = (entry: CacheEntry) => {
|
||||
cache.set(cacheKey, entry);
|
||||
if (genRef.current !== gen) return;
|
||||
cache.set(cacheKey, entry);
|
||||
setStatus(entry.status);
|
||||
setData(entry.data);
|
||||
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.
|
||||
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;
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) {
|
||||
@@ -144,7 +156,7 @@ export function useUsageQuery(
|
||||
if (Date.now() - cached.updatedAt < STALE_TTL_MS) return;
|
||||
}
|
||||
void runQuery(false);
|
||||
}, [supported, cacheKey, runQuery]);
|
||||
}, [supported, cacheKey, runQuery, disabled]);
|
||||
|
||||
// Ignore late responses after unmount.
|
||||
useEffect(() => {
|
||||
@@ -156,12 +168,12 @@ export function useUsageQuery(
|
||||
// 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.
|
||||
useEffect(() => {
|
||||
if (!supported) return;
|
||||
if (!supported || disabled) return;
|
||||
const mins = autoIntervalMinutes ?? 0;
|
||||
if (mins <= 0) return;
|
||||
const id = setInterval(() => void runQuery(false), mins * 60_000);
|
||||
return () => clearInterval(id);
|
||||
}, [supported, autoIntervalMinutes, runQuery]);
|
||||
}, [supported, autoIntervalMinutes, runQuery, disabled]);
|
||||
|
||||
return {
|
||||
status,
|
||||
|
||||
+13
-2
@@ -138,7 +138,6 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
thinkingLow: "Low",
|
||||
thinkingMedium: "Medium",
|
||||
thinkingHigh: "High",
|
||||
thinkingMax: "Max",
|
||||
thinkingContextHint: "Thinking uses more context. Ensure the model context length and reserved size are sufficient.",
|
||||
loopControlSettings: "Loop Control",
|
||||
maxAttemptsPerStep: "Max attempts per step",
|
||||
@@ -152,6 +151,9 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
permissionAllow: "Allow",
|
||||
permissionDeny: "Deny",
|
||||
permissionAsk: "Ask",
|
||||
permissionDangerousGuard: "Dangerous Command Guard",
|
||||
permissionDangerousGuardDesc:
|
||||
"When on (default): Manual/YOLO modes force a prompt for dangerous commands (rm -rf, shutdown, dd of=, ...) and commands that cannot be statically analyzed; Auto (never-ask) mode no longer intercepts them since kimi-code 0.41.0. Turning it off restores the previous behavior. Env KIMI_CODE_DANGEROUS_COMMAND_GUARD overrides this config",
|
||||
addRule: "+ Add rule",
|
||||
addCommonRules: "Add common rules",
|
||||
hooks: "Lifecycle Hooks",
|
||||
@@ -286,6 +288,7 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
|
||||
// Dashboard — ranges / status
|
||||
rangeToday: "Today",
|
||||
rangeYesterday: "Yesterday",
|
||||
range7d: "7d",
|
||||
range30d: "30d",
|
||||
rangeAll: "All",
|
||||
@@ -307,6 +310,7 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
cardDailyTrendSubCapped: "Last 30 days · {n} days total",
|
||||
tabModelTrend: "Model usage trend",
|
||||
tabProviderTrend: "Provider–model usage trend",
|
||||
tabModelTotals: "Model usage totals",
|
||||
tabDailyTrend: "Daily usage trend",
|
||||
providerUnknown: "Unknown provider",
|
||||
cardDailyTrendSub: "{n} days",
|
||||
@@ -476,8 +480,14 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
flagWaitForDesc: "The agent can wait for background tasks to finish within the current turn",
|
||||
flagAutoSessionTitle: "Auto Session Title",
|
||||
flagAutoSessionTitleDesc: "Generate session titles automatically via the managed chat_title tool",
|
||||
flagRemoteControl: "Remote Control",
|
||||
flagRemoteControlDesc: "Enable the experimental remote control feature",
|
||||
flagSearchWorker: "Search Worker",
|
||||
flagSearchWorkerDesc: "Run the kap-server global search backend in a background worker host",
|
||||
flagDefaultOn: "On by default",
|
||||
lockedByEnv: "Locked by env var",
|
||||
masterEnvOnHint: "KIMI_CODE_EXPERIMENTAL_FLAG is set: all experimental features are forced on by the env var",
|
||||
masterEnvOnHint:
|
||||
"KIMI_CODE_EXPERIMENTAL_FLAG is set: flags without an explicit [experimental] entry are forced on; explicit entries in config.toml still win",
|
||||
secondaryModelSection: "Subagent Model (Secondary Model)",
|
||||
secondaryModelDisabledHint:
|
||||
"The experimental flag \"Subagent Secondary Model\" is off and this config will not take effect — enable the toggle above first",
|
||||
@@ -526,6 +536,7 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
"Open the new Web UI in an independent in-app window, or in your system browser (built into kimi-code 0.33+). Requires the kimi CLI on PATH.",
|
||||
openWebUIEmbedded: "Open in App",
|
||||
openWebUIBrowser: "Open in Browser",
|
||||
webuiOpening: "Opening...",
|
||||
|
||||
// Plugin marketplace
|
||||
pluginMarketplace: "Plugin Marketplace",
|
||||
|
||||
+13
-2
@@ -136,7 +136,6 @@ export const zhTranslations = {
|
||||
thinkingLow: "低",
|
||||
thinkingMedium: "中",
|
||||
thinkingHigh: "高",
|
||||
thinkingMax: "最大",
|
||||
thinkingContextHint: "启用思考会占用更多上下文,请确保模型上下文长度和预留空间足够。",
|
||||
loopControlSettings: "循环控制",
|
||||
maxAttemptsPerStep: "单步最大尝试次数",
|
||||
@@ -150,6 +149,9 @@ export const zhTranslations = {
|
||||
permissionAllow: "允许",
|
||||
permissionDeny: "拒绝",
|
||||
permissionAsk: "询问",
|
||||
permissionDangerousGuard: "危险命令防护",
|
||||
permissionDangerousGuardDesc:
|
||||
"开启(默认)时:Manual/YOLO 模式下,危险命令(rm -rf、shutdown、dd of= 等)和无法静态分析的命令会强制询问;自动(永不询问)模式自 kimi-code 0.41.0 起不再拦截。关闭后回归旧行为。环境变量 KIMI_CODE_DANGEROUS_COMMAND_GUARD 会覆盖本配置",
|
||||
addRule: "+ 添加规则",
|
||||
addCommonRules: "添加常用规则",
|
||||
hooks: "生命周期钩子",
|
||||
@@ -281,6 +283,7 @@ export const zhTranslations = {
|
||||
|
||||
// Dashboard — ranges / status
|
||||
rangeToday: "今天",
|
||||
rangeYesterday: "昨天",
|
||||
range7d: "7 天",
|
||||
range30d: "30 天",
|
||||
rangeAll: "全部",
|
||||
@@ -302,6 +305,7 @@ export const zhTranslations = {
|
||||
cardDailyTrendSubCapped: "近 30 天 · 共 {n} 天",
|
||||
tabModelTrend: "模型用量趋势",
|
||||
tabProviderTrend: "供应商模型用量趋势",
|
||||
tabModelTotals: "模型用量统计",
|
||||
tabDailyTrend: "每日用量趋势",
|
||||
providerUnknown: "未知供应商",
|
||||
cardDailyTrendSub: "共 {n} 天",
|
||||
@@ -471,8 +475,14 @@ export const zhTranslations = {
|
||||
flagWaitForDesc: "代理可在当前回合内等待后台任务完成,而无需结束回合再被唤醒",
|
||||
flagAutoSessionTitle: "自动会话标题",
|
||||
flagAutoSessionTitleDesc: "通过托管 chat_title 工具自动生成会话标题",
|
||||
flagRemoteControl: "远程控制",
|
||||
flagRemoteControlDesc: "启用实验性远程控制功能",
|
||||
flagSearchWorker: "搜索 Worker",
|
||||
flagSearchWorkerDesc: "kap-server 全局搜索的后台 worker 宿主服务",
|
||||
flagDefaultOn: "默认开启",
|
||||
lockedByEnv: "被环境变量锁定",
|
||||
masterEnvOnHint: "KIMI_CODE_EXPERIMENTAL_FLAG 已设置:全部实验功能被环境变量强制开启",
|
||||
masterEnvOnHint:
|
||||
"KIMI_CODE_EXPERIMENTAL_FLAG 已设置:未显式配置的实验项将被强制开启;config.toml 中的显式配置优先生效",
|
||||
secondaryModelSection: "子代理模型(次主力模型)",
|
||||
secondaryModelDisabledHint:
|
||||
"实验开关「子代理次主力模型」未启用,本配置不会生效——请先在上方打开对应开关",
|
||||
@@ -518,6 +528,7 @@ export const zhTranslations = {
|
||||
"以独立窗口在应用内打开新版 Web 界面,也可以选择在系统浏览器打开(kimi-code 0.33+ 内置 code-app;需要 kimi 命令在 PATH 中)。",
|
||||
openWebUIEmbedded: "在应用内打开",
|
||||
openWebUIBrowser: "在浏览器打开",
|
||||
webuiOpening: "打开中...",
|
||||
|
||||
// Plugin marketplace
|
||||
pluginMarketplace: "插件市场",
|
||||
|
||||
@@ -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
@@ -49,20 +49,43 @@ export function getAgentSettings(rawOther: unknown): AgentSettings {
|
||||
) {
|
||||
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 {
|
||||
thinking: {
|
||||
...DEFAULT_SETTINGS.thinking,
|
||||
...getSection<AgentSettings["thinking"]>(rawOther, "thinking"),
|
||||
},
|
||||
thinking,
|
||||
loop_control: loop,
|
||||
background: {
|
||||
...DEFAULT_SETTINGS.background,
|
||||
...getSection<AgentSettings["background"]>(rawOther, "background"),
|
||||
},
|
||||
permission: {
|
||||
rules:
|
||||
getSection<AgentSettings["permission"]>(rawOther, "permission")?.rules ??
|
||||
[],
|
||||
rules: sectionPermission?.rules ?? [],
|
||||
// `dangerous_command_guard` stays undefined unless the config carries
|
||||
// an explicit boolean — upstream defaults it to ON (kimi-code 0.40.1),
|
||||
// so an absent key means "on". The env var
|
||||
// KIMI_CODE_DANGEROUS_COMMAND_GUARD (literal "true"/"false" only)
|
||||
// outranks this config value at kimi-code runtime.
|
||||
...(typeof sectionPermission?.dangerous_command_guard === "boolean"
|
||||
? { dangerous_command_guard: sectionPermission.dangerous_command_guard }
|
||||
: {}),
|
||||
},
|
||||
hooks: getSection<AgentSettings["hooks"]>(rawOther, "hooks") ?? [],
|
||||
};
|
||||
@@ -70,25 +93,58 @@ export function getAgentSettings(rawOther: unknown): AgentSettings {
|
||||
|
||||
export function setAgentSettings(
|
||||
rawOther: unknown,
|
||||
patch: Partial<AgentSettings>
|
||||
patch: Partial<AgentSettings>,
|
||||
opts?: { legacyV1?: boolean }
|
||||
): unknown {
|
||||
const root = { ...asRecord(rawOther) };
|
||||
const current = getAgentSettings(rawOther);
|
||||
// Explicit guard value wins over the carried-over one; undefined keeps the
|
||||
// key absent (upstream default = on).
|
||||
const guard =
|
||||
patch.permission?.dangerous_command_guard ??
|
||||
current.permission?.dangerous_command_guard;
|
||||
const permission: NonNullable<AgentSettings["permission"]> = {
|
||||
rules: patch.permission?.rules ?? current.permission?.rules ?? [],
|
||||
...(typeof guard === "boolean" ? { dangerous_command_guard: guard } : {}),
|
||||
};
|
||||
const next: AgentSettings = {
|
||||
thinking: { ...current.thinking, ...patch.thinking },
|
||||
loop_control: { ...current.loop_control, ...patch.loop_control },
|
||||
background: { ...current.background, ...patch.background },
|
||||
permission: {
|
||||
rules: patch.permission?.rules ?? current.permission?.rules ?? [],
|
||||
},
|
||||
permission,
|
||||
hooks: patch.hooks ?? current.hooks ?? [],
|
||||
};
|
||||
|
||||
// `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.loop_control) root.loop_control = next.loop_control;
|
||||
if (next.background) root.background = next.background;
|
||||
if (next.permission?.rules && next.permission.rules.length > 0) {
|
||||
root.permission = next.permission;
|
||||
// Keep `[permission]` when it carries rules *or* an explicit
|
||||
// dangerous_command_guard — dropping the section would silently lose a
|
||||
// guard=false write (absent key = upstream default on). An empty rules
|
||||
// array is omitted rather than written as `rules = []`.
|
||||
const hasRules = !!permission.rules && permission.rules.length > 0;
|
||||
const hasGuard = typeof permission.dangerous_command_guard === "boolean";
|
||||
if (hasRules || hasGuard) {
|
||||
root.permission = {
|
||||
...(hasRules ? { rules: permission.rules } : {}),
|
||||
...(hasGuard
|
||||
? { dangerous_command_guard: permission.dangerous_command_guard }
|
||||
: {}),
|
||||
};
|
||||
} else {
|
||||
delete root.permission;
|
||||
}
|
||||
|
||||
+98560
-94515
File diff suppressed because it is too large.
Load diff
+81007
-77499
File diff suppressed because it is too large.
Load diff
@@ -1,8 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
EXPERIMENTAL_FLAGS,
|
||||
getExperimentalFlags,
|
||||
getSecondaryModel,
|
||||
getSubagentModelPool,
|
||||
isExperimentalFlagSet,
|
||||
removeSubagentPoolEntry,
|
||||
setExperimentalFlag,
|
||||
setSecondaryModelOnly,
|
||||
setSubagentDefault,
|
||||
setSubagentForce,
|
||||
@@ -231,6 +235,31 @@ describe("pool mutations", () => {
|
||||
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", () => {
|
||||
const raw = rawWith({
|
||||
default_model: "kimi-k1",
|
||||
@@ -424,3 +453,75 @@ describe("validateSubagentPool", () => {
|
||||
expect(errors).toContainEqual({ key: "forceExcludesModels" });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// [experimental] — flag registry (kimi-code 0.41.0) + write semantics
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("experimental flag registry", () => {
|
||||
it("mirrors the 0.41.0 v2 registry (9 flags, file_history removed)", () => {
|
||||
expect(EXPERIMENTAL_FLAGS.map((f) => f.id)).toEqual([
|
||||
"secondary-model",
|
||||
"tool-select",
|
||||
"persistence_minidb_readmodel",
|
||||
"tower",
|
||||
"subagent_fork",
|
||||
"wait_for",
|
||||
"auto_session_title",
|
||||
"remote-control",
|
||||
"search_worker",
|
||||
]);
|
||||
// Upstream removed the flag in 0.41.0 (turn-level file history is
|
||||
// always on), so the adapter must not mirror it either.
|
||||
expect(EXPERIMENTAL_FLAGS.some((f) => f.id === "file_history")).toBe(false);
|
||||
});
|
||||
|
||||
it("secondary-model is on by default since 0.40.1", () => {
|
||||
expect(
|
||||
EXPERIMENTAL_FLAGS.find((f) => f.id === "secondary-model")?.defaultEnabled
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setExperimentalFlag", () => {
|
||||
const rawExp = (section: Record<string, unknown>) => ({
|
||||
experimental: section,
|
||||
});
|
||||
|
||||
it("enabling writes true; disabling a default-off flag deletes the key", () => {
|
||||
const on = setExperimentalFlag({}, "tool-select", true) as {
|
||||
experimental: Record<string, unknown>;
|
||||
};
|
||||
expect(on.experimental["tool-select"]).toBe(true);
|
||||
const off = setExperimentalFlag(on, "tool-select", false) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(off).not.toHaveProperty("experimental"); // empty section dropped
|
||||
});
|
||||
|
||||
it("disabling a default-on flag with explicitFalse writes a literal false", () => {
|
||||
const next = setExperimentalFlag(
|
||||
rawExp({ wait_for: true }),
|
||||
"wait_for",
|
||||
false,
|
||||
{ explicitFalse: true }
|
||||
) as { experimental: Record<string, unknown> };
|
||||
expect(next.experimental.wait_for).toBe(false);
|
||||
expect(isExperimentalFlagSet(next, "wait_for")).toBe(true);
|
||||
expect(getExperimentalFlags(next).wait_for).toBe(false);
|
||||
});
|
||||
|
||||
it("an explicit false coexists with other flags and keeps the section", () => {
|
||||
const next = setExperimentalFlag(
|
||||
rawExp({ "secondary-model": false }),
|
||||
"tower",
|
||||
true,
|
||||
{ explicitFalse: true }
|
||||
) as { experimental: Record<string, unknown> };
|
||||
expect(next.experimental).toEqual({
|
||||
"secondary-model": false,
|
||||
tower: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -28,32 +28,57 @@ export interface ExperimentalFlagDef {
|
||||
id: string;
|
||||
/** Single-feature env var that can force the flag (locks the UI toggle). */
|
||||
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
|
||||
* (the per-feature flag.ts files under packages/agent-core-v2/src:
|
||||
* secondary-model, tool-select, persistence_minidb_readmodel, tower,
|
||||
* subagent_fork, wait_for, auto_session_title). `acp-v2` was removed
|
||||
* upstream and is dropped here. */
|
||||
* subagent_fork, wait_for, auto_session_title, remote-control,
|
||||
* search_worker). `acp-v2` was removed upstream and is dropped here;
|
||||
* `file_history` was removed in kimi-code 0.41.0 (turn-level file
|
||||
* history is always on, no flag). Keep the env-var list in sync with
|
||||
* `get_experimental_env_status` in src-tauri/src/commands.rs. */
|
||||
export const EXPERIMENTAL_FLAGS: ExperimentalFlagDef[] = [
|
||||
{ id: "secondary-model", envVar: "KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL" },
|
||||
{
|
||||
id: "secondary-model",
|
||||
envVar: "KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL",
|
||||
defaultEnabled: true, // on by default since kimi-code 0.40.1
|
||||
},
|
||||
{ id: "tool-select", envVar: "KIMI_CODE_EXPERIMENTAL_TOOL_SELECT" },
|
||||
{
|
||||
id: "persistence_minidb_readmodel",
|
||||
envVar: "KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL",
|
||||
defaultEnabled: true,
|
||||
},
|
||||
{ id: "tower", envVar: "KIMI_CODE_EXPERIMENTAL_TOWER" },
|
||||
{ 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",
|
||||
envVar: "KIMI_CODE_EXPERIMENTAL_AUTO_SESSION_TITLE",
|
||||
},
|
||||
{ id: "remote-control", envVar: "KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL" },
|
||||
{
|
||||
id: "search_worker",
|
||||
envVar: "KIMI_CODE_EXPERIMENTAL_SEARCH_WORKER",
|
||||
defaultEnabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const EXPERIMENTAL_MASTER_ENV = "KIMI_CODE_EXPERIMENTAL_FLAG";
|
||||
|
||||
/** Whether the master env var is set to a truthy value (locks every flag on). */
|
||||
/** Whether the master env var is set to a truthy value. Since kimi-code
|
||||
* 0.40.1 it only force-ONs flags that have *no* explicit `[experimental]`
|
||||
* entry (an explicit config value — true or false — outranks it, and the
|
||||
* master env can never force a flag off). It locks no UI toggles; only a
|
||||
* flag's own single-feature env var does. */
|
||||
export function isMasterEnvOn(env: ExperimentalEnvStatus | null): boolean {
|
||||
return isTruthyEnv(env?.[EXPERIMENTAL_MASTER_ENV]);
|
||||
}
|
||||
@@ -87,16 +112,33 @@ export function getExperimentalFlags(rawOther: unknown): Record<string, boolean>
|
||||
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(
|
||||
rawOther: unknown,
|
||||
id: string,
|
||||
enabled: boolean
|
||||
enabled: boolean,
|
||||
opts?: { explicitFalse?: boolean }
|
||||
): unknown {
|
||||
const root = { ...asRecord(rawOther) };
|
||||
const flags = { ...getExperimentalFlags(rawOther) };
|
||||
if (enabled) {
|
||||
flags[id] = true;
|
||||
} else if (opts?.explicitFalse) {
|
||||
flags[id] = false;
|
||||
} else {
|
||||
delete flags[id];
|
||||
}
|
||||
@@ -256,8 +298,11 @@ export function setSubagentModelPool(
|
||||
return root;
|
||||
}
|
||||
|
||||
/** Add or update one alias in the pool `models` table. The rest of the
|
||||
* section (default, force, unknown fields) is preserved untouched. */
|
||||
/** Add or update one alias in the pool `models` table. When the section is
|
||||
* 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(
|
||||
rawOther: unknown,
|
||||
alias: string,
|
||||
@@ -269,8 +314,21 @@ export function upsertSubagentPoolEntry(
|
||||
for (const [k, v] of Object.entries(section)) next[k] = v;
|
||||
const models: Record<string, unknown> = {};
|
||||
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;
|
||||
next.models = models;
|
||||
if (effectiveDefault !== undefined) {
|
||||
next.default_model = effectiveDefault;
|
||||
next.model = effectiveDefault;
|
||||
}
|
||||
root.secondary_model = next;
|
||||
return root;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -35,12 +35,32 @@ export function planLabel(name: string, t: TranslateFn): string {
|
||||
* src-tauri/src/commands.rs and src-tauri/src/services/*.
|
||||
*/
|
||||
export function localizeUsageError(error: string, t: TranslateFn): string {
|
||||
if (error.startsWith("unsupported provider")) return t("usageUnsupportedProvider");
|
||||
if (error.startsWith("no API key configured")) return t("usageErrNoKey");
|
||||
if (error.startsWith("usage query disabled")) return t("usageErrDisabled");
|
||||
if (/login expired|refresh failed/i.test(error)) return t("usageErrLoginExpired");
|
||||
if (error.startsWith("no Kimi Code OAuth credentials")) return t("usageErrNoOauth");
|
||||
if (error.startsWith("newapi template requires")) return t("usageErrNewapiCreds");
|
||||
if (error.startsWith("Authentication failed")) return t("usageInvalidKey");
|
||||
// commands.rs 的 kinds 循环把单条错误包成 `{kind}: {msg}`(kind 含数字,
|
||||
// 如 plan:zhipu),先剥离再按前缀匹配;未知错误仍回退按原文展示(保留 kind
|
||||
// 前缀,属诊断细节)。
|
||||
const stripped = error.replace(/^(balance|plan):[a-z0-9_]+:\s*/, "");
|
||||
if (stripped.startsWith("unsupported provider")) return t("usageUnsupportedProvider");
|
||||
if (stripped.startsWith("no API key configured")) return t("usageErrNoKey");
|
||||
if (stripped.startsWith("usage query disabled")) return t("usageErrDisabled");
|
||||
if (/login expired|refresh failed/i.test(stripped)) return t("usageErrLoginExpired");
|
||||
if (stripped.startsWith("no Kimi Code OAuth credentials")) return t("usageErrNoOauth");
|
||||
if (stripped.startsWith("newapi template requires")) return t("usageErrNewapiCreds");
|
||||
if (stripped.startsWith("Authentication failed")) return t("usageInvalidKey");
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a usage amount with its unit. `¤` (U+00A4) is NewAPI's placeholder
|
||||
* when the site has no custom currency symbol configured — treat it as "no
|
||||
* symbol" and render only the number.
|
||||
*/
|
||||
export function formatAmount(
|
||||
value: number | null | undefined,
|
||||
unit?: string | null
|
||||
): string {
|
||||
if (value == null) return "—";
|
||||
const symbol =
|
||||
unit === "CNY" ? "¥" : unit === "USD" ? "$" : unit && unit.trim() !== "¤" ? `${unit} ` : "";
|
||||
const num = Number.isInteger(value) ? String(value) : value.toFixed(2);
|
||||
return `${symbol}${num}`;
|
||||
}
|
||||
@@ -52,6 +52,8 @@ export interface RangeStats {
|
||||
totals: TotalsRow;
|
||||
daily: DailyRow[];
|
||||
models: ModelRow[];
|
||||
/** Model totals keyed by bare model name — the same model across providers merges into one row. */
|
||||
modelsByName: ModelRow[];
|
||||
recent: RecentRow[];
|
||||
recentTotal: number;
|
||||
recentLimit: number;
|
||||
|
||||
+24
-2
@@ -100,7 +100,18 @@ export interface DiscoveredModel {
|
||||
|
||||
export interface ThinkingConfig {
|
||||
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";
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
@@ -109,7 +120,8 @@ export interface LoopControlConfig {
|
||||
max_attempts_per_step?: number;
|
||||
/** v2 engine key (kimi-code 0.33+): `max_steps_per_run` was renamed. */
|
||||
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;
|
||||
reserved_context_size?: number;
|
||||
}
|
||||
@@ -137,7 +149,17 @@ export interface AgentSettings {
|
||||
thinking?: ThinkingConfig;
|
||||
loop_control?: LoopControlConfig;
|
||||
background?: BackgroundConfig;
|
||||
permission?: { rules?: PermissionRule[] };
|
||||
permission?: {
|
||||
rules?: PermissionRule[];
|
||||
/** kimi-code `[permission]` key. Default true when the key is
|
||||
* absent: since 0.41.0 the guard only gates Manual/YOLO modes —
|
||||
* dangerous commands (rm -rf, shutdown, dd of=, …) and commands
|
||||
* that cannot be statically analyzed force a prompt, while Auto
|
||||
* (never-ask) mode no longer intercepts them; false restores the
|
||||
* previous behavior. Env KIMI_CODE_DANGEROUS_COMMAND_GUARD (literal
|
||||
* "true"/"false" only) outranks this config. */
|
||||
dangerous_command_guard?: boolean;
|
||||
};
|
||||
hooks?: Hook[];
|
||||
}
|
||||
|
||||
|
||||
+10
-10
@@ -6,10 +6,10 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#efebe1" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#231f1a" media="(prefers-color-scheme: dark)" />
|
||||
<title>Kimi Switch — 多 LLM 供应商统一管理 · 用量监控 · 账单一目了然</title>
|
||||
<title>Kimi Switch — 多 LLM 供应商配置管理 · 用量与账单监控</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Kimi Switch / KimiCodeSwitch — Windows 桌面端多 LLM 供应商统一管理器,一键切换 Kimi / 智谱 GLM / MiniMax / DeepSeek / OpenRouter / 基元律动,用量账单实时监控,开源 MIT。"
|
||||
content="Kimi Switch / KimiCodeSwitch — 桌面端 LLM 供应商配置管理器:22 个预设选好即写入 config.toml,用量与账单直读各厂商官方接口。Tauri + Rust,开源 MIT。"
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
@@ -18,20 +18,20 @@
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Kimi Switch — 多 LLM 供应商统一管理" />
|
||||
<meta property="og:title" content="Kimi Switch — 多 LLM 供应商配置管理" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="一键切换 Kimi / 智谱 GLM / MiniMax / DeepSeek 等供应商,用量与账单一目了然,开源 MIT。"
|
||||
content="把 Kimi Code 的供应商配置搬进图形界面:22 个预设、用量与账单直读官方接口,免费开源 MIT。"
|
||||
/>
|
||||
<meta property="og:url" content="https://billowliu2.github.io/KimiSwitch/" />
|
||||
<meta property="og:image" content="https://billowliu2.github.io/KimiSwitch/og.png" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Kimi Switch — 多 LLM 供应商统一管理" />
|
||||
<meta name="twitter:title" content="Kimi Switch — 多 LLM 供应商配置管理" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="一键切换 Kimi / 智谱 GLM / MiniMax / DeepSeek 等供应商,用量与账单一目了然,开源 MIT。"
|
||||
content="把 Kimi Code 的供应商配置搬进图形界面:22 个预设、用量与账单直读官方接口,免费开源 MIT。"
|
||||
/>
|
||||
<meta name="twitter:image" content="https://billowliu2.github.io/KimiSwitch/og.png" />
|
||||
|
||||
@@ -49,10 +49,10 @@
|
||||
"alternateName": ["KimiSwitch", "KimiCodeSwitch", "Kimi Switch", "KimiCode Switch"],
|
||||
"url": "https://billowliu2.github.io/KimiSwitch/",
|
||||
"applicationCategory": "DeveloperApplication",
|
||||
"operatingSystem": "Windows 10, Windows 11",
|
||||
"softwareVersion": "0.6.7",
|
||||
"dateModified": "2026-08-01",
|
||||
"description": "Windows 桌面端多 LLM 供应商统一管理器,一键切换 Kimi / 智谱 GLM / MiniMax / DeepSeek / OpenRouter / 基元律动,用量账单实时监控,开源 MIT。",
|
||||
"operatingSystem": "Windows 10, Windows 11, macOS, Linux",
|
||||
"softwareVersion": "0.7.12",
|
||||
"dateModified": "2026-09-03",
|
||||
"description": "桌面端 LLM 供应商配置管理器:22 个预设选好即写入 config.toml,用量与账单直读各厂商官方接口。Tauri + Rust,开源 MIT。",
|
||||
"downloadUrl": "https://github.com/billowliu2/KimiSwitch/releases",
|
||||
"softwareHelp": "https://github.com/billowliu2/KimiSwitch",
|
||||
"license": "https://opensource.org/licenses/MIT",
|
||||
|
||||
@@ -87,7 +87,7 @@ export default function Footer() {
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="group flex items-start gap-3 rounded-2xl border border-border bg-card p-4 transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/40"
|
||||
className="group flex items-start gap-3 rounded-xl border border-border bg-card p-4 transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/40"
|
||||
style={{ boxShadow: "var(--shadow-soft)" }}
|
||||
>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-border bg-white">
|
||||
|
||||
@@ -24,7 +24,9 @@ export default function Nav() {
|
||||
/>
|
||||
<span>Kimi Switch</span>
|
||||
</NavLink>
|
||||
<nav className="flex items-center gap-5 overflow-x-auto overflow-y-hidden text-sm text-muted-foreground [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<nav
|
||||
aria-label="Primary"
|
||||
className="flex items-center gap-5 overflow-x-auto overflow-y-hidden text-sm text-muted-foreground [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{links.map((l) => (
|
||||
<NavLink
|
||||
key={l.to}
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function SectionShell({ id, title, subtitle, children }: SectionS
|
||||
ref={ref}
|
||||
id={id}
|
||||
aria-labelledby={`${id}-title`}
|
||||
className={`py-20 transition-all duration-700 ease-out ${
|
||||
className={`pt-16 pb-20 transition-all duration-700 ease-out ${
|
||||
inView
|
||||
? "motion-safe:opacity-100 motion-safe:translate-y-0"
|
||||
: "motion-safe:opacity-0 motion-safe:translate-y-6"
|
||||
@@ -30,7 +30,9 @@ export default function SectionShell({ id, title, subtitle, children }: SectionS
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className="mt-3 text-base leading-relaxed text-muted-foreground">{subtitle}</p>
|
||||
<p className="mt-3 max-w-[68ch] text-base leading-relaxed text-muted-foreground">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
|
||||
+100
-78
@@ -16,43 +16,43 @@ const zh = {
|
||||
performance: "性能",
|
||||
changelog: "更新日志",
|
||||
download: "下载",
|
||||
downloadBtn: "下载 v0.7.4",
|
||||
downloadBtn: "下载 v0.7.12",
|
||||
},
|
||||
hero: {
|
||||
badge: "v0.7.4 · 开源 MIT · Windows / macOS / Linux",
|
||||
badge: "v0.7.12 · 开源 MIT · Windows / macOS / Linux",
|
||||
titleBefore: "统一管理你的",
|
||||
titleAccent: "AI 供应商",
|
||||
titleAfter: "",
|
||||
subtitle:
|
||||
"一键切换 Kimi / 智谱 GLM / MiniMax / DeepSeek / OpenRouter / 基元律动,用量与账单一目了然。",
|
||||
"Kimi Code 的供应商配置不用再手改 config.toml:22 个预设选好即写入(写入前自动备份),用量与账单直读各厂商官方接口。",
|
||||
download: "免费下载",
|
||||
source: "查看源码",
|
||||
stats: [
|
||||
{ value: "22", label: "预设供应商" },
|
||||
{ value: "5910", label: "模型价格库" },
|
||||
{ value: "100%", label: "开源免费" },
|
||||
{ value: "7495", label: "模型价格库" },
|
||||
{ value: "MIT", label: "开源协议" },
|
||||
],
|
||||
},
|
||||
features: {
|
||||
title: "核心功能",
|
||||
subtitle: "从多供应商管理到用量监控,一站搞定。",
|
||||
subtitle: "配置、切换、用量、账单、会话——都落在本机那份 ~/.kimi-code/config.toml 上。",
|
||||
items: [
|
||||
{
|
||||
id: "multi-provider",
|
||||
title: "多供应商管理",
|
||||
desc: "Kimi / 智谱 GLM / MiniMax / DeepSeek / OpenRouter / 基元律动等 22 个预设,一键添加与切换。",
|
||||
desc: "Kimi / 智谱 GLM / MiniMax / DeepSeek / OpenRouter / 基元律动等 22 个预设,Base URL 与模型映射已填好,选定即写入配置。",
|
||||
img: "screenshots/preset-picker.png",
|
||||
},
|
||||
{
|
||||
id: "custom-provider",
|
||||
title: "自定义供应商",
|
||||
desc: "自定义 Base URL / API Key / 模型映射,支持 JSON 高级编辑。",
|
||||
desc: "Base URL、API Key、模型别名逐字段编辑,也可切到 JSON 页签直接改;配置里的未知字段原样保留。",
|
||||
img: "screenshots/edit-provider-basic-managed.png",
|
||||
},
|
||||
{
|
||||
id: "usage",
|
||||
title: "用量与账单监控",
|
||||
desc: "套餐类 5 小时 / 每周用量 + 按量余额,models.dev 真实价格计算成本,卡片直显。",
|
||||
desc: "套餐的 5 小时 / 每周额度与按量余额直接显示在卡片上,成本按 models.dev 牌价折算。",
|
||||
img: "screenshots/dashboard-light.png",
|
||||
},
|
||||
{
|
||||
@@ -63,7 +63,7 @@ const zh = {
|
||||
},
|
||||
{
|
||||
id: "kimi-auth",
|
||||
title: "Kimi 一键授权登录",
|
||||
title: "Kimi 授权登录",
|
||||
desc: "应用内置设备码授权(等同 kimi login),浏览器授权即用,15 分钟过期自动续期。",
|
||||
img: "screenshots/kimi-oauth-dialog.png",
|
||||
},
|
||||
@@ -75,31 +75,31 @@ const zh = {
|
||||
},
|
||||
{
|
||||
id: "model-discovery",
|
||||
title: "智能模型发现",
|
||||
desc: "模型能力 / 上下文大小自动从 models.dev 同步,构建时自动拉取,5910 模型覆盖。",
|
||||
title: "模型自动发现",
|
||||
desc: "可用模型直接问供应商 API;显示名 / 上下文 / 能力取自 models.dev 快照(当前 7495 个模型、212 家供应商)。",
|
||||
img: "screenshots/kimi-cli-select-model.png",
|
||||
},
|
||||
{
|
||||
id: "auto-update",
|
||||
title: "自动更新",
|
||||
desc: "自动检测新版本,一键下载安装,始终保持最新。",
|
||||
desc: "启动时和每 8 小时各查一次新版本(私有镜像优先、GitHub 兜底),应用内下载并引导安装。",
|
||||
img: "screenshots/settings.png",
|
||||
},
|
||||
],
|
||||
},
|
||||
showcase: {
|
||||
title: "界面演示",
|
||||
subtitle: "真实界面截图,所见即所得。",
|
||||
subtitle: "以下截图取自 v0.7.12 实机运行。",
|
||||
items: [
|
||||
{
|
||||
src: "screenshots/usage-config-page.png",
|
||||
title: "配置用量查询",
|
||||
desc: "NewAPI 中转站模板、超时与自动刷新、测试查询一屏搞定",
|
||||
desc: "NewAPI 中转站模板、超时与自动刷新、测试查询放在同一页",
|
||||
},
|
||||
{
|
||||
src: "screenshots/preset-picker.png",
|
||||
title: "预设供应商",
|
||||
desc: "22 个预设开箱即用,一键添加常用厂商",
|
||||
desc: "22 个预设,勾选即可添加常用厂商",
|
||||
},
|
||||
{
|
||||
src: "screenshots/kimi-oauth-dialog.png",
|
||||
@@ -125,7 +125,7 @@ const zh = {
|
||||
},
|
||||
performance: {
|
||||
title: "轻量原生",
|
||||
subtitle: "Tauri + Rust 内核,WebView2 前端,常驻托盘零负担。",
|
||||
subtitle: "Tauri + Rust,复用系统 WebView2,不打包 Chromium。下面三项是空闲状态实测。",
|
||||
metrics: [
|
||||
{ target: 93, suffix: " MB", label: "Working Set", desc: "程序空闲态工作集内存" },
|
||||
{ target: 34, suffix: " MB", label: "Private WS", desc: "独占内存(WebView2 共享部分扣除)" },
|
||||
@@ -140,6 +140,17 @@ const zh = {
|
||||
syncedNote: "数据已同步 GitHub Releases",
|
||||
fallbackNote: "内置版本记录",
|
||||
entries: [
|
||||
{
|
||||
version: "v0.7.12",
|
||||
date: "2026-09-03",
|
||||
items: [
|
||||
"适配 kimi-code 0.40.1:实验 flag 清单更新(新增 file_history / search_worker,secondary-model 默认开启)",
|
||||
"flag 优先级语义修正:显式配置优先于总开关,总开关不再锁定全部实验开关",
|
||||
"新增危险命令守卫开关:Auto 模式直接拒绝 rm -rf / shutdown 等危险命令,其它模式强制询问",
|
||||
"修复 WebUI 首次打开慢时多次点击的竞态报错",
|
||||
"models-dev 快照更新至 7495 模型 / 212 供应商",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "v0.7.4",
|
||||
date: "2026-08-09",
|
||||
@@ -170,7 +181,7 @@ const zh = {
|
||||
"兼容 kimi-code 0.33+(v2 引擎):循环控制改用新键 max_attempts_per_step,旧配置自动迁移",
|
||||
"models.dev 模型列表与价格数据同步更新",
|
||||
"官网企业版视觉升级;出品公司标识统一为 codingplan.site",
|
||||
"macOS 安装优化:Release 附带 install-macos.sh 一键脚本,自动清除下载隔离,免去手动右键打开",
|
||||
"macOS 安装优化:Release 附带 install-macos.sh,自动清除下载隔离,免去手动右键打开",
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -201,7 +212,7 @@ const zh = {
|
||||
version: "v0.6.9",
|
||||
date: "2026-08-01",
|
||||
items: [
|
||||
"最近请求新增缓存命中率列,每次请求命中一目了然",
|
||||
"最近请求新增缓存命中率列,逐条请求的命中情况直接可见",
|
||||
"中文模式费用按最新汇率换算人民币(¥)显示,英文模式保持美元",
|
||||
"仪表盘性能再优化:价格解析记忆化缓存,扫描从秒级降到百毫秒级;右上角新增加载耗时/数据量跟踪",
|
||||
],
|
||||
@@ -229,7 +240,7 @@ const zh = {
|
||||
version: "v0.6.6",
|
||||
date: "2026-08-01",
|
||||
items: [
|
||||
"模型价格对标 models.dev,覆盖 5910 个模型",
|
||||
"模型价格数据对标 models.dev,快照随版本更新",
|
||||
"新增基元律动供应商与推荐链接",
|
||||
"设置页文案与参考来源说明优化",
|
||||
],
|
||||
@@ -311,34 +322,34 @@ const zh = {
|
||||
},
|
||||
download: {
|
||||
title: "下载 Kimi Switch",
|
||||
subtitle: "当前版本 v0.7.4 · Windows / macOS / Linux 三平台已发布",
|
||||
autoUpdate: "应用内置自动检测更新,新版本发布后在设置页一键升级。",
|
||||
subtitle: "当前版本 v0.7.12 · Windows / macOS / Linux 三平台已发布",
|
||||
autoUpdate: "更新检查跑在启动时和每 8 小时的周期任务里,设置页也可以手动触发。",
|
||||
ready: "已发布",
|
||||
wip: "开发中",
|
||||
githubBtn: "GitHub 下载",
|
||||
mirrorBtn: "镜像下载",
|
||||
items: [
|
||||
{ id: "windows", name: "Windows", ready: true, note: ".msi 安装包,系统托盘常驻" },
|
||||
{ id: "macos", name: "macOS", ready: true, note: ".dmg 安装包(Apple Silicon),未签名:下载 install-macos.sh 后运行 bash install-macos.sh 一键安装" },
|
||||
{ id: "macos", name: "macOS", ready: true, note: ".dmg 安装包(Apple Silicon),未签名:下载 Release 附带的 install-macos.sh,运行 bash install-macos.sh 清除隔离属性" },
|
||||
{ id: "linux", name: "Linux", ready: true, note: ".deb / .AppImage / .rpm 三格式" },
|
||||
],
|
||||
},
|
||||
cta: {
|
||||
title: "现在就把所有供应商,放进一个应用",
|
||||
subtitle: "免费 · 开源 MIT · 一分钟完成安装",
|
||||
title: "把供应商配置、用量和账单收进一个窗口",
|
||||
subtitle: "免费 · MIT 开源 · Windows / macOS / Linux",
|
||||
download: "免费下载",
|
||||
source: "GitHub 源码",
|
||||
},
|
||||
seo: {
|
||||
home: {
|
||||
title: "Kimi Switch — 多 LLM 供应商统一管理 · 用量监控 · 账单一目了然",
|
||||
title: "Kimi Switch — 多 LLM 供应商配置管理 · 用量与账单监控",
|
||||
description:
|
||||
"Kimi Switch / KimiCodeSwitch — Windows 桌面端多 LLM 供应商统一管理器,一键切换 Kimi / 智谱 GLM / MiniMax / DeepSeek / OpenRouter / 基元律动,用量账单实时监控,开源 MIT。",
|
||||
"Kimi Switch / KimiCodeSwitch — 桌面端 LLM 供应商配置管理器:22 个预设选好即写入 config.toml,用量与账单直读各厂商官方接口。Tauri + Rust,开源 MIT。",
|
||||
},
|
||||
features: {
|
||||
title: "核心功能 - Kimi Switch",
|
||||
description:
|
||||
"Kimi Switch 核心功能:多供应商管理、用量与账单监控、Kimi 一键授权登录、智能模型发现与自动更新,22 个预设开箱即用。",
|
||||
"Kimi Switch 功能页:多供应商预设与自定义配置、用量与账单监控、Kimi 设备码授权、模型自动发现与周期更新检查。",
|
||||
},
|
||||
changelog: {
|
||||
title: "更新日志 - Kimi Switch",
|
||||
@@ -380,116 +391,116 @@ const en: Dict = {
|
||||
performance: "Performance",
|
||||
changelog: "Changelog",
|
||||
download: "Download",
|
||||
downloadBtn: "Download v0.7.4",
|
||||
downloadBtn: "Download v0.7.12",
|
||||
},
|
||||
hero: {
|
||||
badge: "v0.7.4 · Open source MIT · Windows / macOS / Linux",
|
||||
badge: "v0.7.12 · Open source MIT · Windows / macOS / Linux",
|
||||
titleBefore: "One app for all your ",
|
||||
titleAccent: "AI providers",
|
||||
titleAfter: "",
|
||||
subtitle:
|
||||
"Switch between Kimi, Zhipu GLM, MiniMax, DeepSeek, OpenRouter and TokenRhythm in one click, with usage and billing at a glance.",
|
||||
"Stop hand-editing ~/.kimi-code/config.toml. Pick one of 22 provider presets and it goes straight into your config (backed up first); usage and billing read from each vendor's own API.",
|
||||
download: "Free Download",
|
||||
source: "View Source",
|
||||
source: "View source",
|
||||
stats: [
|
||||
{ value: "22", label: "Provider presets" },
|
||||
{ value: "5910", label: "Model price DB" },
|
||||
{ value: "100%", label: "Open source" },
|
||||
{ value: "7495", label: "Model price DB" },
|
||||
{ value: "MIT", label: "License" },
|
||||
],
|
||||
},
|
||||
features: {
|
||||
title: "Core Features",
|
||||
subtitle: "From multi-provider management to usage monitoring, all in one place.",
|
||||
title: "Core features",
|
||||
subtitle: "Configuration, switching, usage, billing and sessions — all against the config.toml on your machine.",
|
||||
items: [
|
||||
{
|
||||
id: "multi-provider",
|
||||
title: "Multi-Provider Management",
|
||||
desc: "22 presets including Kimi, Zhipu GLM, MiniMax, DeepSeek, OpenRouter and TokenRhythm. Add and switch in one click.",
|
||||
title: "Provider presets",
|
||||
desc: "22 presets — Kimi, Zhipu GLM, MiniMax, DeepSeek, OpenRouter, TokenRhythm and more — ship with Base URL and model mapping filled in; picking one writes it to your config.",
|
||||
img: "screenshots/preset-picker.png",
|
||||
},
|
||||
{
|
||||
id: "custom-provider",
|
||||
title: "Custom Providers",
|
||||
desc: "Custom Base URL, API Key and model mapping, with advanced JSON editing.",
|
||||
title: "Custom providers",
|
||||
desc: "Edit Base URL, API key and model aliases field by field, or switch to the raw JSON tab; unknown fields are passed through untouched.",
|
||||
img: "screenshots/edit-provider-basic-managed.png",
|
||||
},
|
||||
{
|
||||
id: "usage",
|
||||
title: "Usage & Billing",
|
||||
desc: "5-hour / weekly plan quotas plus pay-as-you-go balance, priced with real models.dev rates and shown on cards.",
|
||||
title: "Usage & billing",
|
||||
desc: "5-hour / weekly plan quotas and pay-as-you-go balance shown directly on cards, with costs computed at models.dev list prices.",
|
||||
img: "screenshots/dashboard-light.png",
|
||||
},
|
||||
{
|
||||
id: "sessions",
|
||||
title: "Session Manager",
|
||||
title: "Session manager",
|
||||
desc: "Browse, search and resume past sessions across providers.",
|
||||
img: "screenshots/sessions-light.png",
|
||||
},
|
||||
{
|
||||
id: "kimi-auth",
|
||||
title: "One-Click Kimi Login",
|
||||
title: "Kimi device-code login",
|
||||
desc: "Built-in device-code authorization (same as kimi login). Authorize in the browser; 15-minute expiry auto-renews.",
|
||||
img: "screenshots/kimi-oauth-dialog.png",
|
||||
},
|
||||
{
|
||||
id: "usage-config",
|
||||
title: "Usage Query Setup",
|
||||
title: "Usage query setup",
|
||||
desc: "Full-page config: NewAPI relay templates, timeout, auto-refresh interval, and a test query.",
|
||||
img: "screenshots/usage-config-page.png",
|
||||
},
|
||||
{
|
||||
id: "model-discovery",
|
||||
title: "Smart Model Discovery",
|
||||
desc: "Model capabilities and context sizes sync from models.dev at build time, covering 5910 models.",
|
||||
title: "Model discovery",
|
||||
desc: "Model lists are fetched from the provider API itself; display names, context sizes and capabilities come from a build-time models.dev snapshot (7495 models, 212 providers).",
|
||||
img: "screenshots/kimi-cli-select-model.png",
|
||||
},
|
||||
{
|
||||
id: "auto-update",
|
||||
title: "Auto Updates",
|
||||
desc: "Automatically detects new versions and installs them in one click.",
|
||||
title: "Auto updates",
|
||||
desc: "Checks at startup and every 8 hours (private mirror first, GitHub fallback), downloads in-app and walks you through install.",
|
||||
img: "screenshots/settings.png",
|
||||
},
|
||||
],
|
||||
},
|
||||
showcase: {
|
||||
title: "Screenshots",
|
||||
subtitle: "Real screenshots from the app. What you see is what you get.",
|
||||
subtitle: "All screenshots below are taken from the running v0.7.12 build.",
|
||||
items: [
|
||||
{
|
||||
src: "screenshots/usage-config-page.png",
|
||||
title: "Usage Query Setup",
|
||||
title: "Usage query setup",
|
||||
desc: "NewAPI relay templates, timeout and auto-refresh, plus a test query, all on one page",
|
||||
},
|
||||
{
|
||||
src: "screenshots/preset-picker.png",
|
||||
title: "Provider Presets",
|
||||
desc: "22 presets ready out of the box, add popular vendors in one click",
|
||||
title: "Provider presets",
|
||||
desc: "Tick through 22 presets to add popular vendors",
|
||||
},
|
||||
{
|
||||
src: "screenshots/kimi-oauth-dialog.png",
|
||||
title: "Kimi Authorization",
|
||||
title: "Kimi authorization",
|
||||
desc: "Device-code flow identical to kimi login, auto-renews on expiry",
|
||||
},
|
||||
{
|
||||
src: "screenshots/dashboard-daily-detail.png",
|
||||
title: "Usage Details",
|
||||
title: "Usage details",
|
||||
desc: "Daily token and cost trends",
|
||||
},
|
||||
{
|
||||
src: "screenshots/sessions-light.png",
|
||||
title: "Session Manager",
|
||||
title: "Session manager",
|
||||
desc: "Browse and resume past sessions across providers",
|
||||
},
|
||||
{
|
||||
src: "screenshots/kimi-plan-quota-card.png",
|
||||
title: "Plan Quota Card",
|
||||
desc: "5-hour / weekly quotas at a glance",
|
||||
title: "Plan quota card",
|
||||
desc: "5-hour / weekly quotas read straight off the card",
|
||||
},
|
||||
],
|
||||
},
|
||||
performance: {
|
||||
title: "Lightweight & Native",
|
||||
subtitle: "Tauri + Rust core with a WebView2 frontend. Zero tray overhead.",
|
||||
title: "Lightweight and native",
|
||||
subtitle: "Tauri + Rust on the system WebView2 — no bundled Chromium. The three numbers below are idle-state measurements.",
|
||||
metrics: [
|
||||
{ target: 93, suffix: " MB", label: "Working Set", desc: "Idle working-set memory" },
|
||||
{ target: 34, suffix: " MB", label: "Private WS", desc: "Private memory (shared WebView2 excluded)" },
|
||||
@@ -504,6 +515,17 @@ const en: Dict = {
|
||||
syncedNote: "Synced from GitHub Releases",
|
||||
fallbackNote: "Built-in release notes",
|
||||
entries: [
|
||||
{
|
||||
version: "v0.7.12",
|
||||
date: "2026-09-03",
|
||||
items: [
|
||||
"Adapted to kimi-code 0.40.1: experimental flag list updated (adds file_history / search_worker; secondary-model now on by default)",
|
||||
"Flag priority semantics fixed: explicit configuration takes precedence over the master toggle, which no longer locks every switch",
|
||||
"New dangerous-command guard toggle: Auto mode rejects rm -rf / shutdown outright, other modes force a confirmation prompt",
|
||||
"Fix race-condition errors when clicking repeatedly while the WebUI is slow to open for the first time",
|
||||
"models-dev snapshot updated to 7495 models / 212 providers",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "v0.7.4",
|
||||
date: "2026-08-09",
|
||||
@@ -534,7 +556,7 @@ const en: Dict = {
|
||||
"Compatible with kimi-code 0.33+ (v2 engine): loop control now writes max_attempts_per_step with auto-migration",
|
||||
"models.dev model list and pricing snapshot synced",
|
||||
"Enterprise-style website refresh; publisher identity unified as codingplan.site",
|
||||
"macOS install polish: releases now ship install-macos.sh, a one-click script that clears the download quarantine",
|
||||
"macOS install polish: releases now ship install-macos.sh, which clears the download quarantine for you",
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -565,7 +587,7 @@ const en: Dict = {
|
||||
version: "v0.6.9",
|
||||
date: "2026-08-01",
|
||||
items: [
|
||||
"Recent requests gain a cache-hit-rate column, per request at a glance",
|
||||
"Recent requests gain a cache-hit-rate column, shown per request",
|
||||
"Chinese UI now shows costs in CNY (latest exchange rate); English keeps USD",
|
||||
"Dashboard speedup: memoized price resolution drops the scan from seconds to milliseconds; load time / payload tracking in the top-right corner",
|
||||
],
|
||||
@@ -592,7 +614,7 @@ const en: Dict = {
|
||||
version: "v0.6.6",
|
||||
date: "2026-08-01",
|
||||
items: [
|
||||
"Model pricing aligned with models.dev, covering 5910 models",
|
||||
"Model pricing data aligned with models.dev, snapshot refreshed per release",
|
||||
"New TokenRhythm provider with referral link",
|
||||
"Settings copy and source attribution polish",
|
||||
],
|
||||
@@ -652,56 +674,56 @@ const en: Dict = {
|
||||
],
|
||||
},
|
||||
privacy: {
|
||||
title: "Your Data Stays on Your Machine",
|
||||
title: "Your data stays on your machine",
|
||||
subtitle: "Our privacy and open-source commitments in three sentences.",
|
||||
items: [
|
||||
{
|
||||
id: "local",
|
||||
title: "Local Storage",
|
||||
title: "Local storage",
|
||||
desc: "Configs and usage data live only in ~/.kimi-switch and config.toml. Nothing is uploaded.",
|
||||
},
|
||||
{
|
||||
id: "direct",
|
||||
title: "Direct Connections",
|
||||
title: "Direct connections",
|
||||
desc: "Billing queries go straight to each vendor's official API. No middlemen, no tracking.",
|
||||
},
|
||||
{
|
||||
id: "open",
|
||||
title: "Open & Auditable",
|
||||
title: "Open & auditable",
|
||||
desc: "Fully open source under MIT on GitHub. Reviews and contributions welcome.",
|
||||
},
|
||||
],
|
||||
},
|
||||
download: {
|
||||
title: "Download Kimi Switch",
|
||||
subtitle: "Current version v0.7.4 · Windows, macOS and Linux now released",
|
||||
autoUpdate: "Built-in update detection: upgrade in one click from Settings when a new version ships.",
|
||||
subtitle: "Current version v0.7.12 · Windows, macOS and Linux now released",
|
||||
autoUpdate: "Update checks run at startup and every 8 hours; Settings also has a manual check.",
|
||||
ready: "Available",
|
||||
wip: "In development",
|
||||
githubBtn: "Download from GitHub",
|
||||
mirrorBtn: "China mirror",
|
||||
items: [
|
||||
{ id: "windows", name: "Windows", ready: true, note: ".msi installer, lives in the system tray" },
|
||||
{ id: "macos", name: "macOS", ready: true, note: ".dmg installer (Apple Silicon), unsigned: download install-macos.sh and run bash install-macos.sh for one-click install" },
|
||||
{ id: "macos", name: "macOS", ready: true, note: ".dmg installer (Apple Silicon); unsigned — grab install-macos.sh from the release and run bash install-macos.sh to clear the quarantine flag" },
|
||||
{ id: "linux", name: "Linux", ready: true, note: ".deb / .AppImage / .rpm" },
|
||||
],
|
||||
},
|
||||
cta: {
|
||||
title: "Bring every provider into one app",
|
||||
subtitle: "Free · Open source MIT · One-minute install",
|
||||
title: "Put provider config, usage and billing in one window",
|
||||
subtitle: "Free · MIT licensed · Windows / macOS / Linux",
|
||||
download: "Free Download",
|
||||
source: "GitHub Source",
|
||||
source: "GitHub source",
|
||||
},
|
||||
seo: {
|
||||
home: {
|
||||
title: "Kimi Switch — Unified Management for AI Providers · Usage & Billing",
|
||||
title: "Kimi Switch — LLM provider config, usage and billing in one app",
|
||||
description:
|
||||
"Kimi Switch / KimiCodeSwitch — a Windows desktop manager for multiple LLM providers. Switch between Kimi, Zhipu GLM, MiniMax, DeepSeek, OpenRouter and TokenRhythm in one click, with usage and billing at a glance. Open source MIT.",
|
||||
"Kimi Switch / KimiCodeSwitch — a desktop manager for LLM provider configuration. 22 presets write straight into ~/.kimi-code/config.toml; usage and billing read from each vendor's own API. Open source MIT.",
|
||||
},
|
||||
features: {
|
||||
title: "Core Features - Kimi Switch",
|
||||
title: "Core features - Kimi Switch",
|
||||
description:
|
||||
"Kimi Switch core features: multi-provider management, usage & billing, one-click Kimi login, smart model discovery and auto updates. 22 presets out of the box.",
|
||||
"Kimi Switch features: provider presets and custom config, usage & billing, Kimi device-code login, model discovery and periodic update checks.",
|
||||
},
|
||||
changelog: {
|
||||
title: "Changelog - Kimi Switch",
|
||||
@@ -715,7 +737,7 @@ const en: Dict = {
|
||||
},
|
||||
},
|
||||
footer: {
|
||||
recommend: "Recommended Providers",
|
||||
recommend: "Recommended providers",
|
||||
mirror: "China mirror",
|
||||
downloads: "Total GitHub downloads: {n}",
|
||||
creditBefore: "Dashboard & session management adapted from ",
|
||||
|
||||
+10
-33
@@ -23,9 +23,9 @@
|
||||
--accent: 240 6% 94%;
|
||||
--accent-foreground: 240 9% 11%;
|
||||
--border: 240 6% 90%;
|
||||
--primary: 221 83% 53%;
|
||||
--primary: 221 74% 50%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--ring: 221 83% 53%;
|
||||
--ring: 221 74% 50%;
|
||||
/* 克制分层阴影(色相一致的 tinted shadow,非荧光发光) */
|
||||
--shadow-soft: 0 1px 2px hsl(240 9% 11% / 0.05),
|
||||
0 8px 24px hsl(240 9% 11% / 0.06);
|
||||
@@ -44,9 +44,9 @@
|
||||
--accent: 222 25% 14%;
|
||||
--accent-foreground: 220 20% 92%;
|
||||
--border: 222 22% 17%;
|
||||
--primary: 217 91% 60%;
|
||||
--primary: 216 68% 62%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--ring: 217 91% 60%;
|
||||
--ring: 216 68% 62%;
|
||||
--shadow-soft: 0 1px 2px hsl(222 47% 2% / 0.5),
|
||||
0 8px 24px hsl(222 47% 2% / 0.45);
|
||||
--shadow-lift: 0 1px 2px hsl(222 47% 2% / 0.5),
|
||||
@@ -60,24 +60,6 @@
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased;
|
||||
}
|
||||
/* 科技风底纹:固定网格线,极低透明度,不干扰阅读 */
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(to right, hsl(var(--primary) / 0.04) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, hsl(var(--primary) / 0.04) 1px, transparent 1px);
|
||||
background-size: 44px 44px;
|
||||
mask-image: radial-gradient(ellipse 80% 60% at 50% 0%, black 30%, transparent 75%);
|
||||
}
|
||||
#root {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 选中色与键盘焦点(无障碍) */
|
||||
::selection {
|
||||
background: hsl(var(--primary) / 0.16);
|
||||
@@ -96,19 +78,14 @@
|
||||
|
||||
@layer components {
|
||||
.container-page {
|
||||
@apply mx-auto w-full max-w-6xl px-6;
|
||||
@apply mx-auto w-full max-w-7xl px-5 md:px-8;
|
||||
}
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-xl px-5 py-2.5 text-sm font-medium transition-all duration-200;
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-lg px-5 py-2.5 text-sm font-medium transition-all duration-200;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply btn bg-primary text-primary-foreground hover:bg-primary/90 hover:-translate-y-px active:translate-y-0 active:scale-[0.98];
|
||||
box-shadow: 0 1px 2px hsl(var(--primary) / 0.3),
|
||||
0 6px 16px hsl(var(--primary) / 0.22);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
box-shadow: 0 1px 2px hsl(var(--primary) / 0.3),
|
||||
0 10px 24px hsl(var(--primary) / 0.3);
|
||||
box-shadow: 0 1px 2px hsl(221 45% 25% / 0.25);
|
||||
}
|
||||
.btn-ghost {
|
||||
@apply btn border border-border bg-card/60 text-foreground hover:border-foreground/25 hover:bg-accent active:scale-[0.98];
|
||||
@@ -126,14 +103,14 @@
|
||||
}
|
||||
/* 截图容器:限定最大宽高,避免撑满页面 */
|
||||
.screenshot {
|
||||
@apply overflow-hidden rounded-2xl border border-border bg-muted;
|
||||
@apply overflow-hidden rounded-xl border border-border bg-muted;
|
||||
box-shadow: var(--shadow-lift);
|
||||
}
|
||||
.screenshot > img {
|
||||
@apply block h-auto w-full;
|
||||
}
|
||||
/* 图标容器:统一的蓝色徽标容器 */
|
||||
/* 图标容器:中性底、统一描边宽度(Phosphor duotone) */
|
||||
.icon-chip {
|
||||
@apply flex h-10 w-10 items-center justify-center rounded-xl border border-primary/20 bg-primary/10 text-primary;
|
||||
@apply flex h-10 w-10 items-center justify-center rounded-lg border border-border bg-muted text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -7,42 +7,36 @@ export default function CTA() {
|
||||
<section
|
||||
id="cta"
|
||||
aria-labelledby="cta-title"
|
||||
className="py-24"
|
||||
className="pt-20 pb-28"
|
||||
>
|
||||
<div className="container-page">
|
||||
<div className="card relative overflow-hidden p-12 text-center md:p-16">
|
||||
{/* 克制同色系光晕 + 顶部强调线(去紫,保持页面调性) */}
|
||||
{/* 左对齐 + 内容错位:不再整块居中 */}
|
||||
<div className="card relative max-w-3xl p-10 md:p-14">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -top-24 left-1/2 h-64 w-[480px] -translate-x-1/2 rounded-full bg-primary/10 blur-3xl"
|
||||
className="absolute inset-x-0 top-0 h-px bg-primary/25"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-primary/40 to-transparent"
|
||||
/>
|
||||
<div className="relative">
|
||||
<h2
|
||||
id="cta-title"
|
||||
className="text-3xl font-bold tracking-[-0.03em] md:text-5xl"
|
||||
<h2
|
||||
id="cta-title"
|
||||
className="text-3xl font-bold tracking-[-0.03em] md:text-4xl"
|
||||
>
|
||||
{t.cta.title}
|
||||
</h2>
|
||||
<p className="mt-4 max-w-[52ch] text-base text-muted-foreground md:text-lg">
|
||||
{t.cta.subtitle}
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<Link to="/download" className="btn-primary">
|
||||
{t.cta.download}
|
||||
</Link>
|
||||
<a
|
||||
href="https://github.com/billowliu2/KimiSwitch"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="btn-ghost"
|
||||
>
|
||||
{t.cta.title}
|
||||
</h2>
|
||||
<p className="mx-auto mt-4 max-w-2xl text-base text-muted-foreground md:text-lg">
|
||||
{t.cta.subtitle}
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap justify-center gap-3">
|
||||
<Link to="/download" className="btn-primary">
|
||||
{t.cta.download}
|
||||
</Link>
|
||||
<a
|
||||
href="https://github.com/billowliu2/KimiSwitch"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="btn-ghost"
|
||||
>
|
||||
{t.cta.source}
|
||||
</a>
|
||||
</div>
|
||||
{t.cta.source}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,27 +8,30 @@ const icons: Record<string, Icon> = {
|
||||
linux: LinuxLogo,
|
||||
};
|
||||
|
||||
const VERSION = "0.7.4";
|
||||
const VERSION = "0.7.12";
|
||||
const GITHUB = "https://github.com/billowliu2/KimiSwitch";
|
||||
const MIRROR_RELEASES = "https://git.codingplan.site/admin/KimiCodeSwitch/releases";
|
||||
const dl = (file: string) => `${GITHUB}/releases/download/v${VERSION}/${file}`;
|
||||
|
||||
/** Per-platform download assets for the current release (names match the CI
|
||||
* release workflow exactly — verified against the published v0.7.4 assets:
|
||||
* tauri-bundler on CI produces `Kimi.Switch_…` (dots) on every platform). */
|
||||
* release workflow exactly — verified against the published v0.7.12 assets:
|
||||
* tauri-bundler on CI produces `Kimi.Switch_…` (dots) on every platform,
|
||||
* except the rpm which uses `Kimi.Switch-<version>-1` (hyphens).
|
||||
* Filenames interpolate VERSION, so a release bump only changes the
|
||||
* constant above. */
|
||||
const assets: Record<string, { label: string; href: string }[]> = {
|
||||
windows: [
|
||||
{ label: "MSI", href: dl("Kimi.Switch_0.7.4_x64_en-US.msi") },
|
||||
{ label: "MSI", href: dl(`Kimi.Switch_${VERSION}_x64_en-US.msi`) },
|
||||
{ label: "镜像", href: MIRROR_RELEASES },
|
||||
],
|
||||
macos: [
|
||||
{ label: "Apple Silicon (.dmg)", href: dl("Kimi.Switch_0.7.4_aarch64.dmg") },
|
||||
{ label: "Apple Silicon (.dmg)", href: dl(`Kimi.Switch_${VERSION}_aarch64.dmg`) },
|
||||
{ label: "安装脚本", href: dl("install-macos.sh") },
|
||||
],
|
||||
linux: [
|
||||
{ label: ".deb", href: dl("Kimi.Switch_0.7.4_amd64.deb") },
|
||||
{ label: ".AppImage", href: dl("Kimi.Switch_0.7.4_amd64.AppImage") },
|
||||
{ label: ".rpm", href: dl("Kimi.Switch-0.7.4-1.x86_64.rpm") },
|
||||
{ label: ".deb", href: dl(`Kimi.Switch_${VERSION}_amd64.deb`) },
|
||||
{ label: ".AppImage", href: dl(`Kimi.Switch_${VERSION}_amd64.AppImage`) },
|
||||
{ label: ".rpm", href: dl(`Kimi.Switch-${VERSION}-1.x86_64.rpm`) },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -40,23 +43,36 @@ export default function Download() {
|
||||
title={t.download.title}
|
||||
subtitle={t.download.subtitle}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{/* 不对称:Windows 为主推平台,横跨整行;macOS / Linux 两列在下方 */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{t.download.items.map((p) => {
|
||||
const Icon = icons[p.id] ?? WindowsLogo;
|
||||
const links = assets[p.id] ?? [];
|
||||
const featured = p.id === "windows";
|
||||
return (
|
||||
<div key={p.id} className="card flex flex-col">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="icon-chip">
|
||||
<Icon className="h-5 w-5" weight="duotone" />
|
||||
<div
|
||||
key={p.id}
|
||||
className={`card flex flex-col ${
|
||||
featured
|
||||
? "md:col-span-2 md:flex-row md:items-center md:justify-between md:gap-10"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<div className="icon-chip shrink-0">
|
||||
<Icon className="h-5 w-5" weight="duotone" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold tracking-tight">{p.name}</h3>
|
||||
<span className="ml-auto rounded-md border border-primary/20 bg-primary/10 px-2 py-0.5 text-[11px] font-medium tracking-wide text-primary">
|
||||
{t.download.ready}
|
||||
</span>
|
||||
</div>
|
||||
<span className="rounded-md border border-primary/20 bg-primary/10 px-2 py-0.5 text-[11px] font-medium tracking-wide text-primary">
|
||||
{t.download.ready}
|
||||
</span>
|
||||
<p className={`text-sm text-muted-foreground ${featured ? "max-w-[65ch]" : ""}`}>
|
||||
{p.note}
|
||||
</p>
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold tracking-tight">{p.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">{p.note}</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<div className="mt-4 flex shrink-0 flex-wrap items-start gap-2 md:mt-0">
|
||||
{links.map((l) => (
|
||||
<a
|
||||
key={l.label}
|
||||
@@ -74,7 +90,7 @@ export default function Download() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="mt-6 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<p className="mt-6 flex max-w-[65ch] items-center gap-2 text-sm text-muted-foreground">
|
||||
<DownloadSimple className="h-4 w-4 text-primary" weight="duotone" />
|
||||
{t.download.autoUpdate}
|
||||
</p>
|
||||
|
||||
@@ -45,6 +45,8 @@ export default function Features() {
|
||||
key={f.id}
|
||||
style={{ transitionDelay: `${i * 60}ms` }}
|
||||
className={`card overflow-hidden p-0 transition-all duration-500 ease-out hover:border-primary/50 ${
|
||||
i % 2 === 1 ? "sm:mt-12" : ""
|
||||
} ${
|
||||
gridInView
|
||||
? "motion-safe:opacity-100 motion-safe:translate-y-0"
|
||||
: "motion-safe:opacity-0 motion-safe:translate-y-4"
|
||||
|
||||
@@ -23,15 +23,6 @@ export default function Hero() {
|
||||
aria-labelledby="hero-title"
|
||||
className="relative overflow-hidden pt-16 pb-20 md:pt-24"
|
||||
>
|
||||
{/* 克制的同色系光晕(去紫,统一蓝色 + 中性) */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -top-32 right-[-10%] h-[420px] w-[420px] rounded-full bg-primary/10 blur-3xl"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -left-40 top-1/2 h-[320px] w-[320px] rounded-full bg-primary/5 blur-3xl"
|
||||
/>
|
||||
<div className="container-page relative grid grid-cols-1 gap-10 md:grid-cols-2 md:items-center">
|
||||
<div>
|
||||
<div {...reveal(0)}>
|
||||
@@ -87,7 +78,7 @@ export default function Hero() {
|
||||
<div className="screenshot max-h-[420px]">
|
||||
<img
|
||||
src={asset("screenshots/hero.png")}
|
||||
alt="Kimi Switch: multi-LLM provider list with one-click switching"
|
||||
alt="Kimi Switch provider list, active provider pinned to the top"
|
||||
loading="eager"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -16,18 +16,23 @@ export default function Privacy() {
|
||||
title={t.privacy.title}
|
||||
subtitle={t.privacy.subtitle}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{/* 三条承诺改为一个面板内的三行,图标左置,避免三张等宽卡片 */}
|
||||
<div className="card divide-y divide-border p-0">
|
||||
{t.privacy.items.map((it) => {
|
||||
const Icon = icons[it.id] ?? Database;
|
||||
return (
|
||||
<div key={it.id} className="card">
|
||||
<div className="icon-chip mb-3">
|
||||
<div key={it.id} className="flex items-start gap-5 p-6 md:p-7">
|
||||
<div className="icon-chip shrink-0">
|
||||
<Icon className="h-5 w-5" weight="duotone" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold tracking-tight">{it.title}</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{it.desc}
|
||||
</p>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-base font-semibold tracking-tight">
|
||||
{it.title}
|
||||
</h3>
|
||||
<p className="mt-1 max-w-[65ch] text-sm leading-relaxed text-muted-foreground">
|
||||
{it.desc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -21,12 +21,18 @@ export default function Showcase() {
|
||||
key={s.src}
|
||||
style={{ transitionDelay: `${i * 60}ms` }}
|
||||
className={`transition-all duration-500 ease-out ${
|
||||
i === 0 ? "sm:col-span-2" : ""
|
||||
} ${
|
||||
gridInView
|
||||
? "motion-safe:opacity-100 motion-safe:translate-y-0"
|
||||
: "motion-safe:opacity-0 motion-safe:translate-y-4"
|
||||
}`}
|
||||
>
|
||||
<div className="screenshot group aspect-[4/3] overflow-hidden">
|
||||
<div
|
||||
className={`screenshot group overflow-hidden ${
|
||||
i === 0 ? "aspect-[16/9]" : "aspect-[4/3]"
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={asset(s.src)}
|
||||
alt={`Kimi Switch - ${s.title}`}
|
||||
|
||||
Reference in new issue
Block a user