Compare commits

...
10 Commits
Author SHA1 Message Date
KimiSwitch Dev fc09a29b9f chore(release): bump to v0.7.2 — 高级设置 + WebUI 快捷入口 + loop_control 兼容 + macOS 安装脚本
Release / Attach macOS install script (push) Canceled after 0s
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
2026-08-07 10:21:47 +08:00
KimiSwitch Dev 24d37fbbf4 chore: 设置出品公司为 codingplan.site 2026-08-06 15:09:12 +08:00
KimiSwitch Dev 54110316b4 chore(data): 同步 models.dev 最新模型数据 2026-08-06 15:00:04 +08:00
KimiSwitch Dev 75e48bc4c6 feat(website): 官网企业版视觉升级
- 引入 Geist 字体,去除紫色渐变,克制阴影与交互态
- 导航/卡片/按钮统一企业风格,各区块视觉收敛
2026-08-06 01:56:37 +08:00
KimiSwitch Dev b2f6e7a166 chore(data): 更新 models-dev 模型列表 2026-08-06 01:56:36 +08:00
KimiSwitch Dev 90cc4a16d2 fix(subagent): 实验开关改为滑动开关,修复开启态因 bg-primary 未定义而隐形
- Toggle 开启态改用已定义的 bg-blue-600(tailwind 配置无 primary 背景色,
  导致开启态滑轨透明、白色滑块在白底上不可见)
- 子代理开关移至模型下拉之后,统一使用滑动开关
2026-08-06 01:56:35 +08:00
KimiSwitch Dev 8aed04898c feat(subagent): 实验功能设置新增子代理模型配置
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
- 子代理设置页支持选择次主力模型,仅保存模型引用(不再写补丁字段),
  子代理直接继承所选模型的上下文长度与思考模式
- 界面只读展示继承的上下文长度与思考模式,移除补丁编辑项
- 仪表盘统计识别子代理请求(__secondary__):归为「子代理模型(估算)」
  并缓存次主力模型定价;自定义网关模型价格按官方同名模型跨 provider 匹配
- bump v0.7.1
2026-08-05 23:38:05 +08:00
KimiSwitch Dev 8b0daabd95 fix(tray): 托盘图标悬停气泡显示程序名(Kimi Switch) 2026-08-05 11:19:30 +08:00
KimiSwitch Dev 6e38bed092 docs(website): v0.7.0 更新日志补入 Kimi Eyes 插件站链接
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
2026-08-02 18:18:34 +08:00
KimiSwitch Dev 6df9095ee8 feat(settings): 关于页新增 Kimi Eyes 插件站链接(billowliu2.github.io/kimi-eyes) 2026-08-02 18:12:29 +08:00
45 changed files with 18654 additions and 9301 deletions

No files matched your search

