Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7435198fcc | ||
|
|
67db19eda4 | ||
|
|
9764929919 | ||
|
|
f40e4be577 | ||
|
|
138a2b8547 | ||
|
|
769852c4b5 |
No files matched your search
@@ -81,11 +81,11 @@ jobs:
|
|||||||
|
|
||||||
### 本次更新
|
### 本次更新
|
||||||
|
|
||||||
- **高级设置**:原「子代理设置」升级为「高级设置」,聚合子代理模型指定、实验功能开关与 WebUI 快捷入口
|
- **模型数据同步(models.dev 2026-08-27)**:7343 个模型 / 203 个供应商
|
||||||
- **仪表盘热力图双击弹窗**:双击热力图方块即可查看当日模型用量分布,每种模型展示 Token / 请求次数 / 费用 / 命中率
|
- **新增 GLM-5.3 / GLM-5.3-Flash 全线接入**:智谱官方、OpenRouter、Cloudflare、DeepInfra、HuggingFace、火山引擎等约 20 条渠道;GLM-5.3-Flash 定价 $0.075 / $0.25(输入/输出,每百万 token)
|
||||||
- **子代理模型指定(实验功能)**:为子代理绑定次主力模型,不再默认继承主模型;实验开关改为滑动开关并修复开启态隐形
|
- 新增 coding plan 渠道:`zhipuai-coding-plan/glm-5.3`、`glm-5.3-highspeed`、`glm-5.3-flash`
|
||||||
- **兼容 kimi-code 0.33+(v2 引擎)**:`loop_control` 写入新键名 `max_attempts_per_step`,旧配置自动迁移并保留旧键兼容 `KIMI_CODE_LEGACY_FLAG=1`;hooks 候选补充新事件
|
- 新增 Qwen3.8 系列、火山引擎豆包 Seed 2.x 全系、DeepSeek-V4 GA 版、MiniMax-M2.7/M3 等共 74 个模型条目
|
||||||
- models.dev 模型数据同步;官网企业版视觉升级;出品公司标识统一
|
- 价格更新 58 处、能力信息修正 56 处(详见 release-notes-v0.7.9.md)
|
||||||
|
|
||||||
### Downloads by platform
|
### Downloads by platform
|
||||||
|
|
||||||
|
|||||||
@@ -543,6 +543,12 @@ A: 需要分两步:
|
|||||||
**Q: 切换会覆盖其他供应商吗?**
|
**Q: 切换会覆盖其他供应商吗?**
|
||||||
A: 不会。Kimi Code 的 `config.toml` 始终写入全部供应商,仅 `default_model` 决定生效项。这与 CLI 原生 `/provider` 行为一致。
|
A: 不会。Kimi Code 的 `config.toml` 始终写入全部供应商,仅 `default_model` 决定生效项。这与 CLI 原生 `/provider` 行为一致。
|
||||||
|
|
||||||
|
**Q: 在 Kimi Code CLI 里新增的顶层配置节(如 `[task]`/`[swarm]`)会被保存时删掉吗?**
|
||||||
|
A: 不会(v0.7.10 起)。保存时以加载配置那一刻的顶层键为基线,CLI 后加的节原样保留;只有在本应用中显式删除的节才会从 `config.toml` 移除。
|
||||||
|
|
||||||
|
**Q: 遇到 Kimi Code 新增的供应商类型(`type` 值)会怎样?**
|
||||||
|
A: 原样保留,不再改写为 `kimi`。编辑界面会显示原始类型值(标注"未知类型");该供应商不支持的操作(如模型发现)会返回明确错误提示。
|
||||||
|
|
||||||
**Q: 新增/激活的供应商位置怎么变?**
|
**Q: 新增/激活的供应商位置怎么变?**
|
||||||
A: 自动提升到列表最前,UI 立即反映。
|
A: 自动提升到列表最前,UI 立即反映。
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "kimiswitch",
|
"name": "kimiswitch",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.7.7",
|
"version": "0.7.10",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+77684
-58645
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,8 @@
|
|||||||
|
## v0.7.10
|
||||||
|
|
||||||
|
### 配置写回保护(适配 kimi-code 0.38+ 配置演进)
|
||||||
|
|
||||||
|
- **修复:保存不再误删 CLI 新增的顶层配置节**。此前在 Kimi Switch 加载配置后,若 Kimi Code CLI 往 `config.toml` 写入了新的顶层节(如 0.38+ 的 `[task]` / `[swarm]` / `[cron]` / `[tools]` / `[identity]` / `[token_counting]` 等),回到 Kimi Switch 保存时会把这些节**静默删除**。现在以导入时的顶层键为基线:CLI 后加的节原样保留;在界面中显式删除的节仍会被移除;老数据(无基线,如 SQLite 快照恢复)不删任何键
|
||||||
|
- **修复:未知供应商类型不再被改写**。`config.toml` 中 Kimi Code 新增的供应商 `type` 值往返读写后不再被改写成 `kimi`,原样保留;编辑器 API 格式下拉框显示原始值(标注"未知类型");模型发现等不支持的操作返回明确错误提示,不再误用 `KIMI_API_KEY`
|
||||||
|
- SQLite 快照恢复路径同步修复(此前从快照恢复同样会丢失未知类型)
|
||||||
|
- 新增 3 个配置往返回归测试(Rust 测试 106 项全绿,前端 tsc / vitest 通过)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
## v0.7.8
|
||||||
|
|
||||||
|
### 修复:模型用量上下显示不同步
|
||||||
|
|
||||||
|
- 修复同一供应商卡片上,上方用量摘要(含 🔄 刷新按钮)刷新后、下方用量进度条仍停留在旧数据的问题(如上方 24% vs 下方 8%)
|
||||||
|
- 用量查询状态提升至卡片层统一持有:点刷新、自动轮询后,两处显示同帧同步更新
|
||||||
|
- 自动刷新间隔不再只挂在其中一处显示上,每卡片恰好一份定时器,行为不变
|
||||||
|
|
||||||
|
### 实验功能开关同步 kimi-code 最新旗标注册表
|
||||||
|
|
||||||
|
- 高级设置的实验功能清单对齐 kimi-code 上游 v2 旗标注册表(7 项)
|
||||||
|
- 新增开关:**Tower 模式**(多智能体协同,/tower 命令切换)、**子代理 Fork 上下文**(Agent/AgentSwarm 携带主代理会话快照)、**WaitFor 工具**(回合内等待后台任务)、**自动会话标题**(托管 chat_title 工具)
|
||||||
|
- 移除上游已废弃的 **ACP v2 协议** 开关(手写进 config.toml 的配置仍会完整保留)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
## v0.7.9
|
||||||
|
|
||||||
|
### 模型数据同步(models.dev 2026-08-27 快照)
|
||||||
|
|
||||||
|
- 模型库刷新至 **7343 个模型 / 203 个供应商**,内置快照与官网静态资源同步更新
|
||||||
|
- **新增 GLM-5.3 / GLM-5.3-Flash 全线接入**:智谱官方、OpenRouter、Cloudflare Workers AI、DeepInfra、HuggingFace、火山引擎等约 20 条渠道,GLM-5.3-Flash 定价 $0.075/$0.25(输入/输出,每百万 token)
|
||||||
|
- 新增 coding plan 渠道:`zhipuai-coding-plan/glm-5.3`、`glm-5.3-highspeed`、`glm-5.3-flash`
|
||||||
|
- 新增 Qwen3.8 系列(27B / Flash / 2.4T-A95B)、火山引擎豆包 Seed 2.x 全系(2-0 Pro/Lite/Mini/Code-Preview、2-1 Pro/Turbo)、DeepSeek-V4 GA 版、MiniMax-M2.7/M3 等共 74 个模型条目
|
||||||
|
- 价格更新 58 处:Amazon Bedrock GPT-5.6 Sol 降价(5.5→4)、DeepSeek V4 Flash 0731 半价、Kimi-Latest 微调降价等
|
||||||
|
- 能力信息修正 56 处:MiniMax-M2/M3 上下文扩展至 200K/1M、Grok 4.x 补充 structured_output、豆包 Seed 补充图像输入等
|
||||||
Generated
+1
-1
@@ -1978,7 +1978,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kimiswitch"
|
name = "kimiswitch"
|
||||||
version = "0.7.7"
|
version = "0.7.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "kimiswitch"
|
name = "kimiswitch"
|
||||||
version = "0.7.7"
|
version = "0.7.10"
|
||||||
description = "Kimi Switch - model config manager"
|
description = "Kimi Switch - model config manager"
|
||||||
authors = ["codingplan.site"]
|
authors = ["codingplan.site"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
+21
-11
@@ -244,6 +244,7 @@ fn build_active_config(config: &Config) -> Config {
|
|||||||
providers,
|
providers,
|
||||||
models,
|
models,
|
||||||
raw_other: config.raw_other.clone(),
|
raw_other: config.raw_other.clone(),
|
||||||
|
imported_section_keys: config.imported_section_keys.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,6 +278,9 @@ pub async fn list_provider_models(provider: Provider) -> Result<Vec<DiscoveredMo
|
|||||||
ProviderType::Anthropic => fetch_anthropic_models(&base, &api_key).await,
|
ProviderType::Anthropic => fetch_anthropic_models(&base, &api_key).await,
|
||||||
ProviderType::GoogleGenai => fetch_google_genai_models(&base, &api_key).await,
|
ProviderType::GoogleGenai => fetch_google_genai_models(&base, &api_key).await,
|
||||||
ProviderType::Vertexai => Err("Vertex AI model discovery requires GCP project/location configuration and is not yet supported".to_string()),
|
ProviderType::Vertexai => Err("Vertex AI model discovery requires GCP project/location configuration and is not yet supported".to_string()),
|
||||||
|
ProviderType::Unknown(s) => Err(format!(
|
||||||
|
"unsupported provider type for model discovery: {s}"
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,8 +342,12 @@ fn resolve_api_key(provider: &Provider) -> Option<String> {
|
|||||||
return Some(key.clone());
|
return Some(key.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let env_key = expected_api_key_key(&provider.provider_type);
|
if let Some(env_key) = expected_api_key_key(&provider.provider_type) {
|
||||||
provider.env.get(env_key).filter(|s| !s.is_empty()).cloned()
|
if let Some(key) = provider.env.get(env_key).filter(|s| !s.is_empty()) {
|
||||||
|
return Some(key.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_base_url(provider: &Provider) -> String {
|
fn resolve_base_url(provider: &Provider) -> String {
|
||||||
@@ -356,13 +364,16 @@ fn resolve_base_url(provider: &Provider) -> String {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn expected_api_key_key(provider_type: &ProviderType) -> &'static str {
|
/// Well-known env var fallback for the API key per provider type; `None`
|
||||||
|
/// for unknown types (no well-known variable name to look up).
|
||||||
|
fn expected_api_key_key(provider_type: &ProviderType) -> Option<&'static str> {
|
||||||
match provider_type {
|
match provider_type {
|
||||||
ProviderType::Kimi => "KIMI_API_KEY",
|
ProviderType::Kimi => Some("KIMI_API_KEY"),
|
||||||
ProviderType::Anthropic => "ANTHROPIC_API_KEY",
|
ProviderType::Anthropic => Some("ANTHROPIC_API_KEY"),
|
||||||
ProviderType::Openai | ProviderType::OpenaiResponses => "OPENAI_API_KEY",
|
ProviderType::Openai | ProviderType::OpenaiResponses => Some("OPENAI_API_KEY"),
|
||||||
ProviderType::GoogleGenai => "GOOGLE_API_KEY",
|
ProviderType::GoogleGenai => Some("GOOGLE_API_KEY"),
|
||||||
ProviderType::Vertexai => "VERTEXAI_API_KEY",
|
ProviderType::Vertexai => Some("VERTEXAI_API_KEY"),
|
||||||
|
ProviderType::Unknown(_) => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -650,9 +661,8 @@ pub async fn query_provider_usage(
|
|||||||
.clone()
|
.clone()
|
||||||
.filter(|s| !s.trim().is_empty())
|
.filter(|s| !s.trim().is_empty())
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
provider
|
expected_api_key_key(&provider.provider_type)
|
||||||
.env
|
.and_then(|env_key| provider.env.get(env_key))
|
||||||
.get(expected_api_key_key(&provider.provider_type))
|
|
||||||
.cloned()
|
.cloned()
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
});
|
});
|
||||||
|
|||||||
+4
-8
@@ -207,6 +207,9 @@ pub fn load_config(agent: &Agent) -> DbResult<Config> {
|
|||||||
providers,
|
providers,
|
||||||
models,
|
models,
|
||||||
raw_other: Value::Null,
|
raw_other: Value::Null,
|
||||||
|
// The SQLite snapshot stores no import baseline; an empty baseline
|
||||||
|
// makes export's stale-key cleanup a no-op (safe degradation).
|
||||||
|
imported_section_keys: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,12 +331,5 @@ pub fn delete_setting_pub(key: &str) -> DbResult<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn provider_type_for_str(s: &str) -> ProviderType {
|
fn provider_type_for_str(s: &str) -> ProviderType {
|
||||||
match s {
|
ProviderType::from_kimi_type(s)
|
||||||
"anthropic" => ProviderType::Anthropic,
|
|
||||||
"openai" => ProviderType::Openai,
|
|
||||||
"openai_responses" => ProviderType::OpenaiResponses,
|
|
||||||
"google-genai" => ProviderType::GoogleGenai,
|
|
||||||
"vertexai" => ProviderType::Vertexai,
|
|
||||||
_ => ProviderType::Kimi,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+147
-23
@@ -148,7 +148,7 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
|
|||||||
let provider_type = table
|
let provider_type = table
|
||||||
.get("type")
|
.get("type")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.map(provider_type_for_kimi_type)
|
.map(ProviderType::from_kimi_type)
|
||||||
.unwrap_or(ProviderType::Kimi);
|
.unwrap_or(ProviderType::Kimi);
|
||||||
|
|
||||||
let base_url = table.get("base_url").and_then(|v| v.as_str()).map(|s| s.to_string());
|
let base_url = table.get("base_url").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||||
@@ -216,19 +216,23 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let raw_other = {
|
let mut rest = root;
|
||||||
let mut rest = root;
|
rest.remove("default_model");
|
||||||
rest.remove("default_model");
|
rest.remove("providers");
|
||||||
rest.remove("providers");
|
rest.remove("models");
|
||||||
rest.remove("models");
|
// Baseline of top-level keys seen at import time (table order). Export
|
||||||
toml_value_to_json(&TomlValue::Table(rest))
|
// only drops baseline keys that later disappeared from raw_other, i.e.
|
||||||
};
|
// sections the user removed in the UI — sections the CLI adds after
|
||||||
|
// import are not in the baseline and survive the round-trip.
|
||||||
|
let imported_section_keys: Vec<String> = rest.keys().cloned().collect();
|
||||||
|
let raw_other = toml_value_to_json(&TomlValue::Table(rest));
|
||||||
|
|
||||||
Config {
|
Config {
|
||||||
default_model,
|
default_model,
|
||||||
providers,
|
providers,
|
||||||
models,
|
models,
|
||||||
raw_other,
|
raw_other,
|
||||||
|
imported_section_keys,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,10 +358,13 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
|
|||||||
// Sync UI-managed top-level sections (thinking, experimental,
|
// Sync UI-managed top-level sections (thinking, experimental,
|
||||||
// secondary_model, ...) from config.raw_other back into the root.
|
// secondary_model, ...) from config.raw_other back into the root.
|
||||||
// At import time every top-level key except default_model/providers/
|
// At import time every top-level key except default_model/providers/
|
||||||
// models was collected into raw_other, so raw_other is the authoritative
|
// models was collected into raw_other, and their keys recorded in
|
||||||
// snapshot of those sections. This both persists UI edits to those
|
// `imported_section_keys` (the import-time baseline). Keys present in
|
||||||
// sections and drops sections the user explicitly removed (absent from
|
// raw_other are written back (persisting UI edits); keys that were in
|
||||||
// raw_other but still present in `existing`).
|
// the baseline but are absent from raw_other were removed by the user
|
||||||
|
// and are dropped from `existing`. Top-level keys NOT in the baseline
|
||||||
|
// were added by the CLI after import and are preserved untouched. An
|
||||||
|
// empty baseline (Config not built via import) deletes nothing.
|
||||||
let mut managed_keys = std::collections::HashSet::new();
|
let mut managed_keys = std::collections::HashSet::new();
|
||||||
if let TomlValue::Table(extra) =
|
if let TomlValue::Table(extra) =
|
||||||
json_to_toml(&config.raw_other).unwrap_or(TomlValue::Table(Table::new()))
|
json_to_toml(&config.raw_other).unwrap_or(TomlValue::Table(Table::new()))
|
||||||
@@ -377,6 +384,7 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
|
|||||||
&& *k != "providers"
|
&& *k != "providers"
|
||||||
&& *k != "models"
|
&& *k != "models"
|
||||||
&& !managed_keys.contains(*k)
|
&& !managed_keys.contains(*k)
|
||||||
|
&& config.imported_section_keys.iter().any(|b| b == *k)
|
||||||
})
|
})
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.collect();
|
||||||
@@ -387,17 +395,6 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
|
|||||||
TomlValue::Table(root)
|
TomlValue::Table(root)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn provider_type_for_kimi_type(typ: &str) -> ProviderType {
|
|
||||||
match typ {
|
|
||||||
"anthropic" => ProviderType::Anthropic,
|
|
||||||
"openai" => ProviderType::Openai,
|
|
||||||
"openai_responses" => ProviderType::OpenaiResponses,
|
|
||||||
"google-genai" => ProviderType::GoogleGenai,
|
|
||||||
"vertexai" => ProviderType::Vertexai,
|
|
||||||
_ => ProviderType::Kimi,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn toml_value_to_json(value: &TomlValue) -> Value {
|
fn toml_value_to_json(value: &TomlValue) -> Value {
|
||||||
match value {
|
match value {
|
||||||
TomlValue::String(s) => Value::String(s.clone()),
|
TomlValue::String(s) => Value::String(s.clone()),
|
||||||
@@ -572,6 +569,7 @@ api_key = ""
|
|||||||
providers,
|
providers,
|
||||||
models,
|
models,
|
||||||
raw_other: Value::Null,
|
raw_other: Value::Null,
|
||||||
|
imported_section_keys: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let exported = config_to_kimi_code(&config, None);
|
let exported = config_to_kimi_code(&config, None);
|
||||||
@@ -705,6 +703,7 @@ default_effort = "low"
|
|||||||
providers,
|
providers,
|
||||||
models,
|
models,
|
||||||
raw_other: Value::Null,
|
raw_other: Value::Null,
|
||||||
|
imported_section_keys: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let exported = config_to_kimi_code(&config, None);
|
let exported = config_to_kimi_code(&config, None);
|
||||||
@@ -818,6 +817,7 @@ max_context_size = 1048576
|
|||||||
providers,
|
providers,
|
||||||
models: IndexMap::new(),
|
models: IndexMap::new(),
|
||||||
raw_other: Value::Null,
|
raw_other: Value::Null,
|
||||||
|
imported_section_keys: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let exported = config_to_kimi_code(&config, None);
|
let exported = config_to_kimi_code(&config, None);
|
||||||
@@ -858,6 +858,7 @@ max_context_size = 1048576
|
|||||||
providers,
|
providers,
|
||||||
models: IndexMap::new(),
|
models: IndexMap::new(),
|
||||||
raw_other: Value::Null,
|
raw_other: Value::Null,
|
||||||
|
imported_section_keys: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let exported = config_to_kimi_code(&config, None);
|
let exported = config_to_kimi_code(&config, None);
|
||||||
@@ -908,6 +909,7 @@ max_context_size = 1048576
|
|||||||
)]),
|
)]),
|
||||||
models: IndexMap::new(),
|
models: IndexMap::new(),
|
||||||
raw_other: Value::Null,
|
raw_other: Value::Null,
|
||||||
|
imported_section_keys: Vec::new(),
|
||||||
};
|
};
|
||||||
let exported = config_to_kimi_code(&config, None);
|
let exported = config_to_kimi_code(&config, None);
|
||||||
let provider = exported
|
let provider = exported
|
||||||
@@ -927,6 +929,7 @@ max_context_size = 1048576
|
|||||||
)]),
|
)]),
|
||||||
models: IndexMap::new(),
|
models: IndexMap::new(),
|
||||||
raw_other: Value::Null,
|
raw_other: Value::Null,
|
||||||
|
imported_section_keys: Vec::new(),
|
||||||
};
|
};
|
||||||
let exported = config_to_kimi_code(&config, None);
|
let exported = config_to_kimi_code(&config, None);
|
||||||
let provider = exported
|
let provider = exported
|
||||||
@@ -940,4 +943,125 @@ max_context_size = 1048576
|
|||||||
Some("sk-user-key")
|
Some("sk-user-key")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kimi_code_unknown_provider_type_roundtrip() {
|
||||||
|
// A provider `type` string the CLI knows but Kimi Switch does not
|
||||||
|
// must survive import → export verbatim, not be rewritten to "kimi".
|
||||||
|
let toml_str = r#"
|
||||||
|
default_model = "p1/custom-model"
|
||||||
|
|
||||||
|
[providers.p1]
|
||||||
|
type = "custom-xyz"
|
||||||
|
api_key = "sk-x"
|
||||||
|
|
||||||
|
[models."p1/custom-model"]
|
||||||
|
provider = "p1"
|
||||||
|
model = "custom-model"
|
||||||
|
max_context_size = 128000
|
||||||
|
"#;
|
||||||
|
let value: TomlValue = toml_str.parse().unwrap();
|
||||||
|
let config = kimi_code_to_config(&value);
|
||||||
|
|
||||||
|
let provider = config.providers.get("p1").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
provider.provider_type,
|
||||||
|
ProviderType::Unknown("custom-xyz".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
let exported = config_to_kimi_code(&config, Some(&value));
|
||||||
|
let provider = exported
|
||||||
|
.as_table().unwrap()
|
||||||
|
.get("providers").unwrap()
|
||||||
|
.as_table().unwrap()
|
||||||
|
.get("p1").unwrap()
|
||||||
|
.as_table().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
provider.get("type").and_then(|v| v.as_str()),
|
||||||
|
Some("custom-xyz"),
|
||||||
|
"unknown provider type must round-trip verbatim"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kimi_code_export_preserves_sections_added_after_import() {
|
||||||
|
// Top-level sections the CLI added AFTER we imported (not in the
|
||||||
|
// import-time baseline) must survive export instead of being wiped
|
||||||
|
// by the stale-key cleanup.
|
||||||
|
let imported_str = r#"
|
||||||
|
default_model = "glm-5.2"
|
||||||
|
|
||||||
|
[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
|
||||||
|
"#;
|
||||||
|
let imported: TomlValue = imported_str.parse().unwrap();
|
||||||
|
let config = kimi_code_to_config(&imported);
|
||||||
|
|
||||||
|
// The on-disk file now contains an extra [swarm] section the CLI
|
||||||
|
// wrote after our import.
|
||||||
|
let existing: TomlValue = format!("{}\n[swarm]\ntimeout_ms = 123\n", imported_str.trim_end())
|
||||||
|
.parse()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let exported = config_to_kimi_code(&config, Some(&existing));
|
||||||
|
let root = exported.as_table().unwrap();
|
||||||
|
let swarm = root.get("swarm").unwrap().as_table().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
swarm.get("timeout_ms").and_then(|v| v.as_integer()),
|
||||||
|
Some(123),
|
||||||
|
"CLI-added [swarm] section must be preserved"
|
||||||
|
);
|
||||||
|
// Baseline sections are still synced as before.
|
||||||
|
let thinking = root.get("thinking").unwrap().as_table().unwrap();
|
||||||
|
assert_eq!(thinking.get("enabled").and_then(|v| v.as_bool()), Some(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kimi_code_export_drops_user_removed_sections() {
|
||||||
|
// A section that WAS in the import baseline but was removed from
|
||||||
|
// raw_other (the user deleted it in the UI) must still be dropped
|
||||||
|
// from the exported file.
|
||||||
|
let toml_str = r#"
|
||||||
|
default_model = "glm-5.2"
|
||||||
|
|
||||||
|
[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
|
||||||
|
"#;
|
||||||
|
let value: TomlValue = toml_str.parse().unwrap();
|
||||||
|
let mut config = kimi_code_to_config(&value);
|
||||||
|
assert!(
|
||||||
|
config.imported_section_keys.contains(&"thinking".to_string()),
|
||||||
|
"baseline must record the imported [thinking] section"
|
||||||
|
);
|
||||||
|
|
||||||
|
// UI removed the thinking section.
|
||||||
|
let mut raw = config.raw_other.as_object().unwrap().clone();
|
||||||
|
raw.remove("thinking");
|
||||||
|
config.raw_other = Value::Object(raw);
|
||||||
|
|
||||||
|
let exported = config_to_kimi_code(&config, Some(&value));
|
||||||
|
let root = exported.as_table().unwrap();
|
||||||
|
assert!(
|
||||||
|
!root.contains_key("thinking"),
|
||||||
|
"user-removed section must be dropped on export"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+54
-13
@@ -1,5 +1,7 @@
|
|||||||
|
use std::borrow::Cow;
|
||||||
|
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
/// Target agent whose provider/model config is being edited.
|
/// Target agent whose provider/model config is being edited.
|
||||||
@@ -19,18 +21,35 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum ProviderType {
|
pub enum ProviderType {
|
||||||
Anthropic,
|
Anthropic,
|
||||||
Openai,
|
Openai,
|
||||||
#[serde(rename = "openai_responses")]
|
|
||||||
OpenaiResponses,
|
OpenaiResponses,
|
||||||
#[serde(rename = "google-genai")]
|
|
||||||
GoogleGenai,
|
GoogleGenai,
|
||||||
Vertexai,
|
Vertexai,
|
||||||
/// Kept for compatibility; mapped to OpenAI-compatible in Pi.
|
/// Kept for compatibility; mapped to OpenAI-compatible in Pi.
|
||||||
Kimi,
|
Kimi,
|
||||||
|
/// A provider type string the CLI knows but Kimi Switch does not.
|
||||||
|
/// Kept verbatim so new upstream `type` values survive the round-trip
|
||||||
|
/// instead of being rewritten to "kimi".
|
||||||
|
Unknown(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serde is hand-written to keep a pure string wire format: known variants
|
||||||
|
// map to their CLI string, `Unknown(s)` maps to `s` itself. A derived
|
||||||
|
// representation would expose the internal variant names.
|
||||||
|
impl Serialize for ProviderType {
|
||||||
|
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
serializer.serialize_str(&self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for ProviderType {
|
||||||
|
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||||
|
let s = String::deserialize(deserializer)?;
|
||||||
|
Ok(ProviderType::from_kimi_type(&s))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn default_true() -> bool {
|
const fn default_true() -> bool {
|
||||||
@@ -38,14 +57,29 @@ const fn default_true() -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ProviderType {
|
impl ProviderType {
|
||||||
pub fn as_str(&self) -> &'static str {
|
pub fn as_str(&self) -> Cow<'static, str> {
|
||||||
match self {
|
match self {
|
||||||
ProviderType::Anthropic => "anthropic",
|
ProviderType::Anthropic => Cow::Borrowed("anthropic"),
|
||||||
ProviderType::Openai => "openai",
|
ProviderType::Openai => Cow::Borrowed("openai"),
|
||||||
ProviderType::OpenaiResponses => "openai_responses",
|
ProviderType::OpenaiResponses => Cow::Borrowed("openai_responses"),
|
||||||
ProviderType::GoogleGenai => "google-genai",
|
ProviderType::GoogleGenai => Cow::Borrowed("google-genai"),
|
||||||
ProviderType::Vertexai => "vertexai",
|
ProviderType::Vertexai => Cow::Borrowed("vertexai"),
|
||||||
ProviderType::Kimi => "kimi",
|
ProviderType::Kimi => Cow::Borrowed("kimi"),
|
||||||
|
ProviderType::Unknown(s) => Cow::Owned(s.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a CLI `type` string to a `ProviderType`; unrecognized values
|
||||||
|
/// become `Unknown(s)` so the original string round-trips on export.
|
||||||
|
pub fn from_kimi_type(s: &str) -> ProviderType {
|
||||||
|
match s {
|
||||||
|
"anthropic" => ProviderType::Anthropic,
|
||||||
|
"openai" => ProviderType::Openai,
|
||||||
|
"openai_responses" => ProviderType::OpenaiResponses,
|
||||||
|
"google-genai" => ProviderType::GoogleGenai,
|
||||||
|
"vertexai" => ProviderType::Vertexai,
|
||||||
|
"kimi" => ProviderType::Kimi,
|
||||||
|
other => ProviderType::Unknown(other.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +89,7 @@ impl ProviderType {
|
|||||||
Some("https://api.openai.com/v1")
|
Some("https://api.openai.com/v1")
|
||||||
}
|
}
|
||||||
ProviderType::GoogleGenai => Some("https://generativelanguage.googleapis.com"),
|
ProviderType::GoogleGenai => Some("https://generativelanguage.googleapis.com"),
|
||||||
ProviderType::Anthropic | ProviderType::Vertexai => None,
|
ProviderType::Anthropic | ProviderType::Vertexai | ProviderType::Unknown(_) => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,6 +237,12 @@ pub struct Config {
|
|||||||
pub models: IndexMap<String, Model>,
|
pub models: IndexMap<String, Model>,
|
||||||
#[serde(default, skip_serializing_if = "Value::is_null")]
|
#[serde(default, skip_serializing_if = "Value::is_null")]
|
||||||
pub raw_other: Value,
|
pub raw_other: Value,
|
||||||
|
/// Top-level section keys captured at import time. Export uses this
|
||||||
|
/// baseline to distinguish "user removed this section in the UI" from
|
||||||
|
/// "the CLI added this section after we imported" — only baseline keys
|
||||||
|
/// absent from the current raw_other are dropped on export.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub imported_section_keys: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PartialEq for Config {
|
impl PartialEq for Config {
|
||||||
@@ -211,6 +251,7 @@ impl PartialEq for Config {
|
|||||||
&& self.providers == other.providers
|
&& self.providers == other.providers
|
||||||
&& self.models == other.models
|
&& self.models == other.models
|
||||||
&& self.raw_other == other.raw_other
|
&& self.raw_other == other.raw_other
|
||||||
|
&& self.imported_section_keys == other.imported_section_keys
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -205,8 +205,10 @@ pub fn pi_api_for_provider(provider_type: &ProviderType) -> &'static str {
|
|||||||
ProviderType::Anthropic => "anthropic-messages",
|
ProviderType::Anthropic => "anthropic-messages",
|
||||||
ProviderType::GoogleGenai => "google-generative-ai",
|
ProviderType::GoogleGenai => "google-generative-ai",
|
||||||
ProviderType::Vertexai => "google-vertex",
|
ProviderType::Vertexai => "google-vertex",
|
||||||
// Treat Kimi as OpenAI-compatible since it is not a native Pi API.
|
// Treat Kimi as OpenAI-compatible since it is not a native Pi API;
|
||||||
ProviderType::Kimi => "openai-completions",
|
// unknown upstream types get the same lenient handling (Pi is a
|
||||||
|
// legacy path).
|
||||||
|
ProviderType::Kimi | ProviderType::Unknown(_) => "openai-completions",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,6 +385,7 @@ pub fn pi_file_to_config(file: &PiModelsFile) -> Config {
|
|||||||
providers,
|
providers,
|
||||||
models,
|
models,
|
||||||
raw_other: file.extra.clone(),
|
raw_other: file.extra.clone(),
|
||||||
|
imported_section_keys: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"productName": "Kimi Switch",
|
"productName": "Kimi Switch",
|
||||||
"version": "0.7.7",
|
"version": "0.7.10",
|
||||||
"identifier": "com.kimiswitch.app",
|
"identifier": "com.kimiswitch.app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ function defaultBaseUrl(agent: Agent, type: ProviderType): string {
|
|||||||
case "anthropic":
|
case "anthropic":
|
||||||
case "vertexai":
|
case "vertexai":
|
||||||
return "";
|
return "";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Pi defaults
|
// Pi defaults
|
||||||
@@ -68,6 +70,8 @@ function defaultBaseUrl(agent: Agent, type: ProviderType): string {
|
|||||||
case "anthropic":
|
case "anthropic":
|
||||||
case "vertexai":
|
case "vertexai":
|
||||||
return "";
|
return "";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,6 +311,11 @@ export function ProviderEdit({
|
|||||||
{t}
|
{t}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
|
{!PROVIDER_TYPES.includes(provider.provider_type) && (
|
||||||
|
<option value={provider.provider_type}>
|
||||||
|
{t("apiFormatUnknown", { type: provider.provider_type })}
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+239
-199
@@ -4,6 +4,7 @@ import { Pencil, Copy, Activity, Loader2, Trash2, BarChart3 } from "lucide-react
|
|||||||
import { useTranslation } from "../i18n";
|
import { useTranslation } from "../i18n";
|
||||||
import { ProviderIcon } from "./ProviderIcon";
|
import { ProviderIcon } from "./ProviderIcon";
|
||||||
import { UsageFooter } from "./UsageFooter";
|
import { UsageFooter } from "./UsageFooter";
|
||||||
|
import { useUsageQuery } from "../hooks/useUsageQuery";
|
||||||
import type { Agent, Model, Provider } from "../types";
|
import type { Agent, Model, Provider } from "../types";
|
||||||
|
|
||||||
interface ConnectivityResult {
|
interface ConnectivityResult {
|
||||||
@@ -32,6 +33,228 @@ interface ProviderListProps {
|
|||||||
agent: Agent;
|
agent: Agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ProviderCardProps {
|
||||||
|
provider: Provider;
|
||||||
|
agent: Agent;
|
||||||
|
defaultModel: string | null;
|
||||||
|
models: Record<string, Model>;
|
||||||
|
/** Inline connectivity-test state for this provider, or undefined when idle. */
|
||||||
|
ts: TestState | undefined;
|
||||||
|
onEdit: (name: string) => void;
|
||||||
|
onDelete: (name: string) => void;
|
||||||
|
onDuplicate: (name: string) => void;
|
||||||
|
onSwitchProvider: (name: string) => void;
|
||||||
|
onConfigureUsage: (name: string) => void;
|
||||||
|
onTest: (provider: Provider) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProviderCard({
|
||||||
|
provider,
|
||||||
|
agent,
|
||||||
|
defaultModel,
|
||||||
|
models,
|
||||||
|
ts,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onDuplicate,
|
||||||
|
onSwitchProvider,
|
||||||
|
onConfigureUsage,
|
||||||
|
onTest,
|
||||||
|
}: ProviderCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// Usage query state lives once per card; both the compact (card header) and
|
||||||
|
// detail (footer) UsageFooter variants read this same state, so a refresh
|
||||||
|
// from either side updates both.
|
||||||
|
const usage = useUsageQuery(
|
||||||
|
agent,
|
||||||
|
provider.name,
|
||||||
|
provider.usageKinds,
|
||||||
|
provider.usageConfig?.autoQueryIntervalMinutes
|
||||||
|
);
|
||||||
|
const providerModels = Object.values(models).filter(
|
||||||
|
(m) => m.provider === provider.name
|
||||||
|
);
|
||||||
|
const defaultModelName = defaultModel
|
||||||
|
? models[defaultModel]?.model || models[defaultModel]?.display_name || defaultModel
|
||||||
|
: null;
|
||||||
|
const isActive = provider.active === true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`group relative flex flex-col gap-3 p-4 rounded-xl border transition-colors cursor-pointer w-full ${
|
||||||
|
isActive
|
||||||
|
? "bg-green-50 dark:bg-green-900/10 border-green-300 dark:border-green-500/30 hover:border-green-400 dark:border-green-500/50 hover:bg-green-100 dark:bg-green-900/20"
|
||||||
|
: "bg-panel border-border hover:border-strong hover:bg-hover"
|
||||||
|
}`}
|
||||||
|
onClick={() => onEdit(provider.name)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4 w-full">
|
||||||
|
<ProviderIcon
|
||||||
|
name={provider.name}
|
||||||
|
icon={provider.icon}
|
||||||
|
color={provider.icon_color}
|
||||||
|
size={44}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-semibold text-content-primary truncate">
|
||||||
|
{provider.name}
|
||||||
|
</h3>
|
||||||
|
{isActive && (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 border border-green-300 dark:border-green-500/30">
|
||||||
|
{t("inUse")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{provider.note && (
|
||||||
|
<span className="text-xs text-content-muted truncate max-w-[200px]">
|
||||||
|
{provider.note}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center gap-3 text-sm text-content-muted flex-wrap">
|
||||||
|
<span className="font-mono text-xs">
|
||||||
|
{provider.official_url || provider.base_url || t("noUrl")}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-hover-2 text-content-muted border border-border">
|
||||||
|
{provider.provider_type}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs">
|
||||||
|
{t("modelCount", { count: providerModels.length })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center flex-wrap justify-end gap-2">
|
||||||
|
{defaultModel &&
|
||||||
|
models[defaultModel]?.provider === provider.name && (
|
||||||
|
<span className="text-xs px-2 py-1 rounded-full bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 border border-green-300 dark:border-green-500/20">
|
||||||
|
{t("defaultModel", { name: defaultModelName ?? "" })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* inline connectivity test result */}
|
||||||
|
{ts && ts.status !== "testing" && (
|
||||||
|
<span
|
||||||
|
className={`text-xs px-2 py-0.5 rounded-full border tabular-nums ${
|
||||||
|
ts.status === "ok"
|
||||||
|
? ts.latency && ts.latency > 6000
|
||||||
|
? "bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 border-orange-300 dark:border-orange-500/30"
|
||||||
|
: "bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 border-green-300 dark:border-green-500/30"
|
||||||
|
: "bg-red-100 dark:bg-red-900/30 text-red-500 dark:text-red-400 border-red-300 dark:border-red-500/30"
|
||||||
|
}`}
|
||||||
|
title={
|
||||||
|
ts.status === "fail"
|
||||||
|
? ts.error || t("connectivityFail", { name: provider.name, error: "" })
|
||||||
|
: ts.latency && ts.latency > 6000
|
||||||
|
? t("connectivitySlow", { name: provider.name, latency: ts.latency ?? 0 })
|
||||||
|
: t("connectivityOk", { name: provider.name, latency: ts.latency ?? 0 })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{ts.status === "ok" ? `${ts.latency}ms` : "✕"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* compact usage summary — sits left of the switch button,
|
||||||
|
mirroring cc-switch's card layout (usage → action buttons) */}
|
||||||
|
{(provider.usageKinds?.length ?? 0) > 0 && (
|
||||||
|
<UsageFooter usage={usage} variant="compact" />
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSwitchProvider(provider.name);
|
||||||
|
}}
|
||||||
|
className={`px-3 py-1.5 text-sm rounded focus:ring-2 focus:outline-none ${
|
||||||
|
isActive
|
||||||
|
? "bg-green-600 hover:bg-green-700 text-white focus:ring-green-500"
|
||||||
|
: "bg-blue-600 hover:bg-blue-700 text-white focus:ring-blue-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isActive ? t("inUse") : t("switchTo")}
|
||||||
|
</button>
|
||||||
|
{agent === "kimi_code" && (
|
||||||
|
<span
|
||||||
|
className="text-sm text-content-muted hover:text-content-primary cursor-help select-none"
|
||||||
|
title={t("switchReloadHint")}
|
||||||
|
aria-label={t("switchReloadHint")}
|
||||||
|
>
|
||||||
|
ⓘ
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* icon button group */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit(provider.name);
|
||||||
|
}}
|
||||||
|
title={t("edit")}
|
||||||
|
className={iconBtn}
|
||||||
|
aria-label={t("edit")}
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDuplicate(provider.name);
|
||||||
|
}}
|
||||||
|
title={t("copyProvider")}
|
||||||
|
className={iconBtn}
|
||||||
|
aria-label={t("copyProvider")}
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onTest(provider);
|
||||||
|
}}
|
||||||
|
disabled={ts?.status === "testing"}
|
||||||
|
title={t("testConnectivity")}
|
||||||
|
className={`${iconBtn} disabled:opacity-50`}
|
||||||
|
aria-label={t("testConnectivity")}
|
||||||
|
>
|
||||||
|
{ts?.status === "testing" ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Activity className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onConfigureUsage(provider.name);
|
||||||
|
}}
|
||||||
|
title={t("usageConfigBtn")}
|
||||||
|
className={iconBtn}
|
||||||
|
aria-label={t("usageConfigBtn")}
|
||||||
|
>
|
||||||
|
<BarChart3 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(provider.name);
|
||||||
|
}}
|
||||||
|
title={t("delete")}
|
||||||
|
className={`${iconBtn} hover:text-red-400 hover:border-red-500/30 hover:bg-red-900/20`}
|
||||||
|
aria-label={t("delete")}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<UsageFooter usage={usage} variant="detail" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const iconBtn =
|
const iconBtn =
|
||||||
"w-8 h-8 flex items-center justify-center rounded border border-border text-content-muted hover:text-content-primary hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none transition-colors";
|
"w-8 h-8 flex items-center justify-center rounded border border-border text-content-muted hover:text-content-primary hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none transition-colors";
|
||||||
|
|
||||||
@@ -139,205 +362,22 @@ export function ProviderList({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-3 w-full">
|
<div className="grid grid-cols-1 gap-3 w-full">
|
||||||
{sortedProviders.map((provider) => {
|
{sortedProviders.map((provider) => (
|
||||||
const providerModels = Object.values(models).filter(
|
<ProviderCard
|
||||||
(m) => m.provider === provider.name
|
key={provider.name}
|
||||||
);
|
provider={provider}
|
||||||
const defaultModelName = defaultModel
|
agent={agent}
|
||||||
? models[defaultModel]?.model || models[defaultModel]?.display_name || defaultModel
|
defaultModel={defaultModel}
|
||||||
: null;
|
models={models}
|
||||||
const isActive = provider.active === true;
|
ts={testState[provider.name]}
|
||||||
const ts = testState[provider.name];
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
return (
|
onDuplicate={onDuplicate}
|
||||||
<div
|
onSwitchProvider={onSwitchProvider}
|
||||||
key={provider.name}
|
onConfigureUsage={onConfigureUsage}
|
||||||
className={`group relative flex flex-col gap-3 p-4 rounded-xl border transition-colors cursor-pointer w-full ${
|
onTest={handleTest}
|
||||||
isActive
|
/>
|
||||||
? "bg-green-50 dark:bg-green-900/10 border-green-300 dark:border-green-500/30 hover:border-green-400 dark:border-green-500/50 hover:bg-green-100 dark:bg-green-900/20"
|
))}
|
||||||
: "bg-panel border-border hover:border-strong hover:bg-hover"
|
|
||||||
}`}
|
|
||||||
onClick={() => onEdit(provider.name)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4 w-full">
|
|
||||||
<ProviderIcon
|
|
||||||
name={provider.name}
|
|
||||||
icon={provider.icon}
|
|
||||||
color={provider.icon_color}
|
|
||||||
size={44}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-semibold text-content-primary truncate">
|
|
||||||
{provider.name}
|
|
||||||
</h3>
|
|
||||||
{isActive && (
|
|
||||||
<span className="text-xs px-2 py-0.5 rounded-full bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 border border-green-300 dark:border-green-500/30">
|
|
||||||
{t("inUse")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{provider.note && (
|
|
||||||
<span className="text-xs text-content-muted truncate max-w-[200px]">
|
|
||||||
{provider.note}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 flex items-center gap-3 text-sm text-content-muted flex-wrap">
|
|
||||||
<span className="font-mono text-xs">
|
|
||||||
{provider.official_url || provider.base_url || t("noUrl")}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs px-2 py-0.5 rounded-full bg-hover-2 text-content-muted border border-border">
|
|
||||||
{provider.provider_type}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs">
|
|
||||||
{t("modelCount", { count: providerModels.length })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center flex-wrap justify-end gap-2">
|
|
||||||
{defaultModel &&
|
|
||||||
models[defaultModel]?.provider === provider.name && (
|
|
||||||
<span className="text-xs px-2 py-1 rounded-full bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 border border-green-300 dark:border-green-500/20">
|
|
||||||
{t("defaultModel", { name: defaultModelName ?? "" })}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{/* inline connectivity test result */}
|
|
||||||
{ts && ts.status !== "testing" && (
|
|
||||||
<span
|
|
||||||
className={`text-xs px-2 py-0.5 rounded-full border tabular-nums ${
|
|
||||||
ts.status === "ok"
|
|
||||||
? ts.latency && ts.latency > 6000
|
|
||||||
? "bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 border-orange-300 dark:border-orange-500/30"
|
|
||||||
: "bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 border-green-300 dark:border-green-500/30"
|
|
||||||
: "bg-red-100 dark:bg-red-900/30 text-red-500 dark:text-red-400 border-red-300 dark:border-red-500/30"
|
|
||||||
}`}
|
|
||||||
title={
|
|
||||||
ts.status === "fail"
|
|
||||||
? ts.error || t("connectivityFail", { name: provider.name, error: "" })
|
|
||||||
: ts.latency && ts.latency > 6000
|
|
||||||
? t("connectivitySlow", { name: provider.name, latency: ts.latency ?? 0 })
|
|
||||||
: t("connectivityOk", { name: provider.name, latency: ts.latency ?? 0 })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{ts.status === "ok" ? `${ts.latency}ms` : "✕"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{/* compact usage summary — sits left of the switch button,
|
|
||||||
mirroring cc-switch's card layout (usage → action buttons) */}
|
|
||||||
{(provider.usageKinds?.length ?? 0) > 0 && (
|
|
||||||
<UsageFooter
|
|
||||||
agent={agent}
|
|
||||||
providerName={provider.name}
|
|
||||||
usageKinds={provider.usageKinds}
|
|
||||||
variant="compact"
|
|
||||||
autoIntervalMinutes={
|
|
||||||
provider.usageConfig?.autoQueryIntervalMinutes
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onSwitchProvider(provider.name);
|
|
||||||
}}
|
|
||||||
className={`px-3 py-1.5 text-sm rounded focus:ring-2 focus:outline-none ${
|
|
||||||
isActive
|
|
||||||
? "bg-green-600 hover:bg-green-700 text-white focus:ring-green-500"
|
|
||||||
: "bg-blue-600 hover:bg-blue-700 text-white focus:ring-blue-500"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isActive ? t("inUse") : t("switchTo")}
|
|
||||||
</button>
|
|
||||||
{agent === "kimi_code" && (
|
|
||||||
<span
|
|
||||||
className="text-sm text-content-muted hover:text-content-primary cursor-help select-none"
|
|
||||||
title={t("switchReloadHint")}
|
|
||||||
aria-label={t("switchReloadHint")}
|
|
||||||
>
|
|
||||||
ⓘ
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* icon button group */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onEdit(provider.name);
|
|
||||||
}}
|
|
||||||
title={t("edit")}
|
|
||||||
className={iconBtn}
|
|
||||||
aria-label={t("edit")}
|
|
||||||
>
|
|
||||||
<Pencil className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onDuplicate(provider.name);
|
|
||||||
}}
|
|
||||||
title={t("copyProvider")}
|
|
||||||
className={iconBtn}
|
|
||||||
aria-label={t("copyProvider")}
|
|
||||||
>
|
|
||||||
<Copy className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleTest(provider);
|
|
||||||
}}
|
|
||||||
disabled={ts?.status === "testing"}
|
|
||||||
title={t("testConnectivity")}
|
|
||||||
className={`${iconBtn} disabled:opacity-50`}
|
|
||||||
aria-label={t("testConnectivity")}
|
|
||||||
>
|
|
||||||
{ts?.status === "testing" ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Activity className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onConfigureUsage(provider.name);
|
|
||||||
}}
|
|
||||||
title={t("usageConfigBtn")}
|
|
||||||
className={iconBtn}
|
|
||||||
aria-label={t("usageConfigBtn")}
|
|
||||||
>
|
|
||||||
<BarChart3 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onDelete(provider.name);
|
|
||||||
}}
|
|
||||||
title={t("delete")}
|
|
||||||
className={`${iconBtn} hover:text-red-400 hover:border-red-500/30 hover:bg-red-900/20`}
|
|
||||||
aria-label={t("delete")}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<UsageFooter
|
|
||||||
agent={agent}
|
|
||||||
providerName={provider.name}
|
|
||||||
usageKinds={provider.usageKinds}
|
|
||||||
variant="detail"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -44,11 +44,17 @@ const EFFORT_LABELS: Record<(typeof EFFORTS)[number], TranslationKey> = {
|
|||||||
const FLAG_LABELS: Record<string, { name: TranslationKey; desc: TranslationKey }> = {
|
const FLAG_LABELS: Record<string, { name: TranslationKey; desc: TranslationKey }> = {
|
||||||
"secondary-model": { name: "flagSecondaryModel", desc: "flagSecondaryModelDesc" },
|
"secondary-model": { name: "flagSecondaryModel", desc: "flagSecondaryModelDesc" },
|
||||||
"tool-select": { name: "flagToolSelect", desc: "flagToolSelectDesc" },
|
"tool-select": { name: "flagToolSelect", desc: "flagToolSelectDesc" },
|
||||||
"acp-v2": { name: "flagAcpV2", desc: "flagAcpV2Desc" },
|
|
||||||
persistence_minidb_readmodel: {
|
persistence_minidb_readmodel: {
|
||||||
name: "flagMinidbReadmodel",
|
name: "flagMinidbReadmodel",
|
||||||
desc: "flagMinidbReadmodelDesc",
|
desc: "flagMinidbReadmodelDesc",
|
||||||
},
|
},
|
||||||
|
tower: { name: "flagTower", desc: "flagTowerDesc" },
|
||||||
|
subagent_fork: { name: "flagSubagentFork", desc: "flagSubagentForkDesc" },
|
||||||
|
wait_for: { name: "flagWaitFor", desc: "flagWaitForDesc" },
|
||||||
|
auto_session_title: {
|
||||||
|
name: "flagAutoSessionTitle",
|
||||||
|
desc: "flagAutoSessionTitleDesc",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Validation-error i18n key per engine rule, for the pre-write self-check. */
|
/** Validation-error i18n key per engine rule, for the pre-write self-check. */
|
||||||
|
|||||||
+10
-162
@@ -1,175 +1,23 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { Loader2, RefreshCw } from "lucide-react";
|
import { Loader2, RefreshCw } from "lucide-react";
|
||||||
import { useTranslation } from "../i18n";
|
import { useTranslation } from "../i18n";
|
||||||
import { localizeUsageError, planLabel } from "../lib/usage-display";
|
import { localizeUsageError, planLabel } from "../lib/usage-display";
|
||||||
import type { Agent } from "../types";
|
import type { UsageData, UsageQueryState } from "../hooks/useUsageQuery";
|
||||||
|
|
||||||
interface UsageData {
|
|
||||||
planName?: string | null;
|
|
||||||
remaining?: number | null;
|
|
||||||
total?: number | null;
|
|
||||||
used?: number | null;
|
|
||||||
unit?: string | null;
|
|
||||||
isValid?: boolean | null;
|
|
||||||
resetsAt?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UsageResult {
|
|
||||||
success: boolean;
|
|
||||||
data?: UsageData[] | null;
|
|
||||||
error?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
type UsageStatus = "idle" | "loading" | "success" | "error";
|
|
||||||
|
|
||||||
interface CacheEntry {
|
|
||||||
status: "success" | "error";
|
|
||||||
data: UsageData[];
|
|
||||||
/** Raw error text from Rust; null = transient failure (invoke rejected). */
|
|
||||||
error: string | null;
|
|
||||||
updatedAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Module-level cache shared across mounts: re-entering the list within the
|
|
||||||
// stale TTL shows the last result without firing new requests. Both the
|
|
||||||
// compact (card header) and detail (footer) variants read this same cache.
|
|
||||||
const STALE_TTL_MS = 5 * 60 * 1000;
|
|
||||||
const cache = new Map<string, CacheEntry>();
|
|
||||||
|
|
||||||
// Simple semaphore: at most MAX_CONCURRENT queries in flight at once.
|
|
||||||
const MAX_CONCURRENT = 3;
|
|
||||||
let running = 0;
|
|
||||||
const waiters: Array<() => void> = [];
|
|
||||||
async function acquireSlot(): Promise<void> {
|
|
||||||
if (running >= MAX_CONCURRENT) {
|
|
||||||
await new Promise<void>((resolve) => waiters.push(resolve));
|
|
||||||
}
|
|
||||||
running += 1;
|
|
||||||
}
|
|
||||||
function releaseSlot(): void {
|
|
||||||
running -= 1;
|
|
||||||
waiters.shift()?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UsageFooterProps {
|
interface UsageFooterProps {
|
||||||
agent: Agent;
|
/** Query state lifted to the provider card via useUsageQuery. Both the
|
||||||
providerName: string;
|
* compact (card header) and detail (footer) variants read the same state,
|
||||||
usageKinds?: string[];
|
* so a refresh from either updates both. */
|
||||||
|
usage: UsageQueryState;
|
||||||
/** "detail" (default) renders the multi-line footer; "compact" renders a
|
/** "detail" (default) renders the multi-line footer; "compact" renders a
|
||||||
* one-line summary + last-updated + refresh button for the card header. */
|
* one-line summary + last-updated + refresh button for the card header. */
|
||||||
variant?: "detail" | "compact";
|
variant?: "detail" | "compact";
|
||||||
/** Auto query interval in minutes; only wired on the compact variant so
|
|
||||||
* both variants do not double-fire. 0/undefined = manual only. */
|
|
||||||
autoIntervalMinutes?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UsageFooter({
|
export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) {
|
||||||
agent,
|
|
||||||
providerName,
|
|
||||||
usageKinds,
|
|
||||||
variant = "detail",
|
|
||||||
autoIntervalMinutes,
|
|
||||||
}: UsageFooterProps) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const supported = (usageKinds?.length ?? 0) > 0;
|
const { status, data, error, updatedAt, supported, refresh } = usage;
|
||||||
// Cache key includes the agent so a Kimi Code provider and a Pi provider
|
|
||||||
// with the same name do not clobber each other's cached result.
|
|
||||||
const cacheKey = `${agent}:${providerName}`;
|
|
||||||
|
|
||||||
const [status, setStatus] = useState<UsageStatus>("idle");
|
|
||||||
const [data, setData] = useState<UsageData[]>([]);
|
|
||||||
/** undefined = no error; null = network error; string = Rust error text. */
|
|
||||||
const [error, setError] = useState<string | null | undefined>(undefined);
|
|
||||||
const [updatedAt, setUpdatedAt] = useState<number | null>(null);
|
|
||||||
const [now, setNow] = useState(() => Date.now());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
// Generation counter: stale responses (unmounted / superseded query) are ignored.
|
|
||||||
const genRef = useRef(0);
|
|
||||||
|
|
||||||
const runQuery = useCallback(
|
|
||||||
async (forceRefresh: boolean) => {
|
|
||||||
const gen = ++genRef.current;
|
|
||||||
setStatus("loading");
|
|
||||||
setError(undefined);
|
|
||||||
const finish = (entry: CacheEntry) => {
|
|
||||||
cache.set(cacheKey, entry);
|
|
||||||
if (genRef.current !== gen) return;
|
|
||||||
setStatus(entry.status);
|
|
||||||
setData(entry.data);
|
|
||||||
setError(entry.error);
|
|
||||||
setUpdatedAt(entry.updatedAt);
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
await acquireSlot();
|
|
||||||
let result: UsageResult;
|
|
||||||
try {
|
|
||||||
result = await invoke<UsageResult>("query_provider_usage", {
|
|
||||||
agent,
|
|
||||||
providerName,
|
|
||||||
forceRefresh,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
releaseSlot();
|
|
||||||
}
|
|
||||||
if (genRef.current !== gen) return;
|
|
||||||
if (result.success) {
|
|
||||||
finish({
|
|
||||||
status: "success",
|
|
||||||
data: result.data ?? [],
|
|
||||||
error: null,
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Deterministic failure: keep last good data for ghost display.
|
|
||||||
finish({
|
|
||||||
status: "error",
|
|
||||||
data: cache.get(cacheKey)?.data ?? [],
|
|
||||||
error: result.error ?? "",
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Transient failure (network / timeout): invoke rejected.
|
|
||||||
finish({
|
|
||||||
status: "error",
|
|
||||||
data: cache.get(cacheKey)?.data ?? [],
|
|
||||||
error: null,
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[agent, providerName, cacheKey]
|
|
||||||
);
|
|
||||||
|
|
||||||
// On mount / provider change: serve fresh cache, otherwise query once.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!supported) return;
|
|
||||||
const cached = cache.get(cacheKey);
|
|
||||||
if (cached) {
|
|
||||||
setStatus(cached.status);
|
|
||||||
setData(cached.data);
|
|
||||||
setError(cached.error);
|
|
||||||
setUpdatedAt(cached.updatedAt);
|
|
||||||
if (Date.now() - cached.updatedAt < STALE_TTL_MS) return;
|
|
||||||
}
|
|
||||||
void runQuery(false);
|
|
||||||
}, [supported, cacheKey, runQuery]);
|
|
||||||
|
|
||||||
// Ignore late responses after unmount.
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
genRef.current += 1;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Auto query interval (compact variant only, to avoid double-firing).
|
|
||||||
useEffect(() => {
|
|
||||||
if (variant !== "compact" || !supported) return;
|
|
||||||
const mins = autoIntervalMinutes ?? 0;
|
|
||||||
if (mins <= 0) return;
|
|
||||||
const id = setInterval(() => void runQuery(false), mins * 60_000);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}, [variant, supported, autoIntervalMinutes, runQuery]);
|
|
||||||
|
|
||||||
// Tick relative times once a minute while showing data.
|
// Tick relative times once a minute while showing data.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -226,7 +74,7 @@ export function UsageFooter({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
void runQuery(true);
|
void refresh(true);
|
||||||
}}
|
}}
|
||||||
disabled={status === "loading"}
|
disabled={status === "loading"}
|
||||||
title={t("usageRefresh")}
|
title={t("usageRefresh")}
|
||||||
@@ -344,7 +192,7 @@ export function UsageFooter({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
void runQuery(true);
|
void refresh(true);
|
||||||
}}
|
}}
|
||||||
className="ml-auto shrink-0 px-2 py-0.5 rounded border border-red-300 dark:border-red-500/30 text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
className="ml-auto shrink-0 px-2 py-0.5 rounded border border-red-300 dark:border-red-500/30 text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import type { Agent } from "../types";
|
||||||
|
|
||||||
|
export interface UsageData {
|
||||||
|
planName?: string | null;
|
||||||
|
remaining?: number | null;
|
||||||
|
total?: number | null;
|
||||||
|
used?: number | null;
|
||||||
|
unit?: string | null;
|
||||||
|
isValid?: boolean | null;
|
||||||
|
resetsAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageResult {
|
||||||
|
success: boolean;
|
||||||
|
data?: UsageData[] | null;
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UsageStatus = "idle" | "loading" | "success" | "error";
|
||||||
|
|
||||||
|
export interface CacheEntry {
|
||||||
|
status: "success" | "error";
|
||||||
|
data: UsageData[];
|
||||||
|
/** Raw error text from Rust; null = transient failure (invoke rejected). */
|
||||||
|
error: string | null;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module-level cache shared across mounts: re-entering the list within the
|
||||||
|
// stale TTL shows the last result without firing new requests. Both the
|
||||||
|
// compact (card header) and detail (footer) variants read this same cache.
|
||||||
|
const STALE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
const cache = new Map<string, CacheEntry>();
|
||||||
|
|
||||||
|
// Simple semaphore: at most MAX_CONCURRENT queries in flight at once.
|
||||||
|
const MAX_CONCURRENT = 3;
|
||||||
|
let running = 0;
|
||||||
|
const waiters: Array<() => void> = [];
|
||||||
|
async function acquireSlot(): Promise<void> {
|
||||||
|
if (running >= MAX_CONCURRENT) {
|
||||||
|
await new Promise<void>((resolve) => waiters.push(resolve));
|
||||||
|
}
|
||||||
|
running += 1;
|
||||||
|
}
|
||||||
|
function releaseSlot(): void {
|
||||||
|
running -= 1;
|
||||||
|
waiters.shift()?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageQueryState {
|
||||||
|
status: UsageStatus;
|
||||||
|
data: UsageData[];
|
||||||
|
error: string | null | undefined;
|
||||||
|
updatedAt: number | null;
|
||||||
|
refresh: (force: boolean) => void;
|
||||||
|
supported: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUsageQuery(
|
||||||
|
agent: Agent,
|
||||||
|
providerName: string,
|
||||||
|
usageKinds?: string[],
|
||||||
|
autoIntervalMinutes?: number
|
||||||
|
): UsageQueryState {
|
||||||
|
const supported = (usageKinds?.length ?? 0) > 0;
|
||||||
|
// Cache key includes the agent so a Kimi Code provider and a Pi provider
|
||||||
|
// with the same name do not clobber each other's cached result.
|
||||||
|
const cacheKey = `${agent}:${providerName}`;
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<UsageStatus>("idle");
|
||||||
|
const [data, setData] = useState<UsageData[]>([]);
|
||||||
|
/** undefined = no error; null = network error; string = Rust error text. */
|
||||||
|
const [error, setError] = useState<string | null | undefined>(undefined);
|
||||||
|
const [updatedAt, setUpdatedAt] = useState<number | null>(null);
|
||||||
|
// Generation counter: stale responses (unmounted / superseded query) are ignored.
|
||||||
|
const genRef = useRef(0);
|
||||||
|
|
||||||
|
const runQuery = useCallback(
|
||||||
|
async (forceRefresh: boolean) => {
|
||||||
|
const gen = ++genRef.current;
|
||||||
|
setStatus("loading");
|
||||||
|
setError(undefined);
|
||||||
|
const finish = (entry: CacheEntry) => {
|
||||||
|
cache.set(cacheKey, entry);
|
||||||
|
if (genRef.current !== gen) return;
|
||||||
|
setStatus(entry.status);
|
||||||
|
setData(entry.data);
|
||||||
|
setError(entry.error);
|
||||||
|
setUpdatedAt(entry.updatedAt);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await acquireSlot();
|
||||||
|
let result: UsageResult;
|
||||||
|
try {
|
||||||
|
result = await invoke<UsageResult>("query_provider_usage", {
|
||||||
|
agent,
|
||||||
|
providerName,
|
||||||
|
forceRefresh,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
releaseSlot();
|
||||||
|
}
|
||||||
|
if (genRef.current !== gen) return;
|
||||||
|
if (result.success) {
|
||||||
|
finish({
|
||||||
|
status: "success",
|
||||||
|
data: result.data ?? [],
|
||||||
|
error: null,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Deterministic failure: keep last good data for ghost display.
|
||||||
|
finish({
|
||||||
|
status: "error",
|
||||||
|
data: cache.get(cacheKey)?.data ?? [],
|
||||||
|
error: result.error ?? "",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Transient failure (network / timeout): invoke rejected.
|
||||||
|
finish({
|
||||||
|
status: "error",
|
||||||
|
data: cache.get(cacheKey)?.data ?? [],
|
||||||
|
error: null,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[agent, providerName, cacheKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
// On mount / provider change: serve fresh cache, otherwise query once.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!supported) return;
|
||||||
|
const cached = cache.get(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
setStatus(cached.status);
|
||||||
|
setData(cached.data);
|
||||||
|
setError(cached.error);
|
||||||
|
setUpdatedAt(cached.updatedAt);
|
||||||
|
if (Date.now() - cached.updatedAt < STALE_TTL_MS) return;
|
||||||
|
}
|
||||||
|
void runQuery(false);
|
||||||
|
}, [supported, cacheKey, runQuery]);
|
||||||
|
|
||||||
|
// Ignore late responses after unmount.
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
genRef.current += 1;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto query interval — the hook is called once per provider card and both
|
||||||
|
// variants read the same state, so there is no second mount to double-fire.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!supported) return;
|
||||||
|
const mins = autoIntervalMinutes ?? 0;
|
||||||
|
if (mins <= 0) return;
|
||||||
|
const id = setInterval(() => void runQuery(false), mins * 60_000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [supported, autoIntervalMinutes, runQuery]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
data,
|
||||||
|
error,
|
||||||
|
updatedAt,
|
||||||
|
refresh: runQuery,
|
||||||
|
supported,
|
||||||
|
};
|
||||||
|
}
|
||||||
+9
-2
@@ -74,6 +74,7 @@ export const enTranslations: Record<TranslationKey, string> = {
|
|||||||
kimiOAuthStart: "Start sign-in",
|
kimiOAuthStart: "Start sign-in",
|
||||||
apiSettings: "API Settings",
|
apiSettings: "API Settings",
|
||||||
apiFormat: "API Format",
|
apiFormat: "API Format",
|
||||||
|
apiFormatUnknown: "Unknown format ({type}, kept verbatim)",
|
||||||
authField: "Auth Field",
|
authField: "Auth Field",
|
||||||
apiKey: "API Key",
|
apiKey: "API Key",
|
||||||
getApiKeyLink: "Get API Key",
|
getApiKeyLink: "Get API Key",
|
||||||
@@ -465,10 +466,16 @@ export const enTranslations: Record<TranslationKey, string> = {
|
|||||||
flagSecondaryModelDesc: "Newly spawned subagents bind a second (usually cheaper) model config",
|
flagSecondaryModelDesc: "Newly spawned subagents bind a second (usually cheaper) model config",
|
||||||
flagToolSelect: "Tool Select",
|
flagToolSelect: "Tool Select",
|
||||||
flagToolSelectDesc: "Enable the experimental tool selection feature",
|
flagToolSelectDesc: "Enable the experimental tool selection feature",
|
||||||
flagAcpV2: "ACP v2 Protocol",
|
|
||||||
flagAcpV2Desc: "Enable Agent Communication Protocol v2",
|
|
||||||
flagMinidbReadmodel: "MiniDB Read Model",
|
flagMinidbReadmodel: "MiniDB Read Model",
|
||||||
flagMinidbReadmodelDesc: "Enable the v2 engine MiniDB read-model backend",
|
flagMinidbReadmodelDesc: "Enable the v2 engine MiniDB read-model backend",
|
||||||
|
flagTower: "Tower Mode",
|
||||||
|
flagTowerDesc: "Enable tower mode: coordinate multiple agents on a shared objective (/tower command)",
|
||||||
|
flagSubagentFork: "Subagent Fork Context",
|
||||||
|
flagSubagentForkDesc: "Agent/AgentSwarm tools can start a subagent with a snapshot of the calling agent's history (fork parameter)",
|
||||||
|
flagWaitFor: "WaitFor Tool",
|
||||||
|
flagWaitForDesc: "The agent can wait for background tasks to finish within the current turn",
|
||||||
|
flagAutoSessionTitle: "Auto Session Title",
|
||||||
|
flagAutoSessionTitleDesc: "Generate session titles automatically via the managed chat_title tool",
|
||||||
lockedByEnv: "Locked by env var",
|
lockedByEnv: "Locked by env var",
|
||||||
masterEnvOnHint: "KIMI_CODE_EXPERIMENTAL_FLAG is set: all experimental features are forced on by the env var",
|
masterEnvOnHint: "KIMI_CODE_EXPERIMENTAL_FLAG is set: all experimental features are forced on by the env var",
|
||||||
secondaryModelSection: "Subagent Model (Secondary Model)",
|
secondaryModelSection: "Subagent Model (Secondary Model)",
|
||||||
|
|||||||
+9
-2
@@ -72,6 +72,7 @@ export const zhTranslations = {
|
|||||||
kimiOAuthStart: "开始登录",
|
kimiOAuthStart: "开始登录",
|
||||||
apiSettings: "API 设置",
|
apiSettings: "API 设置",
|
||||||
apiFormat: "API 格式",
|
apiFormat: "API 格式",
|
||||||
|
apiFormatUnknown: "未知格式({type},原样保留)",
|
||||||
authField: "认证字段",
|
authField: "认证字段",
|
||||||
apiKey: "API Key",
|
apiKey: "API Key",
|
||||||
getApiKeyLink: "获取 API Key",
|
getApiKeyLink: "获取 API Key",
|
||||||
@@ -460,10 +461,16 @@ export const zhTranslations = {
|
|||||||
flagSecondaryModelDesc: "子代理派发时绑定第二份(通常更便宜的)模型配置",
|
flagSecondaryModelDesc: "子代理派发时绑定第二份(通常更便宜的)模型配置",
|
||||||
flagToolSelect: "工具选择",
|
flagToolSelect: "工具选择",
|
||||||
flagToolSelectDesc: "启用实验性工具选择功能",
|
flagToolSelectDesc: "启用实验性工具选择功能",
|
||||||
flagAcpV2: "ACP v2 协议",
|
|
||||||
flagAcpV2Desc: "启用 Agent Communication Protocol v2",
|
|
||||||
flagMinidbReadmodel: "MiniDB 读模型",
|
flagMinidbReadmodel: "MiniDB 读模型",
|
||||||
flagMinidbReadmodelDesc: "启用 v2 引擎 MiniDB 读模型后端",
|
flagMinidbReadmodelDesc: "启用 v2 引擎 MiniDB 读模型后端",
|
||||||
|
flagTower: "Tower 模式",
|
||||||
|
flagTowerDesc: "启用 Tower 多智能体协同模式(/tower 命令切换)",
|
||||||
|
flagSubagentFork: "子代理 Fork 上下文",
|
||||||
|
flagSubagentForkDesc: "Agent/AgentSwarm 工具可携带主代理会话快照启动子代理(fork 参数)",
|
||||||
|
flagWaitFor: "WaitFor 工具",
|
||||||
|
flagWaitForDesc: "代理可在当前回合内等待后台任务完成,而无需结束回合再被唤醒",
|
||||||
|
flagAutoSessionTitle: "自动会话标题",
|
||||||
|
flagAutoSessionTitleDesc: "通过托管 chat_title 工具自动生成会话标题",
|
||||||
lockedByEnv: "被环境变量锁定",
|
lockedByEnv: "被环境变量锁定",
|
||||||
masterEnvOnHint: "KIMI_CODE_EXPERIMENTAL_FLAG 已设置:全部实验功能被环境变量强制开启",
|
masterEnvOnHint: "KIMI_CODE_EXPERIMENTAL_FLAG 已设置:全部实验功能被环境变量强制开启",
|
||||||
secondaryModelSection: "子代理模型(次主力模型)",
|
secondaryModelSection: "子代理模型(次主力模型)",
|
||||||
|
|||||||
+3212
-1612
File diff suppressed because it is too large.
Load diff
+2529
-1195
File diff suppressed because it is too large.
Load diff
@@ -30,15 +30,25 @@ export interface ExperimentalFlagDef {
|
|||||||
envVar: string;
|
envVar: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Known experimental flags (Kimi Code v1 + v2 registries, merged). */
|
/** Known experimental flags — mirrors the kimi-code v2 flag registry
|
||||||
|
* (the per-feature flag.ts files under packages/agent-core-v2/src:
|
||||||
|
* secondary-model, tool-select, persistence_minidb_readmodel, tower,
|
||||||
|
* subagent_fork, wait_for, auto_session_title). `acp-v2` was removed
|
||||||
|
* upstream and is dropped here. */
|
||||||
export const EXPERIMENTAL_FLAGS: ExperimentalFlagDef[] = [
|
export const EXPERIMENTAL_FLAGS: ExperimentalFlagDef[] = [
|
||||||
{ id: "secondary-model", envVar: "KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL" },
|
{ id: "secondary-model", envVar: "KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL" },
|
||||||
{ id: "tool-select", envVar: "KIMI_CODE_EXPERIMENTAL_TOOL_SELECT" },
|
{ id: "tool-select", envVar: "KIMI_CODE_EXPERIMENTAL_TOOL_SELECT" },
|
||||||
{ id: "acp-v2", envVar: "KIMI_CODE_EXPERIMENTAL_ACP_V2" },
|
|
||||||
{
|
{
|
||||||
id: "persistence_minidb_readmodel",
|
id: "persistence_minidb_readmodel",
|
||||||
envVar: "KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL",
|
envVar: "KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL",
|
||||||
},
|
},
|
||||||
|
{ id: "tower", envVar: "KIMI_CODE_EXPERIMENTAL_TOWER" },
|
||||||
|
{ id: "subagent_fork", envVar: "KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK" },
|
||||||
|
{ id: "wait_for", envVar: "KIMI_CODE_EXPERIMENTAL_WAIT_FOR" },
|
||||||
|
{
|
||||||
|
id: "auto_session_title",
|
||||||
|
envVar: "KIMI_CODE_EXPERIMENTAL_AUTO_SESSION_TITLE",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const EXPERIMENTAL_MASTER_ENV = "KIMI_CODE_EXPERIMENTAL_FLAG";
|
export const EXPERIMENTAL_MASTER_ENV = "KIMI_CODE_EXPERIMENTAL_FLAG";
|
||||||
|
|||||||
+9
-1
@@ -6,7 +6,9 @@ export type ProviderType =
|
|||||||
| "openai"
|
| "openai"
|
||||||
| "openai_responses"
|
| "openai_responses"
|
||||||
| "google-genai"
|
| "google-genai"
|
||||||
| "vertexai";
|
| "vertexai"
|
||||||
|
/** Any other string the CLI writes — preserved verbatim on round-trip. */
|
||||||
|
| (string & {});
|
||||||
|
|
||||||
export interface Provider {
|
export interface Provider {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -62,6 +64,12 @@ export interface Config {
|
|||||||
providers: Record<string, Provider>;
|
providers: Record<string, Provider>;
|
||||||
models: Record<string, Model>;
|
models: Record<string, Model>;
|
||||||
raw_other?: unknown;
|
raw_other?: unknown;
|
||||||
|
/**
|
||||||
|
* Top-level section keys captured at import time by the Rust side.
|
||||||
|
* Export drops only baseline keys absent from raw_other (sections the
|
||||||
|
* user removed in the UI); CLI-added sections are preserved.
|
||||||
|
*/
|
||||||
|
imported_section_keys?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in new issue
Block a user