feat(models-dev): 高级设置新增 models.dev 在线同步 — 上下文/能力/单价即时生效 + 一键恢复内置;修复 public 快照构建漂移;确认适配 kimi-code 2.1.0 — bump to v0.7.21
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s
This commit is contained in:
1 parent
26e783c2ba
commit
11286419b1
17 files changed
+282609
-267908
No files matched your search
@@ -6,6 +6,13 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## v0.7.21 (2026-09-24)
|
||||||
|
|
||||||
|
- **models.dev 在线同步**:高级设置新增「模型参考数据」卡片——一键在线拉取 models.dev 最新快照(上下文长度 / 能力标记 / 单价),前端与仪表盘计价即时生效,无需等待新版本;支持一键恢复随版本内置数据;显示当前数据来源与版本(同步 / 内置 + 日期 + 模型/供应商数)
|
||||||
|
- **修复构建链路数据漂移**:`fetch-models-dev.mjs` 现在同时写入 `src/lib/models-dev.json`(Rust 计价内嵌)与 `public/models-dev.json`(前端静态资源)——此前 public 副本需手工拷贝,已两次滞后于 src/lib
|
||||||
|
- **kimi-code 2.1.0 已适配确认**:实验 flag 注册表(5 个)、config 模式、用量/quota 格式均无变化;上游 `[watch]` 默认关闭与现有开关行为一致;新增 `tui_mode`(tui.toml)不涉及配置管理面
|
||||||
|
- models-dev 快照同步至 2026-09-23(8,126 模型 / 223 供应商)
|
||||||
|
|
||||||
## v0.7.20 (2026-09-20)
|
## v0.7.20 (2026-09-20)
|
||||||
|
|
||||||
- **套餐用量阈值预警**:用量配置新增「预警阈值 %」(0-100,0/空为关闭),任一用量窗口达到阈值即红色高亮 + ⚠ 标记,低于阈值自动消除
|
- **套餐用量阈值预警**:用量配置新增「预警阈值 %」(0-100,0/空为关闭),任一用量窗口达到阈值即红色高亮 + ⚠ 标记,低于阈值自动消除
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# KimiSwitch v0.7.21
|
||||||
|
|
||||||
|
## 新增
|
||||||
|
|
||||||
|
- **models.dev 在线同步**:高级设置新增「模型参考数据(models.dev)」卡片——一键在线拉取 models.dev 最新快照,新模型发布后无需等待 KimiSwitch 发版:
|
||||||
|
- 覆盖三类参考数据:模型上下文长度、能力标记(思考 / 工具 / 图像 / 视频)、单价($/M tokens)
|
||||||
|
- 同步后立即生效:供应商编辑页的参数自动填充、仪表盘用量计价(Rust 侧价格索引按快照文件 mtime 自动重建,无需重启应用)
|
||||||
|
- 数据落在 `~/.kimi-switch/models-dev.json`,打包内置快照始终作为兜底;无效副本自动回退
|
||||||
|
- 支持一键「恢复内置数据」,随时可重新同步
|
||||||
|
- 状态行显示当前数据来源(在线同步 / 随版本内置)、快照日期与模型 / 供应商数量
|
||||||
|
- 代理环境自动适配:依次读取 `HTTPS_PROXY` / `HTTP_PROXY` 环境变量与 git `http.proxy` 配置
|
||||||
|
|
||||||
|
## 修复
|
||||||
|
|
||||||
|
- **构建链路数据漂移**:`fetch-models-dev.mjs` 此前只写 `src/lib/models-dev.json`(Rust 计价内嵌),前端实际加载的 `public/models-dev.json` 需手工拷贝,历史上已两次滞后。现在两个文件由脚本同步写入,彻底消除漂移
|
||||||
|
|
||||||
|
## 适配
|
||||||
|
|
||||||
|
- **kimi-code 2.1.0 已适配确认**:实验 flag 注册表(5 个)、config 模式、用量 / quota 格式均无变化;上游 `[watch]` 默认关闭与现有开关行为一致;新增 `tui_mode`(`~/.kimi-code/tui.toml`)不涉及配置管理面,暂无适配需求
|
||||||
|
|
||||||
|
## 数据
|
||||||
|
|
||||||
|
- models-dev 快照同步至 2026-09-23(8,126 模型 / 223 供应商)
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "kimiswitch",
|
"name": "kimiswitch",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.7.20",
|
"version": "0.7.21",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+88829
-82134
File diff suppressed because it is too large.
Load diff
@@ -1,9 +1,17 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* Fetch the latest model reference data from models.dev and write:
|
* Fetch the latest model reference data from models.dev and write:
|
||||||
* 1. src/lib/models-dev.json — compact per-model snapshot (frontend)
|
* 1. src/lib/models-dev.json — compact per-model snapshot; embedded
|
||||||
* 2. src/lib/models-dev-full.json — provider-grouped full list incl. pricing
|
* into the Rust binary by dashboard.rs
|
||||||
* 3. src/lib/models-dev.last-good.json — local backup of the last success
|
* (include_str!) and loaded by the
|
||||||
|
* frontend as fallback
|
||||||
|
* 2. public/models-dev.json — the same snapshot as a static asset;
|
||||||
|
* this is what the frontend actually
|
||||||
|
* serves at runtime (must stay in sync
|
||||||
|
* with #1 — they serve different halves
|
||||||
|
* of the app)
|
||||||
|
* 3. src/lib/models-dev-full.json — provider-grouped full list incl. pricing
|
||||||
|
* 4. src/lib/models-dev.last-good.json — local backup of the last success
|
||||||
*
|
*
|
||||||
* The data source is https://models.dev/api.json (176 providers; each model
|
* The data source is https://models.dev/api.json (176 providers; each model
|
||||||
* carries `cost` = { input, output, cache_read?, cache_write? } in $/M tokens).
|
* carries `cost` = { input, output, cache_read?, cache_write? } in $/M tokens).
|
||||||
@@ -63,6 +71,8 @@ if (proxy && process.env.NODE_USE_ENV_PROXY !== "1") {
|
|||||||
const SOURCE_URL = "https://models.dev/api.json";
|
const SOURCE_URL = "https://models.dev/api.json";
|
||||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
const SNAPSHOT = join(ROOT, "src", "lib", "models-dev.json");
|
const SNAPSHOT = join(ROOT, "src", "lib", "models-dev.json");
|
||||||
|
// Static-asset copy served to the frontend (see header — must equal SNAPSHOT).
|
||||||
|
const SNAPSHOT_PUBLIC = join(ROOT, "public", "models-dev.json");
|
||||||
const FULL = join(ROOT, "src", "lib", "models-dev-full.json");
|
const FULL = join(ROOT, "src", "lib", "models-dev-full.json");
|
||||||
// Local backup of the last successful snapshot. Not committed to git (see
|
// Local backup of the last successful snapshot. Not committed to git (see
|
||||||
// .gitignore); the committed models-dev.json itself is the versioned fallback.
|
// .gitignore); the committed models-dev.json itself is the versioned fallback.
|
||||||
@@ -168,12 +178,15 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await mkdir(dirname(SNAPSHOT), { recursive: true });
|
await mkdir(dirname(SNAPSHOT), { recursive: true });
|
||||||
|
await mkdir(dirname(SNAPSHOT_PUBLIC), { recursive: true });
|
||||||
const json = JSON.stringify(snapshot, null, 2) + "\n";
|
const json = JSON.stringify(snapshot, null, 2) + "\n";
|
||||||
await writeFile(SNAPSHOT, json, "utf8");
|
await writeFile(SNAPSHOT, json, "utf8");
|
||||||
|
await writeFile(SNAPSHOT_PUBLIC, json, "utf8");
|
||||||
await writeFile(FULL, JSON.stringify(full, null, 2) + "\n", "utf8");
|
await writeFile(FULL, JSON.stringify(full, null, 2) + "\n", "utf8");
|
||||||
await writeFile(BACKUP, json, "utf8");
|
await writeFile(BACKUP, json, "utf8");
|
||||||
|
|
||||||
console.log(`models.dev snapshot: ${modelCount} models -> ${SNAPSHOT}`);
|
console.log(`models.dev snapshot: ${modelCount} models -> ${SNAPSHOT}`);
|
||||||
|
console.log(`models.dev static asset: -> ${SNAPSHOT_PUBLIC}`);
|
||||||
console.log(
|
console.log(
|
||||||
`models.dev full list: ${Object.keys(full.providers).length} providers -> ${FULL}`,
|
`models.dev full list: ${Object.keys(full.providers).length} providers -> ${FULL}`,
|
||||||
);
|
);
|
||||||
|
|||||||
Generated
+1
-1
@@ -1978,7 +1978,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kimiswitch"
|
name = "kimiswitch"
|
||||||
version = "0.7.20"
|
version = "0.7.21"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "kimiswitch"
|
name = "kimiswitch"
|
||||||
version = "0.7.20"
|
version = "0.7.21"
|
||||||
description = "Kimi Switch - model config manager"
|
description = "Kimi Switch - model config manager"
|
||||||
authors = ["codingplan.site"]
|
authors = ["codingplan.site"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
+38
-23
@@ -6,7 +6,8 @@ use std::collections::HashMap;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{BufRead, BufReader};
|
use std::io::{BufRead, BufReader};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Arc, Mutex, OnceLock, RwLock};
|
||||||
|
use std::time::SystemTime;
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -534,21 +535,30 @@ struct ModelsDevCost {
|
|||||||
cache_write: Option<f64>,
|
cache_write: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compiled-in models.dev snapshot (`src/lib/models-dev.json`, generated by
|
/// models.dev price index over the effective snapshot (runtime-synced copy
|
||||||
/// scripts/fetch-models-dev.mjs before every build via the `pretauri` hook).
|
/// when present, else the compiled-in `src/lib/models-dev.json` generated by
|
||||||
/// Keyed by "<provider>/<model>", lowercased.
|
/// scripts/fetch-models-dev.mjs). Keyed by "<provider>/<model>", lowercased.
|
||||||
const MODELS_DEV_SNAPSHOT: &str = include_str!("../../src/lib/models-dev.json");
|
/// Cached and rebuilt only when the synced file's mtime changes, so an online
|
||||||
|
/// sync (models_dev::sync_models_dev) refreshes prices without a restart.
|
||||||
fn models_dev_cost_index() -> &'static HashMap<String, ModelsDevCost> {
|
fn models_dev_cost_index() -> Arc<HashMap<String, ModelsDevCost>> {
|
||||||
static INDEX: OnceLock<HashMap<String, ModelsDevCost>> = OnceLock::new();
|
static CACHE: OnceLock<RwLock<Option<(Option<SystemTime>, Arc<HashMap<String, ModelsDevCost>>)>>>=
|
||||||
INDEX.get_or_init(|| {
|
OnceLock::new();
|
||||||
|
let cache = CACHE.get_or_init(|| RwLock::new(None));
|
||||||
|
let stamp = fs::metadata(crate::models_dev::synced_path())
|
||||||
|
.and_then(|m| m.modified())
|
||||||
|
.ok();
|
||||||
|
{
|
||||||
|
let cached = cache.read().expect("price index lock");
|
||||||
|
if let Some((cached_stamp, idx)) = cached.as_ref() {
|
||||||
|
if *cached_stamp == stamp {
|
||||||
|
return idx.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let mut map = HashMap::new();
|
let mut map = HashMap::new();
|
||||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(MODELS_DEV_SNAPSHOT) else {
|
let v = serde_json::from_str::<serde_json::Value>(&crate::models_dev::effective_snapshot());
|
||||||
return map;
|
if let Ok(v) = v {
|
||||||
};
|
if let Some(obj) = v.as_object() {
|
||||||
let Some(obj) = v.as_object() else {
|
|
||||||
return map;
|
|
||||||
};
|
|
||||||
for (key, entry) in obj {
|
for (key, entry) in obj {
|
||||||
// Skip the "last_updated" metadata key and entries without cost.
|
// Skip the "last_updated" metadata key and entries without cost.
|
||||||
if !entry.is_object() || entry.get("cost").is_none() {
|
if !entry.is_object() || entry.get("cost").is_none() {
|
||||||
@@ -571,13 +581,16 @@ fn models_dev_cost_index() -> &'static HashMap<String, ModelsDevCost> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
map
|
}
|
||||||
})
|
}
|
||||||
|
let idx = Arc::new(map);
|
||||||
|
*cache.write().expect("price index lock") = Some((stamp, idx.clone()));
|
||||||
|
idx
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Warm the compiled-in models.dev price index off the first dashboard open:
|
/// Warm the models.dev price index off the first dashboard open: parsing the
|
||||||
/// parsing the ~1.3 MB snapshot costs tens of milliseconds, so build it on a
|
/// ~1.3 MB snapshot costs tens of milliseconds, so build it on a background
|
||||||
/// background thread at startup instead of lazily on the first get_summary.
|
/// thread at startup instead of lazily on the first get_summary.
|
||||||
pub fn warm_price_index() {
|
pub fn warm_price_index() {
|
||||||
std::thread::spawn(|| {
|
std::thread::spawn(|| {
|
||||||
let _ = models_dev_cost_index();
|
let _ = models_dev_cost_index();
|
||||||
@@ -608,14 +621,15 @@ fn provider_rank(key: &str) -> usize {
|
|||||||
/// (deterministic tie-break). Returns None when nothing matches.
|
/// (deterministic tie-break). Returns None when nothing matches.
|
||||||
fn models_dev_lookup(model_name: &str) -> Option<(String, ModelsDevCost)> {
|
fn models_dev_lookup(model_name: &str) -> Option<(String, ModelsDevCost)> {
|
||||||
let lower = model_name.to_ascii_lowercase();
|
let lower = model_name.to_ascii_lowercase();
|
||||||
if let Some(cost) = models_dev_cost_index().get(&lower) {
|
let index = models_dev_cost_index();
|
||||||
|
if let Some(cost) = index.get(&lower) {
|
||||||
return Some((lower, *cost));
|
return Some((lower, *cost));
|
||||||
}
|
}
|
||||||
let bare = model_name.rsplit_once('/').map(|(_, b)| b).unwrap_or(model_name);
|
let bare = model_name.rsplit_once('/').map(|(_, b)| b).unwrap_or(model_name);
|
||||||
let bare_l = bare.to_ascii_lowercase();
|
let bare_l = bare.to_ascii_lowercase();
|
||||||
// Match on the model part after the last '/', not the full key: aliases
|
// Match on the model part after the last '/', not the full key: aliases
|
||||||
// strip the family prefix (record "k2.5" vs models.dev "kimi-k2.5").
|
// strip the family prefix (record "k2.5" vs models.dev "kimi-k2.5").
|
||||||
let mut matches: Vec<(&String, &ModelsDevCost)> = models_dev_cost_index()
|
let mut matches: Vec<(&String, &ModelsDevCost)> = index
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(key, _)| {
|
.filter(|(key, _)| {
|
||||||
key.rsplit_once('/')
|
key.rsplit_once('/')
|
||||||
@@ -695,7 +709,8 @@ fn match_price(model_name: &str) -> (String, f64, f64, f64, bool) {
|
|||||||
fn priced_suffix_lookup(model_name: &str) -> Option<(String, ModelsDevCost)> {
|
fn priced_suffix_lookup(model_name: &str) -> Option<(String, ModelsDevCost)> {
|
||||||
let bare = model_name.rsplit_once('/').map(|(_, b)| b).unwrap_or(model_name);
|
let bare = model_name.rsplit_once('/').map(|(_, b)| b).unwrap_or(model_name);
|
||||||
let bare_l = bare.to_ascii_lowercase();
|
let bare_l = bare.to_ascii_lowercase();
|
||||||
let mut matches: Vec<(&String, &ModelsDevCost)> = models_dev_cost_index()
|
let index = models_dev_cost_index();
|
||||||
|
let mut matches: Vec<(&String, &ModelsDevCost)> = index
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(key, cost)| {
|
.filter(|(key, cost)| {
|
||||||
(cost.input > 0.0 || cost.output > 0.0)
|
(cost.input > 0.0 || cost.output > 0.0)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ pub mod dashboard;
|
|||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod kimi_code_io;
|
pub mod kimi_code_io;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
|
pub mod models_dev;
|
||||||
pub mod oauth;
|
pub mod oauth;
|
||||||
pub mod pi_io;
|
pub mod pi_io;
|
||||||
pub mod plugins;
|
pub mod plugins;
|
||||||
@@ -119,6 +120,10 @@ pub fn run() {
|
|||||||
commands::kimi_oauth_start,
|
commands::kimi_oauth_start,
|
||||||
commands::kimi_oauth_poll,
|
commands::kimi_oauth_poll,
|
||||||
commands::get_experimental_env_status,
|
commands::get_experimental_env_status,
|
||||||
|
models_dev::sync_models_dev,
|
||||||
|
models_dev::get_models_dev_status,
|
||||||
|
models_dev::get_models_dev_snapshot,
|
||||||
|
models_dev::reset_models_dev,
|
||||||
dashboard::get_paths,
|
dashboard::get_paths,
|
||||||
dashboard::get_prices,
|
dashboard::get_prices,
|
||||||
dashboard::get_summary,
|
dashboard::get_summary,
|
||||||
|
|||||||
@@ -0,0 +1,352 @@
|
|||||||
|
//! models.dev reference-data sync.
|
||||||
|
//!
|
||||||
|
//! The app carries a compiled-in models.dev snapshot (generated by
|
||||||
|
//! scripts/fetch-models-dev.mjs before every build). This module lets the
|
||||||
|
//! user refresh it at runtime from https://models.dev/api.json without
|
||||||
|
//! rebuilding: the synced copy lives at `~/.kimi-switch/models-dev.json` and
|
||||||
|
//! takes precedence whenever it exists and parses. The dashboard price index
|
||||||
|
//! (see dashboard::models_dev_cost_index) re-checks that file's mtime so new
|
||||||
|
//! prices take effect without an app restart.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::db::kimi_switch_data_dir;
|
||||||
|
|
||||||
|
/// Compiled-in snapshot — the same file the dashboard price index used to
|
||||||
|
/// embed directly. Always available; the runtime-synced copy (if any) wins.
|
||||||
|
const BUILTIN_SNAPSHOT: &str = include_str!("../../src/lib/models-dev.json");
|
||||||
|
|
||||||
|
const SOURCE_URL: &str = "https://models.dev/api.json";
|
||||||
|
const SYNCED_FILE: &str = "models-dev.json";
|
||||||
|
|
||||||
|
/// Where the runtime-synced snapshot lives. The dashboard stats this path's
|
||||||
|
/// mtime to decide when to rebuild its price index.
|
||||||
|
pub fn synced_path() -> PathBuf {
|
||||||
|
kimi_switch_data_dir().join(SYNCED_FILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read + validate the synced copy. Anything unparsable or missing
|
||||||
|
/// `last_updated` is treated as absent so the built-in snapshot takes over.
|
||||||
|
fn read_synced() -> Option<String> {
|
||||||
|
let raw = fs::read_to_string(synced_path()).ok()?;
|
||||||
|
let v: Value = serde_json::from_str(&raw).ok()?;
|
||||||
|
if v.get("last_updated").and_then(|x| x.as_str()).is_some() {
|
||||||
|
Some(raw)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The snapshot the app should use right now: the synced copy when present
|
||||||
|
/// and valid, otherwise the compiled-in one.
|
||||||
|
pub fn effective_snapshot() -> String {
|
||||||
|
read_synced().unwrap_or_else(|| BUILTIN_SNAPSHOT.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ModelsDevStatus {
|
||||||
|
/// "synced" = runtime online copy; "builtin" = compiled at build time.
|
||||||
|
pub source: &'static str,
|
||||||
|
pub last_updated: String,
|
||||||
|
pub model_count: u64,
|
||||||
|
pub provider_count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count models / providers and read the `last_updated` stamp from a
|
||||||
|
/// snapshot string ("<provider>/<model>" keys, plus the `last_updated` meta).
|
||||||
|
fn status_of(source: &'static str, raw: &str) -> ModelsDevStatus {
|
||||||
|
let v: Value = serde_json::from_str(raw).unwrap_or(Value::Null);
|
||||||
|
let mut model_count = 0u64;
|
||||||
|
let mut providers = HashSet::new();
|
||||||
|
if let Some(obj) = v.as_object() {
|
||||||
|
for (k, entry) in obj {
|
||||||
|
if k == "last_updated" || !entry.is_object() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
model_count += 1;
|
||||||
|
if let Some((p, _)) = k.split_once('/') {
|
||||||
|
providers.insert(p.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ModelsDevStatus {
|
||||||
|
source,
|
||||||
|
last_updated: v
|
||||||
|
.get("last_updated")
|
||||||
|
.and_then(|x| x.as_str())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string(),
|
||||||
|
model_count,
|
||||||
|
provider_count: providers.len() as u64,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status(source: &'static str) -> ModelsDevStatus {
|
||||||
|
match source {
|
||||||
|
"synced" => {
|
||||||
|
let raw = read_synced().unwrap_or_default();
|
||||||
|
status_of("synced", &raw)
|
||||||
|
}
|
||||||
|
_ => status_of("builtin", BUILTIN_SNAPSHOT),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Numbers serialize as integers when they are whole (matches the mjs
|
||||||
|
/// script's JSON output, e.g. context 1000000 not 1000000.0).
|
||||||
|
fn num(v: f64) -> Value {
|
||||||
|
if v.fract() == 0.0 && v.abs() < 9e15 {
|
||||||
|
Value::from(v as i64)
|
||||||
|
} else {
|
||||||
|
Value::from(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transform models.dev api.json into the compact snapshot shape produced by
|
||||||
|
/// scripts/fetch-models-dev.mjs (the canonical field-picking logic lives
|
||||||
|
/// there; this is the runtime port). Pure — unit-tested below.
|
||||||
|
fn build_snapshot(raw: &Value) -> Result<Value, String> {
|
||||||
|
let providers = raw.as_object().ok_or("api.json is not a JSON object")?;
|
||||||
|
let mut snapshot = serde_json::Map::new();
|
||||||
|
snapshot.insert(
|
||||||
|
"last_updated".into(),
|
||||||
|
Value::String(Utc::now().format("%Y-%m-%d").to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (provider_id, provider) in providers {
|
||||||
|
let Some(models) = provider.get("models").and_then(|m| m.as_object()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for (model_id, m) in models {
|
||||||
|
let mut entry = serde_json::Map::new();
|
||||||
|
if let Some(name) = m.get("name").and_then(|x| x.as_str()) {
|
||||||
|
entry.insert("name".into(), Value::String(name.to_string()));
|
||||||
|
}
|
||||||
|
// Context 0 means "not applicable" (image/audio models) — treat as
|
||||||
|
// missing so callers fall back to defaults.
|
||||||
|
if let Some(ctx) = m
|
||||||
|
.pointer("/limit/context")
|
||||||
|
.and_then(|x| x.as_f64())
|
||||||
|
.filter(|c| *c > 0.0)
|
||||||
|
{
|
||||||
|
entry.insert("context".into(), num(ctx));
|
||||||
|
}
|
||||||
|
for flag in ["reasoning", "tool_call", "structured_output"] {
|
||||||
|
if m.get(flag).and_then(|x| x.as_bool()) == Some(true) {
|
||||||
|
entry.insert(flag.into(), Value::Bool(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(input) = m.pointer("/modalities/input").and_then(|x| x.as_array()) {
|
||||||
|
let has = |s: &str| input.iter().any(|v| v.as_str() == Some(s));
|
||||||
|
if has("image") {
|
||||||
|
entry.insert("image".into(), Value::Bool(true));
|
||||||
|
}
|
||||||
|
if has("video") {
|
||||||
|
entry.insert("video".into(), Value::Bool(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(cost) = m.get("cost").and_then(|c| c.as_object()) {
|
||||||
|
let mut c = serde_json::Map::new();
|
||||||
|
for k in ["input", "output", "cache_read", "cache_write"] {
|
||||||
|
if let Some(n) = cost.get(k).and_then(|x| x.as_f64()) {
|
||||||
|
c.insert(k.into(), num(n));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !c.is_empty() {
|
||||||
|
entry.insert("cost".into(), Value::Object(c));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
snapshot.insert(format!("{provider_id}/{model_id}"), Value::Object(entry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Value::Object(snapshot))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// reqwest is built without the default system-proxy feature, so proxies must
|
||||||
|
/// be attached explicitly. Mirror fetch-models-dev.mjs: env vars first, then
|
||||||
|
/// git's http.proxy.
|
||||||
|
fn proxy_url() -> Option<String> {
|
||||||
|
let env = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"]
|
||||||
|
.iter()
|
||||||
|
.find_map(|k| std::env::var(k).ok().filter(|p| !p.trim().is_empty()));
|
||||||
|
env.or_else(git_http_proxy)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git_http_proxy() -> Option<String> {
|
||||||
|
let out = std::process::Command::new("git")
|
||||||
|
.args(["config", "--get", "http.proxy"])
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||||
|
(!s.is_empty()).then_some(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn http_client() -> Result<reqwest::Client, String> {
|
||||||
|
let mut b = reqwest::Client::builder()
|
||||||
|
.user_agent(concat!("KimiSwitch/", env!("CARGO_PKG_VERSION")))
|
||||||
|
.timeout(Duration::from_secs(60));
|
||||||
|
if let Some(p) = proxy_url() {
|
||||||
|
b = b.proxy(reqwest::Proxy::all(&p).map_err(|e| format!("invalid proxy '{p}': {e}"))?);
|
||||||
|
}
|
||||||
|
b.build().map_err(|e| format!("failed to build HTTP client: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download api.json, transform it and atomically replace the synced copy.
|
||||||
|
/// On any failure the previous snapshot (synced or built-in) stays untouched.
|
||||||
|
async fn sync_from_remote() -> Result<ModelsDevStatus, String> {
|
||||||
|
let client = http_client()?;
|
||||||
|
let raw: Value = client
|
||||||
|
.get(SOURCE_URL)
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("request failed: {e}"))?
|
||||||
|
.error_for_status()
|
||||||
|
.map_err(|e| format!("models.dev returned {e}"))?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("failed to parse api.json: {e}"))?;
|
||||||
|
|
||||||
|
let snapshot = build_snapshot(&raw)?;
|
||||||
|
let json = serde_json::to_string_pretty(&snapshot).map_err(|e| e.to_string())? + "\n";
|
||||||
|
|
||||||
|
let path = synced_path();
|
||||||
|
if let Some(dir) = path.parent() {
|
||||||
|
fs::create_dir_all(dir).map_err(|e| format!("failed to create {}: {e}", dir.display()))?;
|
||||||
|
}
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
fs::write(&tmp, &json).map_err(|e| format!("failed to write {}: {e}", tmp.display()))?;
|
||||||
|
// Windows rename replaces an existing destination (MOVEFILE_REPLACE_EXISTING).
|
||||||
|
fs::rename(&tmp, &path).map_err(|e| {
|
||||||
|
let _ = fs::remove_file(&tmp);
|
||||||
|
format!("failed to replace {}: {e}", path.display())
|
||||||
|
})?;
|
||||||
|
Ok(status_of("synced", &json))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh the models.dev snapshot from the network.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn sync_models_dev() -> Result<ModelsDevStatus, String> {
|
||||||
|
sync_from_remote().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the current reference data comes from (for the settings card).
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_models_dev_status() -> Result<ModelsDevStatus, String> {
|
||||||
|
Ok(if read_synced().is_some() {
|
||||||
|
status("synced")
|
||||||
|
} else {
|
||||||
|
status("builtin")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The synced snapshot's raw JSON, or None when the frontend should keep
|
||||||
|
/// using the bundled static asset.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_models_dev_snapshot() -> Result<Option<String>, String> {
|
||||||
|
Ok(read_synced())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop the synced copy and fall back to the compiled-in snapshot.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn reset_models_dev() -> Result<ModelsDevStatus, String> {
|
||||||
|
match fs::remove_file(synced_path()) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(e) => return Err(format!("failed to remove synced snapshot: {e}")),
|
||||||
|
}
|
||||||
|
Ok(status("builtin"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_snapshot_picks_reference_fields() {
|
||||||
|
let raw = json!({
|
||||||
|
"moonshotai": {
|
||||||
|
"models": {
|
||||||
|
"kimi-k3": {
|
||||||
|
"name": "Kimi K3",
|
||||||
|
"limit": { "context": 262144, "output": 16384 },
|
||||||
|
"reasoning": true,
|
||||||
|
"tool_call": true,
|
||||||
|
"structured_output": true,
|
||||||
|
"modalities": { "input": ["text", "image"], "output": ["text"] },
|
||||||
|
"cost": { "input": 3.0, "output": 15.0, "cache_read": 0.3, "cache_write": 0.0 }
|
||||||
|
},
|
||||||
|
"kimi-image": {
|
||||||
|
"name": "Kimi Image",
|
||||||
|
"limit": { "context": 0 },
|
||||||
|
"modalities": { "input": ["text", "image", "video"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zhipuai": {
|
||||||
|
"models": {
|
||||||
|
"glm-5.3": {
|
||||||
|
"name": "GLM-5.3",
|
||||||
|
"reasoning": false,
|
||||||
|
"cost": { "input": 0.6, "output": 2.2 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let snap = build_snapshot(&raw).unwrap();
|
||||||
|
let obj = snap.as_object().unwrap();
|
||||||
|
assert!(obj.contains_key("last_updated"));
|
||||||
|
|
||||||
|
let k3 = &obj["moonshotai/kimi-k3"];
|
||||||
|
assert_eq!(k3["name"], "Kimi K3");
|
||||||
|
assert_eq!(k3["context"], 262144);
|
||||||
|
assert_eq!(k3["reasoning"], true);
|
||||||
|
assert_eq!(k3["tool_call"], true);
|
||||||
|
assert_eq!(k3["structured_output"], true);
|
||||||
|
assert_eq!(k3["image"], true);
|
||||||
|
assert!(k3.get("video").is_none());
|
||||||
|
assert_eq!(k3["cost"]["input"], 3.0);
|
||||||
|
assert_eq!(k3["cost"]["cache_read"], 0.3);
|
||||||
|
assert_eq!(k3["cost"]["cache_write"], 0.0);
|
||||||
|
|
||||||
|
// Context 0 → dropped; video modality → flagged.
|
||||||
|
let img = &obj["moonshotai/kimi-image"];
|
||||||
|
assert!(img.get("context").is_none());
|
||||||
|
assert_eq!(img["video"], true);
|
||||||
|
assert!(img.get("cost").is_none());
|
||||||
|
|
||||||
|
// Explicit false is omitted (same as the mjs script).
|
||||||
|
let glm = &obj["zhipuai/glm-5.3"];
|
||||||
|
assert!(glm.get("reasoning").is_none());
|
||||||
|
assert_eq!(glm["cost"]["input"], 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn status_counts_models_and_providers() {
|
||||||
|
let raw = r#"{
|
||||||
|
"last_updated": "2026-09-21",
|
||||||
|
"moonshotai/kimi-k3": { "cost": { "input": 3.0 } },
|
||||||
|
"moonshotai/kimi-k2": { "name": "K2" },
|
||||||
|
"zhipuai/glm-5.3": {}
|
||||||
|
}"#;
|
||||||
|
let st = status_of("builtin", raw);
|
||||||
|
assert_eq!(st.last_updated, "2026-09-21");
|
||||||
|
assert_eq!(st.model_count, 3);
|
||||||
|
assert_eq!(st.provider_count, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn num_serializes_whole_numbers_as_integers() {
|
||||||
|
assert_eq!(num(262144.0), json!(262144));
|
||||||
|
assert_eq!(num(0.3), json!(0.3));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"productName": "Kimi Switch",
|
"productName": "Kimi Switch",
|
||||||
"version": "0.7.20",
|
"version": "0.7.21",
|
||||||
"identifier": "com.kimiswitch.app",
|
"identifier": "com.kimiswitch.app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ import {
|
|||||||
type ValidationErrorKey,
|
type ValidationErrorKey,
|
||||||
} from "../lib/subagent-settings";
|
} from "../lib/subagent-settings";
|
||||||
import { Card, Toggle } from "./ui/controls";
|
import { Card, Toggle } from "./ui/controls";
|
||||||
|
import { applyModelsDevSnapshot, reloadModelsDev } from "../lib/models-dev";
|
||||||
|
|
||||||
|
/** Mirror of models_dev::ModelsDevStatus (camelCase over IPC). */
|
||||||
|
interface ModelsDevStatus {
|
||||||
|
source: "synced" | "builtin";
|
||||||
|
lastUpdated: string;
|
||||||
|
modelCount: number;
|
||||||
|
providerCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface SubagentSettingsPageProps {
|
interface SubagentSettingsPageProps {
|
||||||
/** config.raw_other — hosts the `[experimental]` and `[secondary_model]` sections. */
|
/** config.raw_other — hosts the `[experimental]` and `[secondary_model]` sections. */
|
||||||
@@ -155,13 +164,65 @@ export function SubagentSettingsPage({
|
|||||||
const [addSelection, setAddSelection] = useState("");
|
const [addSelection, setAddSelection] = useState("");
|
||||||
/** WebUI-open button in flight; disables both buttons while non-null. */
|
/** WebUI-open button in flight; disables both buttons while non-null. */
|
||||||
const [webuiBusy, setWebuiBusy] = useState<"embedded" | "browser" | null>(null);
|
const [webuiBusy, setWebuiBusy] = useState<"embedded" | "browser" | null>(null);
|
||||||
|
/** models.dev snapshot provenance (synced copy vs bundled asset). */
|
||||||
|
const [modelsDevStatus, setModelsDevStatus] = useState<ModelsDevStatus | null>(null);
|
||||||
|
/** Sync/restore button in flight; blocks the card's buttons. */
|
||||||
|
const [modelsDevBusy, setModelsDevBusy] = useState(false);
|
||||||
|
/** Last sync error (inline) and success flag (auto-cleared on next action). */
|
||||||
|
const [syncError, setSyncError] = useState<string | null>(null);
|
||||||
|
const [syncOk, setSyncOk] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
invoke<ExperimentalEnvStatus>("get_experimental_env_status")
|
invoke<ExperimentalEnvStatus>("get_experimental_env_status")
|
||||||
.then(setEnv)
|
.then(setEnv)
|
||||||
.catch(() => setEnv({}));
|
.catch(() => setEnv({}));
|
||||||
|
invoke<ModelsDevStatus>("get_models_dev_status")
|
||||||
|
.then(setModelsDevStatus)
|
||||||
|
.catch(() => setModelsDevStatus(null));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const refreshModelsDevStatus = () => {
|
||||||
|
invoke<ModelsDevStatus>("get_models_dev_status")
|
||||||
|
.then(setModelsDevStatus)
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModelsDevSync = async () => {
|
||||||
|
setModelsDevBusy(true);
|
||||||
|
setSyncError(null);
|
||||||
|
setSyncOk(false);
|
||||||
|
try {
|
||||||
|
const st = await invoke<ModelsDevStatus>("sync_models_dev");
|
||||||
|
setModelsDevStatus(st);
|
||||||
|
// Hot-swap the frontend index; the dashboard price index rebuilds on
|
||||||
|
// its next query (mtime-triggered on the Rust side).
|
||||||
|
const raw = await invoke<string | null>("get_models_dev_snapshot");
|
||||||
|
if (raw) applyModelsDevSnapshot(JSON.parse(raw) as Record<string, unknown>);
|
||||||
|
setSyncOk(true);
|
||||||
|
} catch (err) {
|
||||||
|
setSyncError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setModelsDevBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModelsDevRestore = async () => {
|
||||||
|
if (!confirm(t("modelsDataRestoreConfirm"))) return;
|
||||||
|
setModelsDevBusy(true);
|
||||||
|
setSyncError(null);
|
||||||
|
setSyncOk(false);
|
||||||
|
try {
|
||||||
|
const st = await invoke<ModelsDevStatus>("reset_models_dev");
|
||||||
|
setModelsDevStatus(st);
|
||||||
|
await reloadModelsDev();
|
||||||
|
} catch (err) {
|
||||||
|
setSyncError(err instanceof Error ? err.message : String(err));
|
||||||
|
refreshModelsDevStatus();
|
||||||
|
} finally {
|
||||||
|
setModelsDevBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const flags = getExperimentalFlags(rawOther);
|
const flags = getExperimentalFlags(rawOther);
|
||||||
const pool = getSubagentModelPool(rawOther);
|
const pool = getSubagentModelPool(rawOther);
|
||||||
const poolKeys = pool ? Object.keys(pool.models) : [];
|
const poolKeys = pool ? Object.keys(pool.models) : [];
|
||||||
@@ -398,6 +459,66 @@ export function SubagentSettingsPage({
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* models.dev reference data: online sync / restore bundled */}
|
||||||
|
<Card title={t("modelsDataSection")}>
|
||||||
|
<p className="text-xs text-content-muted">{t("modelsDataDesc")}</p>
|
||||||
|
{modelsDevStatus && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<span
|
||||||
|
className={`shrink-0 rounded border px-1.5 py-0.5 text-xs ${
|
||||||
|
modelsDevStatus.source === "synced"
|
||||||
|
? "border-blue-500/30 text-blue-600 dark:text-blue-400"
|
||||||
|
: "border-border text-content-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{modelsDevStatus.source === "synced"
|
||||||
|
? t("modelsDataSourceSynced")
|
||||||
|
: t("modelsDataSourceBuiltin")}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-content-muted">
|
||||||
|
{t("modelsDataStats", {
|
||||||
|
date: modelsDevStatus.lastUpdated,
|
||||||
|
models: modelsDevStatus.modelCount.toLocaleString(),
|
||||||
|
providers: modelsDevStatus.providerCount.toLocaleString(),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{syncOk && (
|
||||||
|
<div className="bg-green-100 dark:bg-green-900/20 border border-green-300 dark:border-green-500/30 rounded-lg px-3 py-2 text-xs text-green-700 dark:text-green-400">
|
||||||
|
{t("modelsDataSyncOk")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{syncError && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="bg-red-100 dark:bg-red-900/20 border border-red-300 dark:border-red-500/30 rounded-lg px-3 py-2 text-xs text-red-700 dark:text-red-400"
|
||||||
|
>
|
||||||
|
{t("modelsDataSyncFailed")}: {syncError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={modelsDevBusy}
|
||||||
|
onClick={handleModelsDevSync}
|
||||||
|
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{modelsDevBusy ? t("modelsDataSyncing") : t("modelsDataSyncNow")}
|
||||||
|
</button>
|
||||||
|
{modelsDevStatus?.source === "synced" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={modelsDevBusy}
|
||||||
|
onClick={handleModelsDevRestore}
|
||||||
|
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{t("modelsDataRestore")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Subagent: secondary model picker / pool + inherited settings */}
|
{/* Subagent: secondary model picker / pool + inherited settings */}
|
||||||
<Card title={t("secondaryModelSection")}>
|
<Card title={t("secondaryModelSection")}>
|
||||||
{isPoolView && pool ? (
|
{isPoolView && pool ? (
|
||||||
|
|||||||
@@ -594,6 +594,21 @@ export const enTranslations: Record<TranslationKey, string> = {
|
|||||||
openWebUIBrowser: "Open in Browser",
|
openWebUIBrowser: "Open in Browser",
|
||||||
webuiOpening: "Opening...",
|
webuiOpening: "Opening...",
|
||||||
|
|
||||||
|
// models.dev reference data (context / capabilities / pricing)
|
||||||
|
modelsDataSection: "Model Reference Data (models.dev)",
|
||||||
|
modelsDataDesc:
|
||||||
|
"Model context limits, capability flags, and pricing come from an online models.dev snapshot. Syncing applies new models immediately — no app update needed.",
|
||||||
|
modelsDataSourceSynced: "Online sync",
|
||||||
|
modelsDataSourceBuiltin: "Bundled with release",
|
||||||
|
modelsDataStats: "{date} · {models} models / {providers} providers",
|
||||||
|
modelsDataSyncNow: "Sync Now",
|
||||||
|
modelsDataSyncing: "Syncing...",
|
||||||
|
modelsDataSyncOk: "Synced — model parameters and pricing refreshed",
|
||||||
|
modelsDataSyncFailed: "Sync failed",
|
||||||
|
modelsDataRestore: "Restore Built-in Data",
|
||||||
|
modelsDataRestoreConfirm:
|
||||||
|
"Delete the local synced copy and fall back to the snapshot bundled with this release? You can sync again at any time.",
|
||||||
|
|
||||||
// Plugin marketplace
|
// Plugin marketplace
|
||||||
pluginMarketplace: "Plugin Marketplace",
|
pluginMarketplace: "Plugin Marketplace",
|
||||||
pluginMarketplaceSubtitle:
|
pluginMarketplaceSubtitle:
|
||||||
|
|||||||
@@ -583,6 +583,21 @@ export const zhTranslations = {
|
|||||||
openWebUIBrowser: "在浏览器打开",
|
openWebUIBrowser: "在浏览器打开",
|
||||||
webuiOpening: "打开中...",
|
webuiOpening: "打开中...",
|
||||||
|
|
||||||
|
// models.dev reference data (context / capabilities / pricing)
|
||||||
|
modelsDataSection: "模型参考数据(models.dev)",
|
||||||
|
modelsDataDesc:
|
||||||
|
"模型上下文长度、能力标记与单价参考来自 models.dev 在线快照。同步后新模型的参数与价格立即生效,无需等待新版本。",
|
||||||
|
modelsDataSourceSynced: "在线同步",
|
||||||
|
modelsDataSourceBuiltin: "随版本内置",
|
||||||
|
modelsDataStats: "{date} · {models} 模型 / {providers} 供应商",
|
||||||
|
modelsDataSyncNow: "立即同步",
|
||||||
|
modelsDataSyncing: "同步中...",
|
||||||
|
modelsDataSyncOk: "已同步,模型参数与单价已刷新",
|
||||||
|
modelsDataSyncFailed: "同步失败",
|
||||||
|
modelsDataRestore: "恢复内置数据",
|
||||||
|
modelsDataRestoreConfirm:
|
||||||
|
"删除本地同步副本并回退到随版本内置的快照数据?之后可随时重新同步。",
|
||||||
|
|
||||||
// Plugin marketplace
|
// Plugin marketplace
|
||||||
pluginMarketplace: "插件市场",
|
pluginMarketplace: "插件市场",
|
||||||
pluginMarketplaceSubtitle:
|
pluginMarketplaceSubtitle:
|
||||||
|
|||||||
+104475
-100479
File diff suppressed because it is too large.
Load diff
+88653
-85246
File diff suppressed because it is too large.
Load diff
+56
-19
@@ -3,13 +3,18 @@
|
|||||||
* (see scripts/fetch-models-dev.mjs).
|
* (see scripts/fetch-models-dev.mjs).
|
||||||
*
|
*
|
||||||
* The snapshot (~1.3 MB JSON) is NOT bundled into the main chunk anymore:
|
* The snapshot (~1.3 MB JSON) is NOT bundled into the main chunk anymore:
|
||||||
* parsing a 1.3 MB JSON literal at startup blocks first paint. It is served
|
* parsing a 1.3 MB JSON literal at startup blocks first paint. Loading order
|
||||||
* as a static asset (`/models-dev.json`) and loaded once in the background.
|
* (see modelsDevReady): the runtime-synced copy under ~/.kimi-switch/
|
||||||
* `getModelRef` stays synchronous and returns undefined until the index is
|
* (via models_dev.rs, written by the 高级设置 sync button) when present,
|
||||||
* ready — callers already fall back to defaults — and `modelsDevReady()`
|
* otherwise the bundled static asset (`/models-dev.json`). `getModelRef`
|
||||||
* lets the app re-render once the index arrives.
|
* stays synchronous and returns undefined until the index is ready — callers
|
||||||
|
* already fall back to defaults — and `modelsDevReady()` lets the app
|
||||||
|
* re-render once the index arrives. `applyModelsDevSnapshot` hot-swaps the
|
||||||
|
* index after an online sync without a restart.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
export interface ModelCost {
|
export interface ModelCost {
|
||||||
input?: number;
|
input?: number;
|
||||||
output?: number;
|
output?: number;
|
||||||
@@ -49,23 +54,39 @@ function buildIndex(raw: Record<string, unknown>) {
|
|||||||
return lower;
|
return lower;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swap in a new snapshot (initial load, online sync, or restore-to-builtin)
|
||||||
|
* and re-notify listeners so context/capability/price columns re-render.
|
||||||
|
* Listeners stay registered across reloads by design.
|
||||||
|
*/
|
||||||
|
function applySnapshot(raw: Record<string, unknown>) {
|
||||||
|
byLowerKey = buildIndex(raw);
|
||||||
|
for (const cb of readyListeners) cb();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchBundled(): Promise<Record<string, unknown>> {
|
||||||
|
const r = await fetch(`${import.meta.env.BASE_URL}models-dev.json`);
|
||||||
|
if (!r.ok) throw new Error(`models-dev.json HTTP ${r.status}`);
|
||||||
|
return (await r.json()) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
let loadPromise: Promise<void> | null = null;
|
let loadPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
export function modelsDevReady(): Promise<void> {
|
export function modelsDevReady(): Promise<void> {
|
||||||
if (!loadPromise) {
|
if (!loadPromise) {
|
||||||
loadPromise = fetch(`${import.meta.env.BASE_URL}models-dev.json`)
|
// Prefer the runtime-synced copy (~/.kimi-switch/models-dev.json, via
|
||||||
.then((r) => {
|
// models_dev.rs); outside Tauri (or without one) fall back to the bundled
|
||||||
if (!r.ok) throw new Error(`models-dev.json HTTP ${r.status}`);
|
// static asset. A failed load is permanent for this session.
|
||||||
return r.json() as Promise<Record<string, unknown>>;
|
loadPromise = (async () => {
|
||||||
})
|
let raw: Record<string, unknown> | null = null;
|
||||||
.then((raw) => {
|
try {
|
||||||
byLowerKey = buildIndex(raw);
|
const synced = await invoke<string | null>("get_models_dev_snapshot");
|
||||||
const listeners = readyListeners;
|
if (synced) raw = JSON.parse(synced) as Record<string, unknown>;
|
||||||
readyListeners = [];
|
} catch {
|
||||||
for (const cb of listeners) cb();
|
/* not running inside Tauri — use the bundled asset */
|
||||||
})
|
}
|
||||||
.catch((err) => {
|
applySnapshot(raw ?? (await fetchBundled()));
|
||||||
// A failed load is permanent for this session: fall back to defaults.
|
})().catch((err) => {
|
||||||
byLowerKey = {};
|
byLowerKey = {};
|
||||||
loadPromise = null; // allow one retry next time
|
loadPromise = null; // allow one retry next time
|
||||||
console.warn("models-dev.json load failed:", err);
|
console.warn("models-dev.json load failed:", err);
|
||||||
@@ -74,7 +95,23 @@ export function modelsDevReady(): Promise<void> {
|
|||||||
return loadPromise;
|
return loadPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Register a callback invoked once the models.dev index is ready. */
|
/**
|
||||||
|
* Apply a freshly synced snapshot (already fetched by the caller) and notify
|
||||||
|
* listeners so the UI refreshes without a restart.
|
||||||
|
*/
|
||||||
|
export function applyModelsDevSnapshot(raw: Record<string, unknown>): void {
|
||||||
|
applySnapshot(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reload from the bundled static asset (after "restore built-in data" —
|
||||||
|
* the synced copy was already deleted on the Rust side).
|
||||||
|
*/
|
||||||
|
export function reloadModelsDev(): Promise<void> {
|
||||||
|
return fetchBundled().then(applySnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register a callback invoked whenever the models.dev index changes. */
|
||||||
export function onModelsDevReady(cb: () => void): void {
|
export function onModelsDevReady(cb: () => void): void {
|
||||||
if (byLowerKey) {
|
if (byLowerKey) {
|
||||||
cb();
|
cb();
|
||||||
|
|||||||
Reference in new issue
Block a user