+24 -1
View File
@@ -82,10 +82,33 @@ jobs:
### Downloads by platform ### Downloads by platform
- **Windows**: `.msi` installer - **Windows**: `.msi` installer
- **macOS**: `.dmg` (unsigned — first launch needs right-click → Open) + `.app` - **macOS**: `.dmg` + `.app`(未签名,仅 Apple Silicon / M 系列芯片)
- **Linux**: `.deb` / `.AppImage` / `.rpm` - **Linux**: `.deb` / `.AppImage` / `.rpm`
### macOS 安装说明(无开发者账号签名,需一次手动绕过)
1. 下载 `KimiSwitch_*.dmg`,双击挂载后把 `Kimi Switch.app` 拖入「应用程序」文件夹
2. 下载本 Release 附带的 `install-macos.sh`,在终端运行:
```bash
bash install-macos.sh
```
脚本会自动清除「下载隔离」属性并启动应用,之后可正常使用。
3. 手动替代方案:右键 `Kimi Switch.app` → 打开 → 弹窗中点「打开」(或 系统设置 → 隐私与安全性 → 仍要打开)
Full changelog: see commit history. Full changelog: see commit history.
prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') }} prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') }}
overwrite: true overwrite: true
args: ${{ matrix.args }} args: ${{ matrix.args }}
upload-macos-script:
name: Attach macOS install script
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Upload install-macos.sh to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "${{ github.ref_name }}" scripts/install-macos.sh --clobber
+7 -1
View File
@@ -55,7 +55,13 @@
## 下载 ## 下载
- **Windows**: [GitHub Releases](https://github.com/billowliu2/KimiSwitch/releases/latest) → `.msi` - **Windows**: [GitHub Releases](https://github.com/billowliu2/KimiSwitch/releases/latest) → `.msi`
- **macOS**: [GitHub Releases](https://github.com/billowliu2/KimiSwitch/releases/latest) → `.dmg`(**未签名** —— 首次启动请右键 → 打开绕过 Gatekeeper) - **macOS**: [GitHub Releases](https://github.com/billowliu2/KimiSwitch/releases/latest) → `.dmg`(**未签名**,仅 Apple Silicon / M 系列芯片)。安装请下载 Release 附带的 `install-macos.sh` 一键处理:
```bash
bash install-macos.sh
```
或手动:把 `Kimi Switch.app` 拖入「应用程序」→ 右键 → 打开 → 点「打开」绕过 Gatekeeper。
- **Linux**: [GitHub Releases](https://github.com/billowliu2/KimiSwitch/releases/latest) → `.deb` / `.AppImage` / `.rpm` - **Linux**: [GitHub Releases](https://github.com/billowliu2/KimiSwitch/releases/latest) → `.deb` / `.AppImage` / `.rpm`
历史版本(v0.5.x 及之前)请前往 [git.codingplan.site 仓库](https://git.codingplan.site/admin/KimiCodeSwitch/releases)。 历史版本(v0.5.x 及之前)请前往 [git.codingplan.site 仓库](https://git.codingplan.site/admin/KimiCodeSwitch/releases)。
+3 -1
View File
@@ -26,7 +26,9 @@ npm run tauri build -- --bundles app,dmg
Output: `src-tauri/target/release/bundle/{macos,dmg}/`. Output: `src-tauri/target/release/bundle/{macos,dmg}/`.
Unsigned: first launch requires right-click → Open to bypass Gatekeeper. Unsigned (no Developer ID account): downloaded copies are blocked by Gatekeeper.
For end users, attach `install-macos.sh` (auto-uploaded to each Release) or instruct:
right-click → Open, or `xattr -cr "/Applications/Kimi Switch.app"`.
### Linux (native) ### Linux (native)
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "kimiswitch", "name": "kimiswitch",
"private": true, "private": true,
"version": "0.7.0", "version": "0.7.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+18
View File
@@ -0,0 +1,18 @@
## v0.7.1
### 实验功能设置:子代理模型配置
- 新增「子代理模型(次主力模型)」设置:选择次主力模型后,子代理默认绑定该模型,不再继承主模型
- 仅保存模型引用,子代理直接继承所选模型的上下文长度与思考模式,无需补丁配置
- 界面只读展示继承的上下文长度与思考模式
### 仪表盘用量统计
- 识别子代理请求(`__secondary__`):归为「子代理模型(估算)」独立展示,并缓存次主力模型定价,删除配置后历史记录仍稳定估算
- 自定义网关模型(未收录 models.dev)按官方同名模型跨 provider 匹配价格,不再落入兜底估算
### Downloads by platform
- **Windows**: `.msi` installer
- **macOS**: `.dmg` (unsigned — first launch needs right-click → Open) + `.app`
- **Linux**: `.deb` / `.AppImage` / `.rpm`
+33
View File
@@ -0,0 +1,33 @@
## v0.7.2
### 子代理模型指定(实验功能)
- 新增「子代理模型(次主力模型)」指定:在实验功能设置中为子代理选择次主力模型后,子代理默认绑定该模型,不再继承主模型
- 实验开关改为滑动开关,修复开启态因样式缺失而不可见的问题
- 仅保存模型引用,子代理直接继承所选模型的上下文长度与思考模式
### 模型数据与发布
- models.dev 模型列表与价格数据同步更新
- 官网企业版视觉升级;出品公司标识统一为 codingplan.site
- macOS 发布优化:Release 附带 `install-macos.sh` 一键安装脚本(见下方说明)
### Downloads by platform
- **Windows**: `.msi` installer
- **macOS**: `.dmg` + `.app`(未签名,仅 Apple Silicon / M 系列芯片)
- **Linux**: `.deb` / `.AppImage` / `.rpm`
### macOS 安装说明(无开发者账号签名,需一次手动绕过)
> 原因:macOS 包未做 Developer ID 签名与公证(无 Apple 开发者账号),系统会拦截从网络下载的未签名应用(Gatekeeper)。`install-macos.sh` 通过清除「下载隔离」属性绕过该拦截,不影响功能与安全。
1. 下载 `Kimi.Switch_0.7.2_aarch64.dmg`,双击挂载后把 `Kimi Switch.app` 拖入「应用程序」文件夹
2. 下载本 Release 附带的 `install-macos.sh`,在终端运行:
```bash
bash install-macos.sh
```
脚本会自动清除下载隔离属性并启动应用,只需执行一次,之后正常使用。
3. 手动替代方案:右键 `Kimi Switch.app` → 打开 → 弹窗点「打开」;或 系统设置 → 隐私与安全性 → 仍要打开
+48
View File
@@ -0,0 +1,48 @@
#!/bin/bash
# install-macos.sh — Kimi Switch macOS 一键安装辅助脚本
#
# 背景:macOS 包未做 Developer ID 签名(无开发者账号),从网络下载后会被
# Gatekeeper 的「下载隔离(quarantine)」属性拦截。本脚本清除该属性后启动应用。
#
# 用法(把 Kimi Switch.app 拖入「应用程序」文件夹后执行):
# bash install-macos.sh
#
# 若脚本提示找不到应用,请先手动把 Kimi Switch.app 拖到 /Applications 再运行。
set -e
APP_NAME="Kimi Switch"
APP_PATH="/Applications/${APP_NAME}.app"
DOWNLOADED_DMG_NAME="KimiSwitch.dmg"
if [ ! -d "$APP_PATH" ]; then
echo "❌ 未找到 ${APP_PATH}"
echo ""
echo "请先把「${APP_NAME}.app」拖到「应用程序」文件夹,再重新运行本脚本:"
echo " 1. 双击下载的 ${DOWNLOADED_DMG_NAME}(磁盘映像会自动挂载)"
echo " 2. 把其中的 Kimi Switch.app 拖入「应用程序」文件夹"
echo " 3. 回到终端重新运行:bash install-macos.sh"
exit 1
fi
echo "① 清除「下载隔离」属性(Gatekeeper 拦截的来源)..."
xattr -cr "$APP_PATH" 2>/dev/null || true
echo " 完成"
echo "② 验证代码签名状态:"
if codesign -dv "$APP_PATH" 2>&1 | grep -q "Signature=adhoc"; then
echo " Signature=adhoc(正常,ad-hoc 签名,右键打开即可运行)"
else
echo " (未检测到签名信息,属正常,不影响本脚本的运行方式)"
fi
echo "③ 正在启动「${APP_NAME}」..."
open "$APP_PATH"
echo ""
echo "✅ 启动完成。"
echo ""
echo "如果仍提示「无法验证开发者」,请按下面任一种方式再试:"
echo " a) 右键 ${APP_NAME}.app → 打开 → 在弹出的提示中点「打开」"
echo " b) 系统设置 → 隐私与安全性 → 滚动到「安全性」→ 点「仍要打开」"
echo ""
echo "首次启动后应用即可正常使用,之后无需重复本操作。"
+1 -1
View File
@@ -1958,7 +1958,7 @@ dependencies = [
[[package]] [[package]]
name = "kimiswitch" name = "kimiswitch"
version = "0.7.0" version = "0.7.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
+2 -2
View File
@@ -1,8 +1,8 @@
[package] [package]
name = "kimiswitch" name = "kimiswitch"
version = "0.7.0" version = "0.7.2"
description = "Kimi Switch - model config manager" description = "Kimi Switch - model config manager"
authors = ["you"] authors = ["codingplan.site"]
edition = "2021" edition = "2021"
[build-dependencies] [build-dependencies]
+57
View File
@@ -949,6 +949,32 @@ pub fn open_installer(app: tauri::AppHandle, path: String) -> Result<(), String>
.open_path(&path, None::<&str>) .open_path(&path, None::<&str>)
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
/// Launch `kimi web` — starts the local Kimi Code Web UI server and opens it
/// in the default browser (the code-app web bundle built into kimi-code 0.33+).
/// The kimi CLI must be on PATH; the spawned process is detached so the server
/// keeps running while Kimi Switch stays open.
#[tauri::command]
pub fn open_kimi_web() -> Result<(), String> {
use std::process::Command;
let mut cmd = Command::new("kimi");
cmd.arg("web");
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
// CREATE_NO_WINDOW: avoid a console window flashing next to the GUI.
cmd.creation_flags(0x08000000);
}
cmd.spawn()
.map(|_| ())
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
"kimi executable not found on PATH — install Kimi Code CLI first".to_string()
} else {
format!("failed to start `kimi web`: {e}")
}
})
}
/// Preference for Linux is AppImage (portable, no install). If the preferred /// Preference for Linux is AppImage (portable, no install). If the preferred
/// extension is not present, returns None so the UI can fall back to /// extension is not present, returns None so the UI can fall back to
/// "open release page" instead of guessing a less-preferred format. /// "open release page" instead of guessing a less-preferred format.
@@ -1078,3 +1104,34 @@ pub async fn kimi_oauth_poll(
) -> Result<crate::oauth::DevicePollStatus, String> { ) -> Result<crate::oauth::DevicePollStatus, String> {
crate::oauth::poll_device_token(&device_code, interval).await crate::oauth::poll_device_token(&device_code, interval).await
} }
// ---------------------------------------------------------------------------
// 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.
///
/// 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",
];
let mut out = HashMap::new();
for name in VARS {
if let Ok(value) = std::env::var(name) {
if !value.trim().is_empty() {
out.insert(name.to_string(), value);
}
}
}
out
}
+100 -4
View File
@@ -149,6 +149,9 @@ pub struct ModelRow {
pub model_resolved: String, pub model_resolved: String,
pub price_id: String, pub price_id: String,
pub cost_estimated: bool, pub cost_estimated: bool,
/// True when any aggregated record came from a subagent request (Kimi Code
/// `__secondary__` marker, resolved to the configured secondary model).
pub is_secondary: bool,
pub requests: usize, pub requests: usize,
pub input_other: u64, pub input_other: u64,
pub output: u64, pub output: u64,
@@ -175,6 +178,9 @@ pub struct RecentRow {
pub cost_estimated: bool, pub cost_estimated: bool,
pub price_id: String, pub price_id: String,
pub from_env: bool, pub from_env: bool,
/// True when the record is a subagent request bound to the configured
/// secondary model (Kimi Code `__secondary__` marker).
pub is_secondary: bool,
} }
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
@@ -330,6 +336,9 @@ pub struct UsageRecord {
pub model_display: String, pub model_display: String,
pub provider: Option<String>, pub provider: Option<String>,
pub from_env: bool, pub from_env: bool,
/// True when the record is a subagent request bound to the configured
/// secondary model (Kimi Code `__secondary__` marker).
pub is_secondary: bool,
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -400,6 +409,26 @@ fn build_alias_provider_map(home: &Path) -> HashMap<String, String> {
map map
} }
/// Read `[secondary_model] model` from the Kimi Code config.toml — the alias
/// subagents bind to when the experimental secondary-model feature is on.
/// Kimi Code emits usage records with the internal marker `__secondary__` for
/// those requests; the marker is resolved to this alias so prices and display
/// line up with the actual model.
fn read_secondary_model_alias(home: &Path) -> Option<String> {
let path = home.join("config.toml");
if !path.is_file() {
return None;
}
let content = std::fs::read_to_string(&path).ok()?;
let parsed: toml::Value = toml::from_str(&content).ok()?;
parsed
.get("secondary_model")
.and_then(|s| s.get("model"))
.and_then(|m| m.as_str())
.map(|s| s.to_string())
.filter(|s| !s.is_empty())
}
/// Resolve the provider for a usage record's raw model key: prefer the `provider/` /// Resolve the provider for a usage record's raw model key: prefer the `provider/`
/// prefix, then fall back to the legacy alias→provider map (config + backups). /// prefix, then fall back to the legacy alias→provider map (config + backups).
fn resolve_provider(model_raw: &str, map: &HashMap<String, String>) -> Option<String> { fn resolve_provider(model_raw: &str, map: &HashMap<String, String>) -> Option<String> {
@@ -726,6 +755,15 @@ fn match_price_inner(model_name: &str) -> (String, f64, f64, f64, bool) {
return (id, c.cache_read, c.input, c.output, false); return (id, c.cache_read, c.input, c.output, false);
} }
} }
// 1c) still no exact match: fall back to a cross-provider bare-name
// suffix lookup in the models.dev snapshot. Custom-gateway aliases (e.g.
// "CodingPlan.site/deepseek-v4-flash", not catalogued in models.dev)
// then price against the official listing of the same model id.
if let Some((id, c)) = priced_suffix_lookup(model_name) {
if c.input > 0.0 || c.output > 0.0 {
return (id, c.cache_read, c.input, c.output, false);
}
}
// 2) legacy Kimi table (kept as a fallback for names not in models.dev). // 2) legacy Kimi table (kept as a fallback for names not in models.dev).
let bare_l = bare.to_ascii_lowercase(); let bare_l = bare.to_ascii_lowercase();
for p in &list_prices() { for p in &list_prices() {
@@ -758,9 +796,30 @@ fn cost_for_usage(input_other: u64, output: u64, cache_read: u64, cache_create:
// Scanner // Scanner
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Settings key caching the last configured secondary-model alias. Historical
/// `__secondary__` usage records carry no model identity, so billing follows
/// the currently configured secondary model; this cache keeps a stable basis
/// even after the secondary config is removed or changed.
const SECONDARY_MODEL_CACHE_KEY: &str = "dashboard.secondary_model_cache";
fn scan_usage(home: &Path) -> (Vec<UsageRecord>, ScanMeta) { fn scan_usage(home: &Path) -> (Vec<UsageRecord>, ScanMeta) {
let root = sessions_root(home); let root = sessions_root(home);
let alias2prov = build_alias_provider_map(home); let alias2prov = build_alias_provider_map(home);
// Resolve the `__secondary__` marker (subagent requests bound to the
// configured secondary model) to the real model alias once per scan.
// Prefer the current config; fall back to the persisted cache so records
// keep a stable billing basis after the secondary config is removed or
// changed. When the config still declares a secondary model, refresh the
// cache with it.
let current_secondary = read_secondary_model_alias(home);
let cached_secondary = crate::db::get_setting_pub(SECONDARY_MODEL_CACHE_KEY)
.ok()
.flatten()
.filter(|s| !s.is_empty());
let secondary_alias = current_secondary.clone().or(cached_secondary);
if let Some(alias) = &current_secondary {
let _ = crate::db::set_setting_pub(SECONDARY_MODEL_CACHE_KEY, alias);
}
let mut records = Vec::new(); let mut records = Vec::new();
let mut files_scanned = 0; let mut files_scanned = 0;
let mut lines_seen = 0; let mut lines_seen = 0;
@@ -800,6 +859,21 @@ fn scan_usage(home: &Path) -> (Vec<UsageRecord>, ScanMeta) {
if scope != "turn" { continue; } if scope != "turn" { continue; }
let model_raw = obj.get("model").and_then(|v| v.as_str()).unwrap_or("unknown").to_string(); let model_raw = obj.get("model").and_then(|v| v.as_str()).unwrap_or("unknown").to_string();
// Kimi Code writes the internal marker `__secondary__` as the model
// key for usage records emitted by subagents bound to the
// configured secondary model. The record stays keyed on that
// stable marker so historical records keep their semantics if the
// secondary config changes later; cost billing follows the
// currently configured secondary model, and the record is flagged
// is_secondary so the UI shows it as its own "subagent model"
// entry.
let is_secondary = model_raw == "__secondary__";
let from_env = model_raw == "__kimi_env_model__";
let price_model = if is_secondary {
secondary_alias.clone().unwrap_or_else(|| model_raw.clone())
} else {
model_raw.clone()
};
let time = obj.get("time").and_then(|v| v.as_u64()).unwrap_or(0); let time = obj.get("time").and_then(|v| v.as_u64()).unwrap_or(0);
let usage = &obj["usage"]; let usage = &obj["usage"];
let input_other = usage.get("inputOther").and_then(|v| v.as_u64()).unwrap_or(0); let input_other = usage.get("inputOther").and_then(|v| v.as_u64()).unwrap_or(0);
@@ -809,16 +883,22 @@ fn scan_usage(home: &Path) -> (Vec<UsageRecord>, ScanMeta) {
// Resolve model display // Resolve model display
let bare = model_raw.rsplit_once('/').map(|x| x.1).unwrap_or(&model_raw); let bare = model_raw.rsplit_once('/').map(|x| x.1).unwrap_or(&model_raw);
let (cost, est) = cost_for_usage(input_other, output, cache_read, cache_create, &model_raw); let (cost, est) = cost_for_usage(input_other, output, cache_read, cache_create, &price_model);
let (pid, _ch, _ip, _op, _) = match_price(&model_raw); // Subagent records are inherently estimates: the `__secondary__`
// marker carries no model identity, so the cost is flagged as
// estimated even when the current secondary config resolves to a
// priced listing.
let est = est || is_secondary;
let (pid, _ch, _ip, _op, _) = match_price(&price_model);
records.push(UsageRecord { records.push(UsageRecord {
time, time,
model: model_raw.clone(), model: model_raw.clone(),
model_resolved: bare.to_string(), model_resolved: bare.to_string(),
model_display: model_raw.clone(), model_display: model_raw.clone(),
provider: resolve_provider(&model_raw, &alias2prov), provider: resolve_provider(&price_model, &alias2prov),
from_env: model_raw == "__kimi_env_model__", from_env,
is_secondary,
input_other, input_other,
output, output,
input_cache_read: cache_read, input_cache_read: cache_read,
@@ -925,6 +1005,7 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats {
model_resolved: r.model_resolved.clone(), model_resolved: r.model_resolved.clone(),
price_id: r.price_id.clone(), price_id: r.price_id.clone(),
cost_estimated: r.cost_estimated, cost_estimated: r.cost_estimated,
is_secondary: r.is_secondary,
requests: 0, input_other: 0, output: 0, requests: 0, input_other: 0, output: 0,
input_cache_read: 0, input_cache_creation: 0, input_cache_read: 0, input_cache_creation: 0,
cost_usd: 0.0, total_tokens: 0, cache_hit_rate: 0.0, cost_usd: 0.0, total_tokens: 0, cache_hit_rate: 0.0,
@@ -933,6 +1014,7 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats {
}); });
entry.1.add(r); entry.1.add(r);
entry.0.cost_estimated = entry.0.cost_estimated || r.cost_estimated; entry.0.cost_estimated = entry.0.cost_estimated || r.cost_estimated;
entry.0.is_secondary = entry.0.is_secondary || r.is_secondary;
} }
let total_input = totals.input_other + totals.input_cache_read + totals.input_cache_creation; let total_input = totals.input_other + totals.input_cache_read + totals.input_cache_creation;
@@ -979,6 +1061,7 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats {
total_tokens: r.input_other + r.output + r.input_cache_read + r.input_cache_creation, total_tokens: r.input_other + r.output + r.input_cache_read + r.input_cache_creation,
cost_usd: r.cost_usd, cost_estimated: r.cost_estimated, cost_usd: r.cost_usd, cost_estimated: r.cost_estimated,
price_id: r.price_id.clone(), from_env: r.from_env, price_id: r.price_id.clone(), from_env: r.from_env,
is_secondary: r.is_secondary,
}).collect(); }).collect();
RangeStats { RangeStats {
@@ -1760,6 +1843,19 @@ mod pricing_tests {
assert!(est, "unknown models must be flagged as estimated"); assert!(est, "unknown models must be flagged as estimated");
} }
#[test]
fn custom_gateway_alias_bills_at_official_listing() {
// Custom-gateway aliases not catalogued in models.dev (e.g. the user's
// aggregated gateway "CodingPlan.site/deepseek-v4-flash") must resolve
// via the cross-provider bare-name lookup to a priced official entry
// instead of falling into the last-resort estimate.
let (id, _, input, output, est) = match_price("CodingPlan.site/deepseek-v4-flash");
assert!(!est, "resolved via models.dev, not estimated");
assert_eq!(id, "deepseek/deepseek-v4-flash");
assert_eq!(input, 0.14);
assert_eq!(output, 0.28);
}
#[test] #[test]
fn ambiguous_ids_prefer_official_provider() { fn ambiguous_ids_prefer_official_provider() {
// glm-4.6 ships under resellers too (e.g. 302ai); the official zhipuai // glm-4.6 ships under resellers too (e.g. 302ai); the official zhipuai
+103
View File
@@ -343,6 +343,39 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
} }
root.insert("models".to_string(), TomlValue::Table(models_table)); root.insert("models".to_string(), TomlValue::Table(models_table));
// Sync UI-managed top-level sections (thinking, experimental,
// secondary_model, ...) from config.raw_other back into the root.
// At import time every top-level key except default_model/providers/
// models was collected into raw_other, so raw_other is the authoritative
// snapshot of those sections. This both persists UI edits to those
// sections and drops sections the user explicitly removed (absent from
// raw_other but still present in `existing`).
let mut managed_keys = std::collections::HashSet::new();
if let TomlValue::Table(extra) =
json_to_toml(&config.raw_other).unwrap_or(TomlValue::Table(Table::new()))
{
for (k, v) in extra {
if k == "default_model" || k == "providers" || k == "models" {
continue;
}
managed_keys.insert(k.clone());
root.insert(k, v);
}
}
let stale_keys: Vec<String> = root
.keys()
.filter(|k| {
*k != "default_model"
&& *k != "providers"
&& *k != "models"
&& !managed_keys.contains(*k)
})
.cloned()
.collect();
for k in stale_keys {
root.remove(&k);
}
TomlValue::Table(root) TomlValue::Table(root)
} }
@@ -552,6 +585,76 @@ api_key = ""
assert!(caps.iter().any(|v| v.as_str() == Some("thinking"))); assert!(caps.iter().any(|v| v.as_str() == Some("thinking")));
} }
#[test]
fn kimi_code_export_syncs_raw_other_top_level_sections() {
// Regression: UI-managed top-level sections ([thinking],
// [experimental], [secondary_model], ...) collected into raw_other at
// import time must be written back on export; sections removed from
// raw_other must be dropped from the output.
let toml_str = r#"
default_model = "glm-5.2"
default_thinking = true
[providers."glmzhongzhuan"]
type = "anthropic"
api_key = "sk-test"
[models."glm-5.2"]
provider = "glmzhongzhuan"
model = "glm-5.2"
max_context_size = 900000
[thinking]
enabled = true
[experimental]
secondary-model = true
[secondary_model]
model = "glm-5.2"
default_effort = "low"
"#;
let value: TomlValue = toml_str.parse().unwrap();
let mut config = kimi_code_to_config(&value);
// raw_other collected the unknown top-level sections
let raw = config.raw_other.as_object().unwrap();
assert!(raw.contains_key("thinking"));
assert!(raw.contains_key("experimental"));
assert!(raw.contains_key("secondary_model"));
// UI edit: flip the experimental flag off and drop secondary_model
let mut raw = config.raw_other.as_object().unwrap().clone();
raw.insert(
"experimental".to_string(),
serde_json::json!({"secondary-model": false}),
);
raw.remove("secondary_model");
config.raw_other = serde_json::Value::Object(raw);
let exported = config_to_kimi_code(&config, Some(&value));
let root = exported.as_table().unwrap();
// Updated section is written back
let exp = root.get("experimental").unwrap().as_table().unwrap();
assert_eq!(
exp.get("secondary-model").and_then(|v| v.as_bool()),
Some(false)
);
// Removed section is dropped
assert!(root.get("secondary_model").is_none());
// Untouched sections are preserved
let thinking = root.get("thinking").unwrap().as_table().unwrap();
assert_eq!(
thinking.get("enabled").and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
root.get("default_thinking").and_then(|v| v.as_bool()),
Some(true)
);
}
#[test] #[test]
fn kimi_code_export_does_not_inject_thinking_from_supports_1m() { fn kimi_code_export_does_not_inject_thinking_from_supports_1m() {
let mut providers = IndexMap::new(); let mut providers = IndexMap::new();
+3
View File
@@ -44,6 +44,7 @@ pub fn run() {
let icon = app.default_window_icon().unwrap().clone(); let icon = app.default_window_icon().unwrap().clone();
TrayIconBuilder::new() TrayIconBuilder::new()
.icon(icon) .icon(icon)
.tooltip("Kimi Switch")
.menu(&menu) .menu(&menu)
.show_menu_on_left_click(false) .show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id.as_ref() { .on_menu_event(|app, event| match event.id.as_ref() {
@@ -108,8 +109,10 @@ pub fn run() {
commands::download_update, commands::download_update,
commands::open_installer, commands::open_installer,
commands::open_external_url, commands::open_external_url,
commands::open_kimi_web,
commands::kimi_oauth_start, commands::kimi_oauth_start,
commands::kimi_oauth_poll, commands::kimi_oauth_poll,
commands::get_experimental_env_status,
dashboard::get_paths, dashboard::get_paths,
dashboard::get_prices, dashboard::get_prices,
dashboard::get_summary, dashboard::get_summary,
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"productName": "Kimi Switch", "productName": "Kimi Switch",
"version": "0.7.0", "version": "0.7.2",
"identifier": "com.kimiswitch.app", "identifier": "com.kimiswitch.app",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
@@ -37,6 +37,7 @@
"icons/icon.ico", "icons/icon.ico",
"icons/icon.icns" "icons/icon.icns"
], ],
"publisher": "codingplan.site",
"windows": { "windows": {
"webviewInstallMode": { "webviewInstallMode": {
"type": "downloadBootstrapper" "type": "downloadBootstrapper"
+21 -2
View File
@@ -5,6 +5,7 @@ import { useConfig } from "./hooks/useConfig";
import { useUpdateCheck } from "./hooks/useUpdateCheck"; import { useUpdateCheck } from "./hooks/useUpdateCheck";
import { ProviderList } from "./components/ProviderList"; import { ProviderList } from "./components/ProviderList";
import { ProviderEdit } from "./components/ProviderEdit"; import { ProviderEdit } from "./components/ProviderEdit";
import { SubagentSettingsPage } from "./components/SubagentSettingsPage";
import { DashboardPage } from "./components/dashboard/DashboardPage"; import { DashboardPage } from "./components/dashboard/DashboardPage";
import { SessionsPage } from "./components/sessions/SessionsPage"; import { SessionsPage } from "./components/sessions/SessionsPage";
import { SettingsModal } from "./components/SettingsModal"; import { SettingsModal } from "./components/SettingsModal";
@@ -70,7 +71,7 @@ export default function App() {
updateConfig, updateConfig,
} = useConfig(agent); } = useConfig(agent);
const [view, setView] = useState<"list" | "edit" | "dashboard" | "sessions">("list"); const [view, setView] = useState<"list" | "edit" | "subagent" | "dashboard" | "sessions">("list");
const [editingProvider, setEditingProvider] = useState<string>(""); const [editingProvider, setEditingProvider] = useState<string>("");
const [loadTimeout, setLoadTimeout] = useState(false); const [loadTimeout, setLoadTimeout] = useState(false);
const [switchMessage, setSwitchMessage] = useState<string | null>(null); const [switchMessage, setSwitchMessage] = useState<string | null>(null);
@@ -573,7 +574,7 @@ export default function App() {
type="button" type="button"
onClick={() => setView("list")} onClick={() => setView("list")}
className={`px-3 py-1 text-sm rounded transition-colors ${ className={`px-3 py-1 text-sm rounded transition-colors ${
view === "list" || view === "edit" view === "list" || view === "edit" || view === "subagent"
? "bg-blue-600 text-white" ? "bg-blue-600 text-white"
: "text-content-muted hover:text-content-primary" : "text-content-muted hover:text-content-primary"
}`} }`}
@@ -649,6 +650,15 @@ export default function App() {
<DashboardPage /> <DashboardPage />
) : view === "sessions" ? ( ) : view === "sessions" ? (
<SessionsPage /> <SessionsPage />
) : view === "subagent" ? (
<SubagentSettingsPage
rawOther={config.raw_other}
models={config.models}
onChange={(nextRawOther) =>
updateConfig((cfg) => ({ ...cfg, raw_other: nextRawOther }))
}
onBack={() => setView("list")}
/>
) : view === "list" ? ( ) : view === "list" ? (
<ProviderList <ProviderList
providers={providers} providers={providers}
@@ -813,6 +823,15 @@ export default function App() {
> >
{t("openConfigDir")} {t("openConfigDir")}
</button> </button>
{view === "list" && (
<button
type="button"
onClick={() => setView("subagent")}
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"
>
{t("subagentSettings")}
</button>
)}
</div> </div>
</footer> </footer>
)} )}
+25 -104
View File
@@ -1,8 +1,8 @@
import type { ReactNode } from "react";
import { useTranslation } from "../i18n"; import { useTranslation } from "../i18n";
import type { TranslationKey } from "../i18n/zh"; import type { TranslationKey } from "../i18n/zh";
import { getAgentSettings, setAgentSettings } from "../lib/agent-settings"; import { getAgentSettings, setAgentSettings } from "../lib/agent-settings";
import type { AgentSettings, Hook, PermissionRule } from "../types"; import type { AgentSettings, Hook, PermissionRule } from "../types";
import { Card, Checkbox, NumberField, Segmented } from "./ui/controls";
interface AgentSettingsPanelProps { interface AgentSettingsPanelProps {
rawOther: unknown; rawOther: unknown;
@@ -24,7 +24,18 @@ const PERMISSION_LABELS: Record<(typeof PERMISSION_DECISIONS)[number], Translati
ask: "permissionAsk", ask: "permissionAsk",
}; };
const COMMON_EVENTS = ["PreToolUse", "PostToolUse"] as const; // kimi-code 0.33+ added TurnStarted / UserPromptQueued / TaskStarted /
// SessionHeartbeat hook events (plus SessionStart / SessionEnd).
const COMMON_EVENTS = [
"PreToolUse",
"PostToolUse",
"TurnStarted",
"UserPromptQueued",
"TaskStarted",
"SessionHeartbeat",
"SessionStart",
"SessionEnd",
] as const;
export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelProps) { export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -91,9 +102,18 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
<Card title={t("loopControlSettings")}> <Card title={t("loopControlSettings")}>
<NumberField <NumberField
label={t("maxRetriesPerStep")} label={t("maxAttemptsPerStep")}
value={settings.loop_control?.max_retries_per_step ?? 3} value={
onChange={(v) => updateLoopControl({ max_retries_per_step: v })} settings.loop_control?.max_attempts_per_step ??
settings.loop_control?.max_retries_per_step ??
3
}
onChange={(v) =>
updateLoopControl({
max_attempts_per_step: v,
max_retries_per_step: v,
})
}
/> />
<NumberField <NumberField
label={t("reservedContextSize")} label={t("reservedContextSize")}
@@ -275,102 +295,3 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
</div> </div>
); );
} }
function Card({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="bg-panel border border-border rounded-xl p-4 space-y-3">
<h4 className="text-content-muted text-sm font-medium">{title}</h4>
{children}
</div>
);
}
function Checkbox({
label,
checked,
disabled,
onChange,
}: {
label: string;
checked: boolean;
disabled?: boolean;
onChange: (checked: boolean) => void;
}) {
return (
<label
className={`flex items-center gap-2 text-sm ${
disabled ? "text-content-muted" : "text-content-primary"
}`}
>
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
className="rounded border-border bg-input text-blue-600 focus:ring-blue-500"
/>
{label}
</label>
);
}
function NumberField({
label,
value,
onChange,
}: {
label: string;
value?: number;
onChange: (value: number | undefined) => void;
}) {
return (
<label className="flex items-center gap-3 text-sm text-content-primary">
<span className="text-content-muted">{label}</span>
<input
type="number"
value={value ?? ""}
onChange={(e) => {
const raw = e.target.value;
onChange(raw === "" ? undefined : Number(raw));
}}
className="w-32 bg-input border border-border rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</label>
);
}
function Segmented({
options,
value,
onChange,
disabled,
}: {
options: { key: string; label: string }[];
value: string;
onChange: (value: string) => void;
disabled?: boolean;
}) {
return (
<div
className={`flex items-center rounded overflow-hidden border border-border ${
disabled ? "opacity-50" : ""
}`}
>
{options.map((opt) => (
<button
key={opt.key}
type="button"
disabled={disabled}
onClick={() => onChange(opt.key)}
className={`px-3 py-1 text-sm ${
value === opt.key
? "bg-blue-600 text-white"
: "bg-input text-content-muted hover:bg-hover-2"
}`}
>
{opt.label}
</button>
))}
</div>
);
}
+12
View File
@@ -384,6 +384,18 @@ export function SettingsModal({
billowliu2.github.io/KimiSwitch billowliu2.github.io/KimiSwitch
</button> </button>
</div> </div>
<div>
{t("kimiEyesSite")}:{" "}
<button
type="button"
onClick={() =>
openExternal("https://billowliu2.github.io/kimi-eyes")
}
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer"
>
billowliu2.github.io/kimi-eyes
</button>
</div>
<div> <div>
{t("attributionPorted")}{" "} {t("attributionPorted")}{" "}
<button <button
+271
View File
@@ -0,0 +1,271 @@
import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useTranslation } from "../i18n";
import type { TranslationKey } from "../i18n/zh";
import type { ExperimentalEnvStatus, Model } from "../types";
import {
EXPERIMENTAL_FLAGS,
clearSecondaryModel,
forcedEnvValue,
getExperimentalFlags,
getSecondaryModel,
isFlagLockedByEnv,
isMasterEnvOn,
setExperimentalFlag,
setSecondaryModelOnly,
type ExperimentalFlagDef,
} from "../lib/subagent-settings";
import { Card, Toggle } from "./ui/controls";
interface SubagentSettingsPageProps {
/** config.raw_other — hosts the `[experimental]` and `[secondary_model]` sections. */
rawOther: unknown;
/** All configured models (alias → entry) for the secondary-model picker. */
models: Record<string, Model>;
onChange: (nextRawOther: unknown) => void;
onBack: () => void;
}
const EFFORTS = ["low", "medium", "high", "max"] 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 }> = {
"secondary-model": { name: "flagSecondaryModel", desc: "flagSecondaryModelDesc" },
"tool-select": { name: "flagToolSelect", desc: "flagToolSelectDesc" },
"acp-v2": { name: "flagAcpV2", desc: "flagAcpV2Desc" },
persistence_minidb_readmodel: {
name: "flagMinidbReadmodel",
desc: "flagMinidbReadmodelDesc",
},
};
export function SubagentSettingsPage({
rawOther,
models,
onChange,
onBack,
}: SubagentSettingsPageProps) {
const { t } = useTranslation();
const [env, setEnv] = useState<ExperimentalEnvStatus | null>(null);
useEffect(() => {
invoke<ExperimentalEnvStatus>("get_experimental_env_status")
.then(setEnv)
.catch(() => setEnv({}));
}, []);
const flags = getExperimentalFlags(rawOther);
const secondary = getSecondaryModel(rawOther);
const masterOn = isMasterEnvOn(env);
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);
const aliasList = Object.keys(models).sort();
// Inherited settings shown for the picked model: subagents bind the pointed
// entry directly (no patch fields), so context size and effort come from the
// model entry / global [thinking], not from a `[secondary_model]` patch.
const selectedEntry = secondary.model ? models[secondary.model] : undefined;
const inheritedContext = selectedEntry?.max_context_size;
const modelEffort =
selectedEntry &&
typeof (selectedEntry.raw_other as Record<string, unknown> | undefined)
?.default_effort === "string"
? ((selectedEntry.raw_other as Record<string, unknown>).default_effort as string)
: undefined;
const thinkingEffort = (() => {
const ro = rawOther as Record<string, unknown> | undefined;
const thinking = ro?.thinking as Record<string, unknown> | undefined;
return thinking && typeof thinking.effort === "string" && thinking.effort
? thinking.effort
: undefined;
})();
const inheritedEffort = modelEffort ?? thinkingEffort;
const fmtContext = (n: number): string =>
n >= 1_000_000
? `${(n / 1_000_000).toFixed(1)}M`
: n >= 1000
? `${Math.round(n / 1000)}K`
: String(n);
const effortLabel = (e: string): string => {
const key = EFFORT_LABELS[e as (typeof EFFORTS)[number]];
return key ? t(key) : e;
};
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;
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="text-xs text-content-muted">
{t(labels.desc)}
<code className="ml-2">{flag.envVar}</code>
</div>
</div>
{locked && (
<span className="text-xs text-content-muted shrink-0">
🔒 {t("lockedByEnv")}
</span>
)}
<Toggle
checked={on}
disabled={locked}
ariaLabel={t(labels.name)}
onChange={(checked) =>
onChange(setExperimentalFlag(rawOther, flag.id, checked))
}
/>
</div>
);
};
return (
<div className="h-full flex flex-col bg-app">
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-border bg-panel">
<button
type="button"
onClick={onBack}
className="p-2 rounded-lg hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
← {t("back")}
</button>
<h2 className="font-semibold text-lg">{t("subagentSettings")}</h2>
</div>
<div className="flex-1 overflow-auto p-6 space-y-4">
{masterOn && (
<div className="bg-green-100 dark:bg-green-900/20 border border-green-300 dark:border-green-500/30 rounded-lg px-4 py-2 text-sm text-green-700 dark:text-green-400">
{t("masterEnvOnHint")}
</div>
)}
{/* Kimi Code WebUI quick open */}
<Card title={t("webuiSection")}>
<p className="text-xs text-content-muted">{t("webuiDesc")}</p>
<button
type="button"
onClick={async () => {
try {
await invoke("open_kimi_web");
} catch (err) {
alert(err instanceof Error ? err.message : String(err));
}
}}
className="mt-3 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"
>
{t("openWebUI")}
</button>
</Card>
{/* Subagent: secondary model picker + inherited settings */}
<Card title={t("secondaryModelSection")}>
<label className="flex items-center gap-3 text-sm text-content-primary">
<span className="text-content-muted">{t("secondaryModelLabel")}</span>
<select
className="flex-1 max-w-md bg-input border border-border rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={secondary.model ?? ""}
onChange={(e) => {
const alias = e.target.value;
if (alias === "") {
onChange(clearSecondaryModel(rawOther));
} else {
// Only the model reference is persisted — no patch fields.
// Subagents bind the pointed entry directly and inherit its
// settings; usage records stay keyed on the real alias
// instead of Kimi Code's synthesized `__secondary__` entry.
onChange(setSecondaryModelOnly(rawOther, alias));
}
}}
>
<option value="">{t("secondaryModelUnset")}</option>
{aliasList.map((alias) => (
<option key={alias} value={alias}>
{models[alias]?.display_name
? `${alias}(${models[alias].display_name})`
: alias}
</option>
))}
</select>
</label>
<div className="border-t border-border" />
<div className="flex items-center gap-3">
<div className="flex-1 min-w-0">
<div className="text-xs text-content-muted">
{t(FLAG_LABELS[secondaryFlag.id].desc)}
<code className="ml-2">{secondaryFlag.envVar}</code>
</div>
</div>
{secondaryLocked && (
<span className="text-xs text-content-muted shrink-0">
🔒 {t("lockedByEnv")}
</span>
)}
<Toggle
checked={secondaryEnabled}
disabled={secondaryLocked}
ariaLabel={t(FLAG_LABELS[secondaryFlag.id].name)}
onChange={(checked) =>
onChange(setExperimentalFlag(rawOther, secondaryFlag.id, checked))
}
/>
</div>
{secondary.model !== undefined && (
<div className="rounded-lg border border-border bg-input/40 px-3 py-2.5 space-y-2">
<div className="flex items-baseline gap-2 text-sm">
<span className="shrink-0 text-content-muted">
{t("secondaryModelInheritedContext")}
</span>
<span className="font-medium text-content-primary">
{inheritedContext ? fmtContext(inheritedContext) : "—"}
</span>
</div>
<div className="flex items-baseline gap-2 text-sm">
<span className="shrink-0 text-content-muted">
{t("secondaryModelInheritedEffort")}
</span>
<span className="font-medium text-content-primary">
{inheritedEffort ? effortLabel(inheritedEffort) : "—"}
</span>
</div>
<p className="text-xs text-content-muted">
{t("secondaryModelInheritedHint")}
</p>
</div>
)}
<p className="text-xs text-content-muted">{t("secondaryModelPriorityHint")}</p>
</Card>
{/* Other experimental features (not subagent-related) */}
<Card title={t("otherExperimentalFlags")}>
{otherFlags.map(renderFlagRow)}
<p className="text-xs text-content-muted">{t("experimentalFlagsHint")}</p>
</Card>
</div>
</div>
);
}
+4
View File
@@ -59,6 +59,10 @@ export function DailyBars({ daily, dimension, names, unknownProviderLabel }: Dai
if (dimension === "provider") { if (dimension === "provider") {
return key === "unknown" ? unknownProviderLabel : key; return key === "unknown" ? unknownProviderLabel : key;
} }
// Subagent records (Kimi Code `__secondary__` marker) show their own label.
if (key === "__secondary__") {
return t("subagentModel");
}
return shortModel(key); return shortModel(key);
}; };
@@ -25,6 +25,10 @@ export function DailyDetailModal({ day, dimension, colorMap, unknownProviderLabe
const { t } = useTranslation(); const { t } = useTranslation();
const { money } = useCurrency(); const { money } = useCurrency();
/** Subagent records (Kimi Code `__secondary__` marker) show their own label. */
const modelName = (model: string) =>
model === "__secondary__" ? t("subagentModel") : shortModel(model);
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose(); if (e.key === "Escape") onClose();
@@ -143,7 +147,7 @@ export function DailyDetailModal({ day, dimension, colorMap, unknownProviderLabe
<div className="mt-1 ml-5 space-y-0.5"> <div className="mt-1 ml-5 space-y-0.5">
{p.models.map((m) => ( {p.models.map((m) => (
<div key={m.model} className="flex items-baseline justify-between gap-2 text-xs"> <div key={m.model} className="flex items-baseline justify-between gap-2 text-xs">
<span className="truncate text-content-muted" title={m.model}>{shortModel(m.model)}</span> <span className="truncate text-content-muted" title={m.model}>{modelName(m.model)}</span>
<span className="shrink-0 text-content-muted tabular-nums">{fmtTokens(m.tokens)}</span> <span className="shrink-0 text-content-muted tabular-nums">{fmtTokens(m.tokens)}</span>
</div> </div>
))} ))}
@@ -167,7 +171,7 @@ export function DailyDetailModal({ day, dimension, colorMap, unknownProviderLabe
<span className="inline-block h-2.5 w-2.5 rounded-sm shrink-0" style={{ backgroundColor: modelColor(i) }} /> <span className="inline-block h-2.5 w-2.5 rounded-sm shrink-0" style={{ backgroundColor: modelColor(i) }} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2"> <div className="flex items-baseline justify-between gap-2">
<span className="truncate text-content-primary" title={s.model}>{shortModel(s.model)}</span> <span className="truncate text-content-primary" title={s.model}>{modelName(s.model)}</span>
<span className="shrink-0 text-xs text-content-muted tabular-nums"> <span className="shrink-0 text-xs text-content-muted tabular-nums">
{fmtTokens(s.tokens)} <span className="text-content-muted">· {s.pct.toFixed(1)}%</span> {fmtTokens(s.tokens)} <span className="text-content-muted">· {s.pct.toFixed(1)}%</span>
</span> </span>
+10 -2
View File
@@ -310,7 +310,9 @@ export function DashboardPage() {
}} }}
/> />
<div className="flex items-center gap-1.5 min-w-0"> <div className="flex items-center gap-1.5 min-w-0">
<span className="text-content-primary whitespace-nowrap truncate">{m.modelDisplay || m.model}</span> <span className="text-content-primary whitespace-nowrap truncate">
{m.isSecondary ? t("subagentModel") : m.modelDisplay || m.model}
</span>
{m.costEstimated && ( {m.costEstimated && (
<span className="text-[10px] text-yellow-600 shrink-0">({t("estimate")})</span> <span className="text-[10px] text-yellow-600 shrink-0">({t("estimate")})</span>
)} )}
@@ -371,7 +373,13 @@ export function DashboardPage() {
<td className="py-1.5 pr-4 text-content-muted whitespace-nowrap"> <td className="py-1.5 pr-4 text-content-muted whitespace-nowrap">
{fmtTime(r.time)} {fmtTime(r.time)}
</td> </td>
<td className="py-1.5 pr-4 text-content-primary">{r.modelDisplay || r.model}</td> <td className="py-1.5 pr-4 text-content-primary">
<span className="flex items-center gap-1.5 min-w-0">
<span className="truncate">
{r.isSecondary ? t("subagentModel") : r.modelDisplay || r.model}
</span>
</span>
</td>
<td className="py-1.5 pr-4 text-right text-content-muted">{fmtTokens(r.inputOther)}</td> <td className="py-1.5 pr-4 text-right text-content-muted">{fmtTokens(r.inputOther)}</td>
<td className="py-1.5 pr-4 text-right text-content-muted">{fmtTokens(r.output)}</td> <td className="py-1.5 pr-4 text-right text-content-muted">{fmtTokens(r.output)}</td>
<td className="py-1.5 pr-4 text-right text-content-muted">{fmtTokens(r.inputCacheRead)}</td> <td className="py-1.5 pr-4 text-right text-content-muted">{fmtTokens(r.inputCacheRead)}</td>
+137
View File
@@ -0,0 +1,137 @@
import type { ReactNode } from "react";
/** Shared small form controls used by settings panels (Agent settings,
* Subagent settings, ...). Extracted from AgentSettingsPanel.tsx so new
* panels can reuse the same look without duplicating code. */
export function Card({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="bg-panel border border-border rounded-xl p-4 space-y-3">
<h4 className="text-content-muted text-sm font-medium">{title}</h4>
{children}
</div>
);
}
export function Checkbox({
label,
checked,
disabled,
onChange,
}: {
label: string;
checked: boolean;
disabled?: boolean;
onChange: (checked: boolean) => void;
}) {
return (
<label
className={`flex items-center gap-2 text-sm ${
disabled ? "text-content-muted" : "text-content-primary"
}`}
>
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
className="rounded border-border bg-input text-blue-600 focus:ring-blue-500"
/>
{label}
</label>
);
}
/** Pill-style toggle switch with a sliding knob. */
export function Toggle({
checked,
disabled,
onChange,
ariaLabel,
}: {
checked: boolean;
disabled?: boolean;
onChange: (checked: boolean) => void;
ariaLabel?: string;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={ariaLabel}
disabled={disabled}
onClick={() => onChange(!checked)}
className={`relative h-5 w-9 shrink-0 rounded-full transition-colors duration-200 focus:ring-2 focus:ring-blue-500 focus:outline-none ${
checked ? "bg-blue-600" : "bg-border"
} ${disabled ? "cursor-not-allowed opacity-50" : ""}`}
>
<span
className={`absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200 ${
checked ? "translate-x-4" : "translate-x-0"
}`}
/>
</button>
);
}
export function NumberField({
label,
value,
onChange,
}: {
label: string;
value?: number;
onChange: (value: number | undefined) => void;
}) {
return (
<label className="flex items-center gap-3 text-sm text-content-primary">
<span className="text-content-muted">{label}</span>
<input
type="number"
value={value ?? ""}
onChange={(e) => {
const raw = e.target.value;
onChange(raw === "" ? undefined : Number(raw));
}}
className="w-32 bg-input border border-border rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</label>
);
}
export function Segmented({
options,
value,
onChange,
disabled,
}: {
options: { key: string; label: string }[];
value: string;
onChange: (value: string) => void;
disabled?: boolean;
}) {
return (
<div
className={`flex items-center rounded overflow-hidden border border-border ${
disabled ? "opacity-50" : ""
}`}
>
{options.map((opt) => (
<button
key={opt.key}
type="button"
disabled={disabled}
onClick={() => onChange(opt.key)}
className={`px-3 py-1 text-sm ${
value === opt.key
? "bg-blue-600 text-white"
: "bg-input text-content-muted hover:bg-hover-2"
}`}
>
{opt.label}
</button>
))}
</div>
);
}
+39 -1
View File
@@ -136,7 +136,7 @@ export const enTranslations: Record<TranslationKey, string> = {
thinkingMax: "Max", thinkingMax: "Max",
thinkingContextHint: "Thinking uses more context. Ensure the model context length and reserved size are sufficient.", thinkingContextHint: "Thinking uses more context. Ensure the model context length and reserved size are sufficient.",
loopControlSettings: "Loop Control", loopControlSettings: "Loop Control",
maxRetriesPerStep: "Max retries per step", maxAttemptsPerStep: "Max attempts per step",
reservedContextSize: "Reserved context size", reservedContextSize: "Reserved context size",
backgroundSettings: "Background Tasks", backgroundSettings: "Background Tasks",
maxRunningTasks: "Max running tasks", maxRunningTasks: "Max running tasks",
@@ -263,6 +263,7 @@ export const enTranslations: Record<TranslationKey, string> = {
lastChecked: "Last checked", lastChecked: "Last checked",
appDescription: "Windows desktop LLM provider config manager", appDescription: "Windows desktop LLM provider config manager",
settingsWebsite: "Official website", settingsWebsite: "Official website",
kimiEyesSite: "Kimi Eyes plugin site",
referenceProject: "Reference project", referenceProject: "Reference project",
// Validation errors // Validation errors
@@ -319,6 +320,7 @@ export const enTranslations: Record<TranslationKey, string> = {
colCacheRead: "Cache read", colCacheRead: "Cache read",
unitTimes: "requests", unitTimes: "requests",
estimate: "est.", estimate: "est.",
subagentModel: "Subagent model",
collapse: "Collapse", collapse: "Collapse",
expandAll: "Expand all ({n})", expandAll: "Expand all ({n})",
@@ -446,4 +448,40 @@ export const enTranslations: Record<TranslationKey, string> = {
trendLineShowRequests: "Show requests", trendLineShowRequests: "Show requests",
trendLineTooltip: "{date}\nTokens: {tokens}\nCache hit: {cache}\nRequests: {req}", trendLineTooltip: "{date}\nTokens: {tokens}\nCache hit: {cache}\nRequests: {req}",
trendLineNoData: "No data", trendLineNoData: "No data",
// Advanced settings page (WebUI quick open + experimental flags + secondary model)
subagentSettings: "Advanced Settings",
experimentalFlags: "Experimental Features",
otherExperimentalFlags: "Other Experimental Features",
experimentalFlagsHint:
"Toggles are written to the [experimental] section of config.toml; env vars take precedence and locked toggles cannot be changed here",
flagSecondaryModel: "Subagent Secondary Model",
flagSecondaryModelDesc: "Newly spawned subagents bind a second (usually cheaper) model config",
flagToolSelect: "Tool Select",
flagToolSelectDesc: "Enable the experimental tool selection feature",
flagAcpV2: "ACP v2 Protocol",
flagAcpV2Desc: "Enable Agent Communication Protocol v2",
flagMinidbReadmodel: "MiniDB Read Model",
flagMinidbReadmodelDesc: "Enable the v2 engine MiniDB read-model backend",
lockedByEnv: "Locked by env var",
masterEnvOnHint: "KIMI_CODE_EXPERIMENTAL_FLAG is set: all experimental features are forced on by the env var",
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",
secondaryModelLabel: "Secondary model",
secondaryModelUnset: "Not configured",
secondaryModelInheritedContext: "Inherited context",
secondaryModelInheritedEffort: "Inherited effort",
secondaryModelInheritedHint:
"Subagents bind the selected model directly and inherit its context size and effort — no patch needed",
secondaryModelHint:
"Second model config bound to subagents by default; when unset, subagents inherit the primary model. Only the model reference is stored; subagents inherit all of that model's settings",
secondaryModelPriorityHint:
"Priority: explicit tool model param > profile model_preference > this config > inherit primary model",
// Kimi Code WebUI quick open
webuiSection: "Kimi Code WebUI",
webuiDesc:
"Launch `kimi web` to open the new Web UI in your browser (built into kimi-code 0.33+). Requires the kimi CLI on PATH.",
openWebUI: "Open WebUI",
}; };
+39 -1
View File
@@ -134,7 +134,7 @@ export const zhTranslations = {
thinkingMax: "最大", thinkingMax: "最大",
thinkingContextHint: "启用思考会占用更多上下文,请确保模型上下文长度和预留空间足够。", thinkingContextHint: "启用思考会占用更多上下文,请确保模型上下文长度和预留空间足够。",
loopControlSettings: "循环控制", loopControlSettings: "循环控制",
maxRetriesPerStep: "单步重试次数", maxAttemptsPerStep: "单步最大尝试次数",
reservedContextSize: "上下文预留大小", reservedContextSize: "上下文预留大小",
backgroundSettings: "后台任务", backgroundSettings: "后台任务",
maxRunningTasks: "最大并发数", maxRunningTasks: "最大并发数",
@@ -258,6 +258,7 @@ export const zhTranslations = {
lastChecked: "上次检查", lastChecked: "上次检查",
appDescription: "Windows 桌面端 LLM 供应商配置管理器", appDescription: "Windows 桌面端 LLM 供应商配置管理器",
settingsWebsite: "官方网站", settingsWebsite: "官方网站",
kimiEyesSite: "Kimi Eyes 插件站",
referenceProject: "引用项目", referenceProject: "引用项目",
// Validation errors // Validation errors
@@ -314,6 +315,7 @@ export const zhTranslations = {
colCacheRead: "缓存读", colCacheRead: "缓存读",
unitTimes: "次", unitTimes: "次",
estimate: "估算", estimate: "估算",
subagentModel: "子代理模型",
collapse: "收起", collapse: "收起",
expandAll: "展开全部 ({n})", expandAll: "展开全部 ({n})",
@@ -441,6 +443,42 @@ export const zhTranslations = {
trendLineShowRequests: "显示请求数", trendLineShowRequests: "显示请求数",
trendLineTooltip: "{date}\nToken: {tokens}\n命中率: {cache}\n请求: {req}", trendLineTooltip: "{date}\nToken: {tokens}\n命中率: {cache}\n请求: {req}",
trendLineNoData: "暂无数据", trendLineNoData: "暂无数据",
// Advanced settings page (WebUI quick open + experimental flags + secondary model)
subagentSettings: "高级设置",
experimentalFlags: "实验功能开关",
otherExperimentalFlags: "其他实验功能",
experimentalFlagsHint:
"开关写入 config.toml 的 [experimental] 节;环境变量优先级更高,被锁定的开关无法在此修改",
flagSecondaryModel: "子代理次主力模型",
flagSecondaryModelDesc: "子代理派发时绑定第二份(通常更便宜的)模型配置",
flagToolSelect: "工具选择",
flagToolSelectDesc: "启用实验性工具选择功能",
flagAcpV2: "ACP v2 协议",
flagAcpV2Desc: "启用 Agent Communication Protocol v2",
flagMinidbReadmodel: "MiniDB 读模型",
flagMinidbReadmodelDesc: "启用 v2 引擎 MiniDB 读模型后端",
lockedByEnv: "被环境变量锁定",
masterEnvOnHint: "KIMI_CODE_EXPERIMENTAL_FLAG 已设置:全部实验功能被环境变量强制开启",
secondaryModelSection: "子代理模型(次主力模型)",
secondaryModelDisabledHint:
"实验开关「子代理次主力模型」未启用,本配置不会生效——请先在上方打开对应开关",
secondaryModelLabel: "次主力模型",
secondaryModelUnset: "未配置",
secondaryModelInheritedContext: "继承上下文长度",
secondaryModelInheritedEffort: "继承思考模式",
secondaryModelInheritedHint:
"子代理直接绑定所选模型,继承其上下文长度与思考设置,无需单独配置补丁",
secondaryModelHint:
"子代理默认绑定的第二份模型配置;未配置时子代理继承主模型;仅保存模型引用,子代理继承该模型的全部设置",
secondaryModelPriorityHint:
"优先级:工具显式 model 参数 > 子代理 profile 的 model_preference > 本配置 > 继承主模型",
// Kimi Code WebUI quick open
webuiSection: "Kimi Code WebUI",
webuiDesc:
"启动 kimi web,在浏览器中打开新版 Web 界面(kimi-code 0.33+ 内置 code-app)。需要 kimi 命令在 PATH 中。",
openWebUI: "打开 WebUI",
} as const; } as const;
export type TranslationKey = keyof typeof zhTranslations; export type TranslationKey = keyof typeof zhTranslations;
+19 -5
View File
@@ -7,7 +7,7 @@ const DEFAULT_SETTINGS: AgentSettings = {
keep: "all", keep: "all",
}, },
loop_control: { loop_control: {
max_retries_per_step: 3, max_attempts_per_step: 3,
reserved_context_size: 50000, reserved_context_size: 50000,
}, },
background: { background: {
@@ -32,15 +32,29 @@ function getSection<T>(rawOther: unknown, key: string): T | undefined {
} }
export function getAgentSettings(rawOther: unknown): AgentSettings { export function getAgentSettings(rawOther: unknown): AgentSettings {
const sectionLoop = getSection<AgentSettings["loop_control"]>(
rawOther,
"loop_control"
);
const loop: AgentSettings["loop_control"] = {
...DEFAULT_SETTINGS.loop_control,
...sectionLoop,
};
// kimi-code 0.33+ (v2 engine) renamed max_retries_per_step →
// max_attempts_per_step; migrate legacy values so they still show and edit.
if (
sectionLoop &&
sectionLoop.max_attempts_per_step === undefined &&
sectionLoop.max_retries_per_step !== undefined
) {
loop.max_attempts_per_step = sectionLoop.max_retries_per_step;
}
return { return {
thinking: { thinking: {
...DEFAULT_SETTINGS.thinking, ...DEFAULT_SETTINGS.thinking,
...getSection<AgentSettings["thinking"]>(rawOther, "thinking"), ...getSection<AgentSettings["thinking"]>(rawOther, "thinking"),
}, },
loop_control: { loop_control: loop,
...DEFAULT_SETTINGS.loop_control,
...getSection<AgentSettings["loop_control"]>(rawOther, "loop_control"),
},
background: { background: {
...DEFAULT_SETTINGS.background, ...DEFAULT_SETTINGS.background,
...getSection<AgentSettings["background"]>(rawOther, "background"), ...getSection<AgentSettings["background"]>(rawOther, "background"),
+9422 -5082
View File
File diff suppressed because it is too large. Load diff
+7863 -4020
View File
File diff suppressed because it is too large. Load diff
+155
View File
@@ -0,0 +1,155 @@
import type { ExperimentalEnvStatus, SecondaryModelConfig } from "../types";
/**
* Helpers for the `[experimental]` and `[secondary_model]` sections of the
* Kimi Code config.toml. Both live in `config.raw_other` (the pass-through
* bucket for unknown top-level TOML sections), so these helpers read/write
* that JSON object directly.
*/
function asRecord(value: unknown): Record<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return {};
}
function isTruthyEnv(value: string | undefined): boolean {
if (!value) return false;
return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
}
// ---------------------------------------------------------------------------
// [experimental] — experimental feature flags
// ---------------------------------------------------------------------------
export interface ExperimentalFlagDef {
/** Flag id as stored in config.toml `[experimental]` (e.g. "secondary-model"). */
id: string;
/** Single-feature env var that can force the flag (locks the UI toggle). */
envVar: string;
}
/** Known experimental flags (Kimi Code v1 + v2 registries, merged). */
export const EXPERIMENTAL_FLAGS: ExperimentalFlagDef[] = [
{ id: "secondary-model", envVar: "KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL" },
{ id: "tool-select", envVar: "KIMI_CODE_EXPERIMENTAL_TOOL_SELECT" },
{ id: "acp-v2", envVar: "KIMI_CODE_EXPERIMENTAL_ACP_V2" },
{
id: "persistence_minidb_readmodel",
envVar: "KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL",
},
];
export const EXPERIMENTAL_MASTER_ENV = "KIMI_CODE_EXPERIMENTAL_FLAG";
/** Whether the master env var is set to a truthy value (locks every flag on). */
export function isMasterEnvOn(env: ExperimentalEnvStatus | null): boolean {
return isTruthyEnv(env?.[EXPERIMENTAL_MASTER_ENV]);
}
/** Whether a flag's value is overridden by its env var (set & non-empty),
* locking the config toggle. Kimi Code lets the env var force the flag on
* *or* off, so a set env var locks regardless of its truthiness. */
export function isFlagLockedByEnv(
env: ExperimentalEnvStatus | null,
flag: ExperimentalFlagDef
): boolean {
return env !== null && flag.envVar in env;
}
/** The value a locked flag is forced to by its env var. */
export function forcedEnvValue(
env: ExperimentalEnvStatus | null,
flag: ExperimentalFlagDef
): boolean {
return isTruthyEnv(env?.[flag.envVar]);
}
/** Read `[experimental]` from raw_other: `{ flagId: boolean }`. */
export function getExperimentalFlags(rawOther: unknown): Record<string, boolean> {
const section = asRecord(rawOther)["experimental"];
const flags = asRecord(section);
const out: Record<string, boolean> = {};
for (const [k, v] of Object.entries(flags)) {
out[k] = v === true;
}
return out;
}
/** Set one flag in `[experimental]`. Removing all flags drops the section. */
export function setExperimentalFlag(
rawOther: unknown,
id: string,
enabled: boolean
): unknown {
const root = { ...asRecord(rawOther) };
const flags = { ...getExperimentalFlags(rawOther) };
if (enabled) {
flags[id] = true;
} else {
delete flags[id];
}
if (Object.keys(flags).length > 0) {
root.experimental = flags;
} else {
delete root.experimental;
}
return root;
}
// ---------------------------------------------------------------------------
// [secondary_model] — subagent secondary model
// ---------------------------------------------------------------------------
/** Read `[secondary_model]` from raw_other; `{}` when absent. */
export function getSecondaryModel(rawOther: unknown): SecondaryModelConfig {
const section = asRecord(rawOther)["secondary_model"];
const cfg = asRecord(section);
const out: SecondaryModelConfig = {};
if (typeof cfg.model === "string" && cfg.model !== "") out.model = cfg.model;
if (typeof cfg.default_effort === "string" && cfg.default_effort !== "")
out.default_effort = cfg.default_effort;
if (typeof cfg.max_output_size === "number" && cfg.max_output_size > 0)
out.max_output_size = cfg.max_output_size;
if (typeof cfg.max_context_size === "number" && cfg.max_context_size > 0)
out.max_context_size = cfg.max_context_size;
return out;
}
/**
* Write `[secondary_model]` with only the `model` field — no patch fields.
* Subagents then bind the pointed model entry directly and inherit its
* settings (context size, capabilities, effort fallback), which also keeps
* usage records keyed on the real model alias instead of the synthesized
* `__secondary__` derived entry. Unknown fields already present in the
* section are preserved; the known patch fields (`default_effort`,
* `max_output_size`, `max_context_size`) are dropped so stale patches are
* cleaned up when the model is re-picked.
*/
export function setSecondaryModelOnly(
rawOther: unknown,
alias: string
): unknown {
const root = { ...asRecord(rawOther) };
const section = asRecord(asRecord(rawOther)["secondary_model"]);
const next: Record<string, unknown> = { model: alias };
const knownFields: ReadonlySet<string> = new Set([
"model",
"default_effort",
"max_output_size",
"max_context_size",
]);
for (const [k, v] of Object.entries(section)) {
if (!knownFields.has(k)) next[k] = v;
}
root.secondary_model = next;
return root;
}
/** Remove `[secondary_model]` entirely (the "not configured" option). */
export function clearSecondaryModel(rawOther: unknown): unknown {
const root = { ...asRecord(rawOther) };
delete root.secondary_model;
return root;
}
+4
View File
@@ -24,6 +24,8 @@ export interface ModelRow extends TotalsRow {
modelResolved: string; modelResolved: string;
priceId: string; priceId: string;
costEstimated: boolean; costEstimated: boolean;
/** True when any record in this row came from a subagent (secondary-model) request. */
isSecondary: boolean;
} }
export interface RecentRow { export interface RecentRow {
@@ -40,6 +42,8 @@ export interface RecentRow {
costEstimated: boolean; costEstimated: boolean;
priceId: string; priceId: string;
fromEnv: boolean; fromEnv: boolean;
/** True when this request came from a subagent (secondary-model) call. */
isSecondary: boolean;
} }
export interface RangeStats { export interface RangeStats {
+21
View File
@@ -97,6 +97,11 @@ export interface ThinkingConfig {
} }
export interface LoopControlConfig { export interface LoopControlConfig {
/** v2 engine key (kimi-code 0.33+): `max_retries_per_step` was renamed. */
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. */
max_retries_per_step?: number; max_retries_per_step?: number;
reserved_context_size?: number; reserved_context_size?: number;
} }
@@ -127,3 +132,19 @@ export interface AgentSettings {
permission?: { rules?: PermissionRule[] }; permission?: { rules?: PermissionRule[] };
hooks?: Hook[]; hooks?: Hook[];
} }
/**
* Kimi Code `[secondary_model]` section of config.toml: the secondary model
* bound to newly spawned subagents (experimental feature).
* `model` points at an alias defined in `[models]`; the remaining fields are
* a patch applied on top of the referenced entry (subagents only).
*/
export interface SecondaryModelConfig {
model?: string;
default_effort?: string;
max_output_size?: number;
max_context_size?: number;
}
/** Values of experimental env vars that are currently set (non-empty). */
export type ExperimentalEnvStatus = Record<string, string>;
+10
View File
@@ -9,6 +9,7 @@
"version": "0.6.7", "version": "0.6.7",
"dependencies": { "dependencies": {
"@fontsource/fira-code": "^5.3.0", "@fontsource/fira-code": "^5.3.0",
"@fontsource/geist": "^5.3.0",
"@fontsource/inter": "^5.3.0", "@fontsource/inter": "^5.3.0",
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
"react": "^18.3.1", "react": "^18.3.1",
@@ -722,6 +723,15 @@
"url": "https://github.com/sponsors/ayuhito" "url": "https://github.com/sponsors/ayuhito"
} }
}, },
"node_modules/@fontsource/geist": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/geist/-/geist-5.3.0.tgz",
"integrity": "sha512-u03ftlfVwb05uijq2jGDJ03/HKGqMKaNFk7KOhkpBUex8+BhrAOfMs/XciJ2UUNUX9fEsZbZQld/RWMA2oFU7g==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/inter": { "node_modules/@fontsource/inter": {
"version": "5.3.0", "version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.3.0.tgz", "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.3.0.tgz",
+1
View File
@@ -10,6 +10,7 @@
}, },
"dependencies": { "dependencies": {
"@fontsource/fira-code": "^5.3.0", "@fontsource/fira-code": "^5.3.0",
"@fontsource/geist": "^5.3.0",
"@fontsource/inter": "^5.3.0", "@fontsource/inter": "^5.3.0",
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
"react": "^18.3.1", "react": "^18.3.1",
+6 -5
View File
@@ -19,7 +19,7 @@ export default function Footer() {
<div className="container-page space-y-8"> <div className="container-page space-y-8">
{/* 推荐链接 */} {/* 推荐链接 */}
<div> <div>
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wider text-muted-foreground"> <h3 className="mb-4 text-sm font-semibold text-muted-foreground">
{t.footer.recommend} {t.footer.recommend}
</h3> </h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
@@ -32,9 +32,10 @@ export default function Footer() {
href={link.url} href={link.url}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="group flex items-start gap-3 rounded-xl border border-border bg-card p-4 transition-all hover:-translate-y-0.5 hover:border-primary/50" 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"
style={{ boxShadow: "var(--shadow-soft)" }}
> >
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border bg-white"> <div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-border bg-white">
{logo ? ( {logo ? (
<img <img
src={asset(logo)} src={asset(logo)}
@@ -48,11 +49,11 @@ export default function Footer() {
)} )}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="font-semibold group-hover:text-primary"> <div className="font-semibold tracking-tight group-hover:text-primary">
{link.name} {link.name}
</div> </div>
{text && ( {text && (
<p className="mt-1 line-clamp-3 text-xs text-muted-foreground"> <p className="mt-1 line-clamp-3 text-xs leading-relaxed text-muted-foreground">
{text} {text}
</p> </p>
)} )}
+1 -1
View File
@@ -11,7 +11,7 @@ export default function Nav() {
{ to: "/download", label: t.nav.download }, { to: "/download", label: t.nav.download },
]; ];
return ( return (
<header className="sticky top-0 z-40 border-b border-border bg-background/80 backdrop-blur"> <header className="sticky top-0 z-40 border-b border-border bg-background/70 backdrop-blur-md">
<div className="container-page flex h-14 items-center justify-between gap-4"> <div className="container-page flex h-14 items-center justify-between gap-4">
<NavLink <NavLink
to="/" to="/"
+2 -2
View File
@@ -25,12 +25,12 @@ export default function SectionShell({ id, title, subtitle, children }: SectionS
<div className="mb-12 max-w-3xl"> <div className="mb-12 max-w-3xl">
<h2 <h2
id={`${id}-title`} id={`${id}-title`}
className="text-3xl font-bold tracking-tight md:text-4xl" className="text-3xl font-bold tracking-[-0.03em] md:text-4xl"
> >
{title} {title}
</h2> </h2>
{subtitle && ( {subtitle && (
<p className="mt-3 text-base text-muted-foreground">{subtitle}</p> <p className="mt-3 text-base leading-relaxed text-muted-foreground">{subtitle}</p>
)} )}
</div> </div>
{children} {children}
+61 -10
View File
@@ -16,10 +16,10 @@ const zh = {
performance: "性能", performance: "性能",
changelog: "更新日志", changelog: "更新日志",
download: "下载", download: "下载",
downloadBtn: "下载 v0.7.0", downloadBtn: "下载 v0.7.2",
}, },
hero: { hero: {
badge: "v0.7.0 · 开源 MIT · Windows / macOS / Linux", badge: "v0.7.2 · 开源 MIT · Windows / macOS / Linux",
titleBefore: "统一管理你的", titleBefore: "统一管理你的",
titleAccent: "AI 供应商", titleAccent: "AI 供应商",
titleAfter: "", titleAfter: "",
@@ -140,6 +140,26 @@ const zh = {
syncedNote: "数据已同步 GitHub Releases", syncedNote: "数据已同步 GitHub Releases",
fallbackNote: "内置版本记录", fallbackNote: "内置版本记录",
entries: [ entries: [
{
version: "v0.7.2",
date: "2026-08-07",
items: [
"子代理模型指定(实验功能):设置中为子代理选择次主力模型,子代理不再默认继承主模型",
"实验开关改为滑动开关,修复开启态因样式缺失而不可见的问题",
"models.dev 模型列表与价格数据同步更新",
"官网企业版视觉升级;出品公司标识统一为 codingplan.site",
"macOS 安装优化:Release 附带 install-macos.sh 一键脚本,自动清除下载隔离,免去手动右键打开",
],
},
{
version: "v0.7.1",
date: "2026-08-05",
items: [
"实验功能设置新增「子代理模型(次主力模型)」配置:选择后子代理绑定该模型,不再继承主模型",
"仪表盘用量统计识别子代理请求(__secondary__)独立展示,并缓存次主力模型定价",
"自定义网关模型按官方同名模型跨 provider 匹配价格,不再落入兜底估算",
],
},
{ {
version: "v0.7.0", version: "v0.7.0",
date: "2026-08-02", date: "2026-08-02",
@@ -152,6 +172,7 @@ const zh = {
"kimi usages 响应异常时透出原始返回,便于对照接口排查", "kimi usages 响应异常时透出原始返回,便于对照接口排查",
"models.dev 价格快照同步", "models.dev 价格快照同步",
"关于页新增官方网站链接", "关于页新增官方网站链接",
"设置-关于页新增 Kimi Eyes 识图插件站链接",
], ],
}, },
{ {
@@ -163,6 +184,15 @@ const zh = {
"仪表盘性能再优化:价格解析记忆化缓存,扫描从秒级降到百毫秒级;右上角新增加载耗时/数据量跟踪", "仪表盘性能再优化:价格解析记忆化缓存,扫描从秒级降到百毫秒级;右上角新增加载耗时/数据量跟踪",
], ],
}, },
{
version: "v0.6.7",
date: "2026-08-01",
items: [
"应用内置 Kimi 授权登录(Device Code Flow,等同 kimi login):浏览器授权即用,无需命令行",
"授权后凭据自动写入,15 分钟过期自动续期,用量查询直接可用",
"修复外链打开问题:授权页 / 设置页超链接统一走 Rust 侧 opener 命令(更可靠)",
],
},
{ {
version: "v0.6.8", version: "v0.6.8",
date: "2026-08-01", date: "2026-08-01",
@@ -259,7 +289,7 @@ const zh = {
}, },
download: { download: {
title: "下载 Kimi Switch", title: "下载 Kimi Switch",
subtitle: "当前版本 v0.7.0 · Windows / macOS / Linux 三平台已发布", subtitle: "当前版本 v0.7.2 · Windows / macOS / Linux 三平台已发布",
autoUpdate: "应用内置自动检测更新,新版本发布后在设置页一键升级。", autoUpdate: "应用内置自动检测更新,新版本发布后在设置页一键升级。",
ready: "已发布", ready: "已发布",
wip: "开发中", wip: "开发中",
@@ -267,7 +297,7 @@ const zh = {
mirrorBtn: "镜像下载", mirrorBtn: "镜像下载",
items: [ items: [
{ id: "windows", name: "Windows", ready: true, note: ".msi 安装包,系统托盘常驻" }, { id: "windows", name: "Windows", ready: true, note: ".msi 安装包,系统托盘常驻" },
{ id: "macos", name: "macOS", ready: true, note: ".dmg 安装包(Apple Silicon),未签名需右键打开" }, { id: "macos", name: "macOS", ready: true, note: ".dmg 安装包(Apple Silicon),未签名:下载 install-macos.sh 后运行 bash install-macos.sh 一键安装" },
{ id: "linux", name: "Linux", ready: true, note: ".deb / .AppImage / .rpm 三格式" }, { id: "linux", name: "Linux", ready: true, note: ".deb / .AppImage / .rpm 三格式" },
], ],
}, },
@@ -309,7 +339,7 @@ const zh = {
"智谱 GLM": "智谱 GLM":
"通过我的邀请链接注册 BigModel.cn,即可获得 2000 万 Tokens 大礼包,畅享新一代旗舰模型 GLM-5.2", "通过我的邀请链接注册 BigModel.cn,即可获得 2000 万 Tokens 大礼包,畅享新一代旗舰模型 GLM-5.2",
DeepSeek: "", DeepSeek: "",
OpenCodeGo: "", OpenCodeGo: "好友订阅后,您可获得 $5,对方也可获得 $5。",
MiniMax: MiniMax:
"MiniMax Token Plan:订阅套餐解锁最新模型,好友订阅享 9 折 + Builder 权益,邀请人得 10% 返利", "MiniMax Token Plan:订阅套餐解锁最新模型,好友订阅享 9 折 + Builder 权益,邀请人得 10% 返利",
基元律动: "", 基元律动: "",
@@ -327,10 +357,10 @@ const en: Dict = {
performance: "Performance", performance: "Performance",
changelog: "Changelog", changelog: "Changelog",
download: "Download", download: "Download",
downloadBtn: "Download v0.7.0", downloadBtn: "Download v0.7.2",
}, },
hero: { hero: {
badge: "v0.7.0 · Open source MIT · Windows / macOS / Linux", badge: "v0.7.2 · Open source MIT · Windows / macOS / Linux",
titleBefore: "One app for all your ", titleBefore: "One app for all your ",
titleAccent: "AI providers", titleAccent: "AI providers",
titleAfter: "", titleAfter: "",
@@ -451,6 +481,26 @@ const en: Dict = {
syncedNote: "Synced from GitHub Releases", syncedNote: "Synced from GitHub Releases",
fallbackNote: "Built-in release notes", fallbackNote: "Built-in release notes",
entries: [ entries: [
{
version: "v0.7.2",
date: "2026-08-07",
items: [
"Subagent model assignment (experimental): pick a secondary model for subagents so they no longer inherit the main model by default",
"Experiment toggle converted to a sliding switch, fixing the invisible enabled state",
"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",
],
},
{
version: "v0.7.1",
date: "2026-08-05",
items: [
"Experimental settings gain a secondary-model picker for subagents, decoupling them from the main model",
"Dashboard usage stats identify subagent requests (__secondary__) and show them separately with cached secondary-model pricing",
"Custom gateway models match prices against the same-named official model across providers",
],
},
{ {
version: "v0.7.0", version: "v0.7.0",
date: "2026-08-02", date: "2026-08-02",
@@ -463,6 +513,7 @@ const en: Dict = {
"kimi usages failure now exposes the raw response for easier debugging", "kimi usages failure now exposes the raw response for easier debugging",
"models.dev price snapshot sync", "models.dev price snapshot sync",
"About page gains an official website link", "About page gains an official website link",
"Settings-About page gains a Kimi Eyes plugin site link",
], ],
}, },
{ {
@@ -578,7 +629,7 @@ const en: Dict = {
}, },
download: { download: {
title: "Download Kimi Switch", title: "Download Kimi Switch",
subtitle: "Current version v0.7.0 · Windows, macOS and Linux now released", subtitle: "Current version v0.7.2 · Windows, macOS and Linux now released",
autoUpdate: "Built-in update detection: upgrade in one click from Settings when a new version ships.", autoUpdate: "Built-in update detection: upgrade in one click from Settings when a new version ships.",
ready: "Available", ready: "Available",
wip: "In development", wip: "In development",
@@ -586,7 +637,7 @@ const en: Dict = {
mirrorBtn: "China mirror", mirrorBtn: "China mirror",
items: [ items: [
{ id: "windows", name: "Windows", ready: true, note: ".msi installer, lives in the system tray" }, { id: "windows", name: "Windows", ready: true, note: ".msi installer, lives in the system tray" },
{ id: "macos", name: "macOS", ready: true, note: ".dmg installer (Apple Silicon), unsigned - right-click to open" }, { 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: "linux", name: "Linux", ready: true, note: ".deb / .AppImage / .rpm" }, { id: "linux", name: "Linux", ready: true, note: ".deb / .AppImage / .rpm" },
], ],
}, },
@@ -628,7 +679,7 @@ const en: Dict = {
"智谱 GLM": "智谱 GLM":
"Register on BigModel.cn via my invite link and get a 20M token gift pack for the new GLM-5.2 flagship", "Register on BigModel.cn via my invite link and get a 20M token gift pack for the new GLM-5.2 flagship",
DeepSeek: "", DeepSeek: "",
OpenCodeGo: "", OpenCodeGo: "When a friend subscribes, you get $5 and they get $5.",
MiniMax: MiniMax:
"MiniMax Token Plan: subscribe to unlock the latest models. Friends get 10% off, inviters get 10% rebate", "MiniMax Token Plan: subscribe to unlock the latest models. Friends get 10% off, inviters get 10% rebate",
基元律动: "", 基元律动: "",
+53 -15
View File
@@ -1,8 +1,8 @@
@import "@fontsource/inter/400.css"; @import "@fontsource/geist/400.css";
@import "@fontsource/inter/500.css"; @import "@fontsource/geist/500.css";
@import "@fontsource/inter/600.css"; @import "@fontsource/geist/600.css";
@import "@fontsource/inter/700.css"; @import "@fontsource/geist/700.css";
@import "@fontsource/inter/800.css"; @import "@fontsource/geist/800.css";
@import "@fontsource/fira-code/400.css"; @import "@fontsource/fira-code/400.css";
@import "@fontsource/fira-code/500.css"; @import "@fontsource/fira-code/500.css";
@@ -26,6 +26,11 @@
--primary: 221 83% 53%; --primary: 221 83% 53%;
--primary-foreground: 0 0% 100%; --primary-foreground: 0 0% 100%;
--ring: 221 83% 53%; --ring: 221 83% 53%;
/* 克制分层阴影(色相一致的 tinted shadow,非荧光发光) */
--shadow-soft: 0 1px 2px hsl(240 9% 11% / 0.05),
0 8px 24px hsl(240 9% 11% / 0.06);
--shadow-lift: 0 1px 2px hsl(240 9% 11% / 0.05),
0 12px 36px hsl(240 9% 11% / 0.1);
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@@ -42,6 +47,10 @@
--primary: 217 91% 60%; --primary: 217 91% 60%;
--primary-foreground: 0 0% 100%; --primary-foreground: 0 0% 100%;
--ring: 217 91% 60%; --ring: 217 91% 60%;
--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),
0 12px 36px hsl(222 47% 2% / 0.55);
} }
} }
@@ -59,8 +68,8 @@
z-index: 0; z-index: 0;
pointer-events: none; pointer-events: none;
background-image: background-image:
linear-gradient(to right, hsl(var(--primary) / 0.05) 1px, transparent 1px), linear-gradient(to right, hsl(var(--primary) / 0.04) 1px, transparent 1px),
linear-gradient(to bottom, hsl(var(--primary) / 0.05) 1px, transparent 1px); linear-gradient(to bottom, hsl(var(--primary) / 0.04) 1px, transparent 1px);
background-size: 44px 44px; background-size: 44px 44px;
mask-image: radial-gradient(ellipse 80% 60% at 50% 0%, black 30%, transparent 75%); mask-image: radial-gradient(ellipse 80% 60% at 50% 0%, black 30%, transparent 75%);
} }
@@ -68,6 +77,21 @@
position: relative; position: relative;
z-index: 1; z-index: 1;
} }
/* 选中色与键盘焦点(无障碍) */
::selection {
background: hsl(var(--primary) / 0.16);
}
:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;
}
/* 展示标题:更紧字距 + 防孤词 */
h1,
h2 {
text-wrap: balance;
letter-spacing: -0.02em;
}
} }
@layer components { @layer components {
@@ -75,27 +99,41 @@
@apply mx-auto w-full max-w-6xl px-6; @apply mx-auto w-full max-w-6xl px-6;
} }
.btn { .btn {
@apply inline-flex items-center justify-center gap-2 rounded-xl px-5 py-2.5 text-sm font-medium transition-colors; @apply inline-flex items-center justify-center gap-2 rounded-xl px-5 py-2.5 text-sm font-medium transition-all duration-200;
} }
.btn-primary { .btn-primary {
@apply btn bg-primary text-primary-foreground hover:opacity-90; @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 0 20px hsl(var(--primary) / 0.35); 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);
} }
.btn-ghost { .btn-ghost {
@apply btn border border-border text-foreground hover:bg-accent; @apply btn border border-border bg-card/60 text-foreground hover:border-foreground/25 hover:bg-accent active:scale-[0.98];
} }
.card { .card {
@apply rounded-xl border border-border bg-card p-6 transition-shadow; @apply rounded-2xl border border-border bg-card p-6;
box-shadow: var(--shadow-soft);
transition:
box-shadow 0.3s ease,
transform 0.3s ease,
border-color 0.3s ease;
} }
.card:hover { .card:hover {
box-shadow: 0 0 28px hsl(var(--primary) / 0.12); box-shadow: var(--shadow-lift);
} }
/* 截图容器:限定最大宽高,避免撑满页面 */ /* 截图容器:限定最大宽高,避免撑满页面 */
.screenshot { .screenshot {
@apply overflow-hidden rounded-xl border border-border bg-muted; @apply overflow-hidden rounded-2xl border border-border bg-muted;
box-shadow: 0 0 36px hsl(var(--primary) / 0.1); box-shadow: var(--shadow-lift);
} }
.screenshot > img { .screenshot > img {
@apply block h-auto w-full; @apply block h-auto w-full;
} }
/* 图标容器:统一的蓝色徽标容器 */
.icon-chip {
@apply flex h-10 w-10 items-center justify-center rounded-xl border border-primary/20 bg-primary/10 text-primary;
}
} }
+9 -5
View File
@@ -10,20 +10,24 @@ export default function CTA() {
className="py-24" className="py-24"
> >
<div className="container-page"> <div className="container-page">
<div className="relative overflow-hidden rounded-xl border border-primary/25 bg-gradient-to-r from-primary/10 via-primary/5 to-violet-500/10 p-12 text-center md:p-16"> <div className="card relative overflow-hidden p-12 text-center md:p-16">
{/* 科技风光晕 */} {/* 克制同色系光晕 + 顶部强调线(去紫,保持页面调性) */}
<div <div
aria-hidden="true" aria-hidden="true"
className="pointer-events-none absolute -top-24 left-1/2 h-64 w-[480px] -translate-x-1/2 rounded-full bg-primary/15 blur-3xl" 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"
/>
<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"> <div className="relative">
<h2 <h2
id="cta-title" id="cta-title"
className="text-3xl font-bold tracking-tight md:text-5xl" className="text-3xl font-bold tracking-[-0.03em] md:text-5xl"
> >
{t.cta.title} {t.cta.title}
</h2> </h2>
<p className="mt-4 text-base text-muted-foreground md:text-lg"> <p className="mx-auto mt-4 max-w-2xl text-base text-muted-foreground md:text-lg">
{t.cta.subtitle} {t.cta.subtitle}
</p> </p>
<div className="mt-8 flex flex-wrap justify-center gap-3"> <div className="mt-8 flex flex-wrap justify-center gap-3">
+37 -10
View File
@@ -18,12 +18,38 @@ function cacheKey(embedded: ChangelogEntry[]) {
return `kimi-switch-changelog-${embedded[0]?.version ?? "v1"}`; return `kimi-switch-changelog-${embedded[0]?.version ?? "v1"}`;
} }
/** Parse a GitHub release body into bullet items. */ /** Compare semantic versions like "v0.7.1" for descending order. */
function compareVersionsDesc(a: string, b: string): number {
const parse = (v: string) =>
v.replace(/^v/, "").split(".").map((n) => Number(n) || 0);
const pa = parse(a);
const pb = parse(b);
for (let i = 0; i < 3; i++) {
const da = pa[i] ?? 0;
const db = pb[i] ?? 0;
if (da !== db) return db - da;
}
return 0;
}
/** Parse a GitHub release body into bullet items, stripping markdown syntax
* and the release-template "Downloads by platform" section (changelog entries
* are about changes, not installers). */
function parseBody(body: string): string[] { function parseBody(body: string): string[] {
return body const items: string[] = [];
.split("\n") for (const raw of body.split("\n")) {
.map((l) => l.replace(/^\s*[-*]\s+/, "").trim()) const line = raw.replace(/^\s*[-*]\s+/, "").trim();
.filter((l) => l.length > 0 && l !== "---"); if (line.length === 0 || line === "---") continue;
// Drop the release template's download section and everything after it.
if (/downloads?\s+by\s+platform/i.test(line)) break;
const cleaned = line
.replace(/^#{1,6}\s+/, "") // markdown heading markers
.replace(/\*\*(.+?)\*\*/g, "$1") // bold
.replace(/`([^`]+)`/g, "$1") // inline code
.trim();
if (cleaned.length > 0) items.push(cleaned);
}
return items;
} }
async function fetchReleases(): Promise<ChangelogEntry[]> { async function fetchReleases(): Promise<ChangelogEntry[]> {
@@ -94,12 +120,13 @@ export default function Changelog() {
}, []); }, []);
// 远端拉到的版本优先显示(含未翻译的新版本),内置列表补齐缺失版本。 // 远端拉到的版本优先显示(含未翻译的新版本),内置列表补齐缺失版本。
// 合并后按版本倒序排序,避免未翻译版本被无脑塞到最前导致时间轴顺序错乱。
const entries: ChangelogEntry[] = [ const entries: ChangelogEntry[] = [
...(remote ?? []).filter( ...(remote ?? []).filter(
(r) => !embedded.some((e) => e.version === r.version), (r) => !embedded.some((e) => e.version === r.version),
), ),
...embedded, ...embedded,
]; ].sort((a, b) => compareVersionsDesc(a.version, b.version));
return ( return (
<SectionShell <SectionShell
@@ -107,16 +134,16 @@ export default function Changelog() {
title={t.changelog.title} title={t.changelog.title}
subtitle={t.changelog.subtitle} subtitle={t.changelog.subtitle}
> >
<ol className="relative space-y-8 border-l border-border pl-8"> <ol className="relative space-y-10 border-l border-border pl-7">
{entries.map((e) => ( {entries.map((e) => (
<li key={e.version} className="relative"> <li key={e.version} className="relative">
<span className="absolute -left-[37px] top-1.5 h-2.5 w-2.5 rounded-full bg-primary" /> <span className="absolute -left-[34px] top-[5px] h-3 w-3 rounded-full border-2 border-background bg-primary ring-4 ring-primary/10" />
<div className="flex flex-wrap items-baseline gap-3"> <div className="flex flex-wrap items-baseline gap-3">
<h3 className="font-mono text-lg font-semibold">{e.version}</h3> <h3 className="font-mono text-lg font-semibold tracking-tight">{e.version}</h3>
<time className="text-xs text-muted-foreground">{e.date}</time> <time className="text-xs text-muted-foreground">{e.date}</time>
</div> </div>
{e.items.length > 0 && ( {e.items.length > 0 && (
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm text-muted-foreground"> <ul className="mt-2 list-disc space-y-1 pl-5 text-sm leading-relaxed text-muted-foreground">
{e.items.map((item) => ( {e.items.map((item) => (
<li key={item}>{item}</li> <li key={item}>{item}</li>
))} ))}
+12 -10
View File
@@ -8,25 +8,27 @@ const icons: Record<string, Icon> = {
linux: LinuxLogo, linux: LinuxLogo,
}; };
const VERSION = "0.7.0"; const VERSION = "0.7.2";
const GITHUB = "https://github.com/billowliu2/KimiSwitch"; const GITHUB = "https://github.com/billowliu2/KimiSwitch";
const MIRROR_RELEASES = "https://git.codingplan.site/admin/KimiCodeSwitch/releases"; const MIRROR_RELEASES = "https://git.codingplan.site/admin/KimiCodeSwitch/releases";
const dl = (file: string) => `${GITHUB}/releases/download/v${VERSION}/${file}`; const dl = (file: string) => `${GITHUB}/releases/download/v${VERSION}/${file}`;
/** Per-platform download assets for the current release (names match the CI /** Per-platform download assets for the current release (names match the CI
* release workflow, e.g. Kimi.Switch_0.7.0_x64_en-US.msi). */ * release workflow. NOTE: tauri-bundler keeps the productName verbatim, so the
* real asset is "Kimi Switch_0.7.2_…" (space), URL-encoded as %20. */
const assets: Record<string, { label: string; href: string }[]> = { const assets: Record<string, { label: string; href: string }[]> = {
windows: [ windows: [
{ label: "MSI", href: dl("Kimi.Switch_0.7.0_x64_en-US.msi") }, { label: "MSI", href: dl("Kimi%20Switch_0.7.2_x64_en-US.msi") },
{ label: "镜像", href: MIRROR_RELEASES }, { label: "镜像", href: MIRROR_RELEASES },
], ],
macos: [ macos: [
{ label: "Apple Silicon (.dmg)", href: dl("Kimi.Switch_0.7.0_aarch64.dmg") }, { label: "Apple Silicon (.dmg)", href: dl("Kimi%20Switch_0.7.2_aarch64.dmg") },
{ label: "安装脚本", href: dl("install-macos.sh") },
], ],
linux: [ linux: [
{ label: ".deb", href: dl("Kimi.Switch_0.7.0_amd64.deb") }, { label: ".deb", href: dl("Kimi%20Switch_0.7.2_amd64.deb") },
{ label: ".AppImage", href: dl("Kimi.Switch_0.7.0_amd64.AppImage") }, { label: ".AppImage", href: dl("Kimi%20Switch_0.7.2_amd64.AppImage") },
{ label: ".rpm", href: dl("Kimi.Switch-0.7.0-1.x86_64.rpm") }, { label: ".rpm", href: dl("Kimi%20Switch-0.7.2-1.x86_64.rpm") },
], ],
}; };
@@ -45,14 +47,14 @@ export default function Download() {
return ( return (
<div key={p.id} className="card flex flex-col"> <div key={p.id} className="card flex flex-col">
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<div className="flex h-10 w-10 items-center justify-center rounded-xl border border-primary/25 bg-primary/10 text-primary"> <div className="icon-chip">
<Icon className="h-5 w-5" weight="duotone" /> <Icon className="h-5 w-5" weight="duotone" />
</div> </div>
<span className="rounded-full border border-primary/25 bg-primary/10 px-2.5 py-0.5 text-xs font-medium text-primary"> <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} {t.download.ready}
</span> </span>
</div> </div>
<h3 className="mb-2 text-lg font-semibold">{p.name}</h3> <h3 className="mb-2 text-lg font-semibold tracking-tight">{p.name}</h3>
<p className="text-sm text-muted-foreground">{p.note}</p> <p className="text-sm text-muted-foreground">{p.note}</p>
<div className="mt-4 flex flex-wrap gap-2"> <div className="mt-4 flex flex-wrap gap-2">
{links.map((l) => ( {links.map((l) => (
+2 -2
View File
@@ -59,10 +59,10 @@ export default function Features() {
/> />
</div> </div>
<div className="p-6"> <div className="p-6">
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-xl border border-primary/25 bg-primary/10 text-primary"> <div className="icon-chip mb-3">
<Icon className="h-5 w-5" weight="duotone" /> <Icon className="h-5 w-5" weight="duotone" />
</div> </div>
<h3 className="mb-2 text-lg font-semibold">{f.title}</h3> <h3 className="mb-2 text-lg font-semibold tracking-tight">{f.title}</h3>
<p className="text-sm leading-relaxed text-muted-foreground"> <p className="text-sm leading-relaxed text-muted-foreground">
{f.desc} {f.desc}
</p> </p>
+6 -6
View File
@@ -23,29 +23,29 @@ export default function Hero() {
aria-labelledby="hero-title" aria-labelledby="hero-title"
className="relative overflow-hidden pt-16 pb-20 md:pt-24" className="relative overflow-hidden pt-16 pb-20 md:pt-24"
> >
{/* 科技风光晕 */} {/* 克制的同色系光晕(去紫,统一蓝色 + 中性) */}
<div <div
aria-hidden="true" aria-hidden="true"
className="pointer-events-none absolute -top-32 right-[-10%] h-[420px] w-[420px] rounded-full bg-primary/15 blur-3xl" className="pointer-events-none absolute -top-32 right-[-10%] h-[420px] w-[420px] rounded-full bg-primary/10 blur-3xl"
/> />
<div <div
aria-hidden="true" aria-hidden="true"
className="pointer-events-none absolute -left-40 top-1/2 h-[320px] w-[320px] rounded-full bg-violet-500/10 blur-3xl" className="pointer-events-none absolute -left-40 top-1/2 h-[320px] w-[320px] rounded-full bg-primary/5 blur-3xl"
/> />
<div className="container-page relative grid grid-cols-1 gap-10 md:grid-cols-2 md:items-center"> <div className="container-page relative grid grid-cols-1 gap-10 md:grid-cols-2 md:items-center">
<div> <div>
<div {...reveal(0)}> <div {...reveal(0)}>
<span className="inline-block rounded-full border border-primary/25 bg-primary/10 px-3 py-1 text-xs font-medium text-primary"> <span className="inline-block rounded-full border border-primary/25 bg-primary/10 px-3 py-1 text-xs font-medium tracking-wide text-primary">
{t.hero.badge} {t.hero.badge}
</span> </span>
</div> </div>
<h1 <h1
id="hero-title" id="hero-title"
{...reveal(100, "mt-4 text-4xl font-bold tracking-tight md:text-5xl lg:text-6xl")} {...reveal(100, "mt-5 text-4xl font-bold leading-[1.05] tracking-[-0.03em] md:text-5xl lg:text-6xl")}
> >
{t.hero.titleBefore} {t.hero.titleBefore}
<br /> <br />
<span className="whitespace-nowrap bg-gradient-to-r from-blue-500 to-violet-500 bg-clip-text text-transparent"> <span className="whitespace-nowrap text-primary">
{t.hero.titleAccent} {t.hero.titleAccent}
</span> </span>
{t.hero.titleAfter} {t.hero.titleAfter}
+1 -1
View File
@@ -65,7 +65,7 @@ function Counter({
const v = useCountUp(target, inView); const v = useCountUp(target, inView);
return ( return (
<div ref={ref} className="card text-center"> <div ref={ref} className="card text-center">
<div className="font-mono text-4xl font-bold tracking-tight tabular-nums"> <div className="font-mono text-4xl font-bold tracking-[-0.03em] tabular-nums">
{v.toFixed(decimals)} {v.toFixed(decimals)}
{suffix} {suffix}
</div> </div>
+2 -2
View File
@@ -21,10 +21,10 @@ export default function Privacy() {
const Icon = icons[it.id] ?? Database; const Icon = icons[it.id] ?? Database;
return ( return (
<div key={it.id} className="card"> <div key={it.id} className="card">
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-xl border border-primary/25 bg-primary/10 text-primary"> <div className="icon-chip mb-3">
<Icon className="h-5 w-5" weight="duotone" /> <Icon className="h-5 w-5" weight="duotone" />
</div> </div>
<h3 className="mb-2 text-lg font-semibold">{it.title}</h3> <h3 className="mb-2 text-lg font-semibold tracking-tight">{it.title}</h3>
<p className="text-sm leading-relaxed text-muted-foreground"> <p className="text-sm leading-relaxed text-muted-foreground">
{it.desc} {it.desc}
</p> </p>
+1 -1
View File
@@ -27,7 +27,7 @@ export default {
}, },
fontFamily: { fontFamily: {
sans: [ sans: [
"Inter", "Geist",
"-apple-system", "-apple-system",
"BlinkMacSystemFont", "BlinkMacSystemFont",
'"Segoe UI"', '"Segoe UI"',