Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcd08eae32 | ||
|
|
35581103ed | ||
|
|
1b80d66239 | ||
|
|
0f421b4e44 |
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "kimiswitch",
|
||||
"private": true,
|
||||
"version": "0.7.17",
|
||||
"version": "0.7.20",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# KimiSwitch v0.7.18
|
||||
|
||||
## 新增
|
||||
|
||||
- **会话管理「一键归档」**:支持按 1 个月前 / 半个月前 / 1 星期前 / 指定日期,一键归档所有最后活跃时间早于所选时间点的会话(一次批量操作,替代逐个选择);结果反馈已归档 / 跳过数量
|
||||
- **归档用量快照落库**:归档瞬间把每个会话的 token 用量与费用按「天 × 模型」粒度快照进 SQLite(`archived_sessions` 表)。之后删除已归档会话(释放磁盘)不再损失历史统计——仪表盘的趋势图、热力图、模型统计、区间合计均会自动合并快照数据,且与现存文件实时统计严格去重(只合并文件已删除的归档会话,绝不双计)
|
||||
|
||||
## 修复
|
||||
|
||||
- models-dev 快照同步至 2026-09-14(7784 模型 / 213 供应商),`public/` 前端静态目录同步;pricing 测试价格断言跟随 DeepSeek 官方调价(deepseek-v4-flash input 0.14→0.15 / output 0.28→0.60)
|
||||
|
||||
## 备注
|
||||
|
||||
- 归档仍是元数据语义(文件原地保留),删除文件才会触发快照合并;用量配置、账单查询等其余功能不受影响
|
||||
@@ -0,0 +1,10 @@
|
||||
# KimiSwitch v0.7.19
|
||||
|
||||
## 适配
|
||||
|
||||
- **适配 kimi-code 0.43.0 实验 flag 镜像(7→5)**:移除已转正的 `auto_session_title` 开关(上游 #3749,AI 会话标题不再需要 flag,首轮对话后自动生成、可在重命名处重新生成)。设置页「实验性 flag」、环境变量探测(`get_experimental_env_status`)、中英文案与测试断言同步清理;上游 0.43.0 注册表现剩 5 个 flag: `wait_for` / `tool-select` / `notify_user` / `tower` / `subagent_fork`
|
||||
- **兼容性核查结论(无需改动)**:0.43.0 的 wire 持久化重建(#3737)不影响用量统计——`usage.record` / `token_counting.measured` / `context.apply_compaction` / `turn.prompt` 记录全部保留(`turn.prompt` 仅新增可选 `turnId` 字段);新增配置项 `loop_control.compaction_max_attempts`(#3750)经现有合并式写入验证保存不丢失;MCP `deferred` 字段(#3667)不涉及本应用管理的配置节
|
||||
|
||||
## 备注
|
||||
|
||||
- 若此前在 `[experimental]` 中显式开启过 `auto_session_title`,残留键会被上游静默忽略,无需手动清理
|
||||
@@ -0,0 +1,23 @@
|
||||
# KimiSwitch v0.7.20
|
||||
|
||||
## 新增
|
||||
|
||||
- **套餐用量阈值预警**:用量配置面板新增「预警阈值 %」(0-100,0 或空 = 关闭)。每次账单查询后,任一百分比用量窗口达到阈值即红色高亮 + ⚠ 标记,低于阈值自动消除;自动查询间隔会周期性重复评估
|
||||
- **Kimi For Coding 加力钱包**:0.43.1 起服务端 quota 响应中的 `boosterWallet`(加力钱包)现在解析展示为一行余额(💰 月度用量 / 总额 / 余额,含币种),与四个用量窗口并列
|
||||
- **供应商凭证环境变量化(`api_key_env`)**:适配 kimi-code 2.0.0 #3762——供应商编辑页「凭证来源」可切换「直接填写 / 环境变量」,密钥不写进 config.toml;导出时与 `api_key` 互斥;账单查询 / 获取模型列表会实时解析环境变量(变量未设置给出明确错误);SQLite 新增列(含自动迁移)
|
||||
- **Kimi Code 版本跟踪器**:设置页新增「Kimi Code 版本」卡片——本机已装版本(`kimi --version` 检测)、上游最新版本、适配状态徽标(绿已适配 / 黄建议升级 KimiSwitch / 橙部分兼容 / 灰未安装),可一键打开上游 Releases
|
||||
- **供应商一键体检**:供应商列表新增「一键体检」按钮——并发探活所有启用供应商的推理端点(`GET /v1/models`)并顺带重新查询账单,每行状态点 + 延迟 tooltip + 顶部汇总。状态判定为三态:账单或探活任一真实通过 = 绿;鉴权失败必红;端点不支持模型列表(404)计中性不误报
|
||||
|
||||
## 设置
|
||||
|
||||
- **压缩重试次数**:Agent 设置新增 `loop_control.compaction_max_attempts`(上游 0.43.0 #3750,默认 5,0/空不写键)
|
||||
- **文件监听开关**:新增 `[watch] enabled` 开关(上游 2.0.1 #3892,2.0.2 起上游默认关闭),并探测 `KIMI_CODE_WATCH` 环境变量
|
||||
|
||||
## 适配
|
||||
|
||||
- **kimi-code 2.0.2 已适配确认**:wire 记录格式、实验 flag(5 个)、config 模式均无变化;`api_key_env` / `[watch]` / `[database]` 新增配置节经现有合并式写入验证无损往返
|
||||
|
||||
## 备注
|
||||
|
||||
- 版本跟踪器的「最新版本」查询直连 GitHub,受限网络环境下可能显示 `—`(已装版本与状态判定不受影响)
|
||||
- 体检跳过大模型平台类供应商(anthropic / google-genai / vertexai 无 `/v1/models` 路由),显示灰点「未检测」
|
||||
Generated
+1
-1
@@ -1978,7 +1978,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kimiswitch"
|
||||
version = "0.7.17"
|
||||
version = "0.7.20"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "kimiswitch"
|
||||
version = "0.7.17"
|
||||
version = "0.7.20"
|
||||
description = "Kimi Switch - model config manager"
|
||||
authors = ["codingplan.site"]
|
||||
edition = "2021"
|
||||
|
||||
+951
-19
File diff suppressed because it is too large.
Load diff
+607
-63
@@ -1,4 +1,5 @@
|
||||
use chrono::{DateTime, Datelike, Local, TimeZone};
|
||||
use crate::db::{ArchivedSessionSnapshot, DayModelStat, DayStat};
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
@@ -277,6 +278,15 @@ pub struct ActionResponse {
|
||||
pub deleted: Option<bool>,
|
||||
}
|
||||
|
||||
/// Outcome of the bulk "archive everything older than X" command.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BulkArchiveResult {
|
||||
pub archived: usize,
|
||||
pub skipped: usize,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PreviewResult {
|
||||
@@ -853,67 +863,8 @@ fn scan_usage(home: &Path) -> (Vec<UsageRecord>, ScanMeta) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
for line in content.lines() {
|
||||
lines_seen += 1;
|
||||
if !line.contains("\"usage.record\"") { continue; }
|
||||
let obj: serde_json::Value = match serde_json::from_str(line) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if obj.get("type").and_then(|v| v.as_str()) != Some("usage.record") { continue; }
|
||||
let scope = obj.get("usageScope").and_then(|v| v.as_str()).unwrap_or("turn");
|
||||
if scope != "turn" { continue; }
|
||||
|
||||
let model_raw = obj.get("model").and_then(|v| v.as_str()).unwrap_or("unknown").to_string();
|
||||
// Kimi Code writes the internal marker `__secondary__` as the model
|
||||
// key for usage records emitted by subagents bound to the
|
||||
// configured secondary model. The record stays keyed on that
|
||||
// stable marker so historical records keep their semantics if the
|
||||
// secondary config changes later; cost billing follows the
|
||||
// currently configured secondary model, and the record is flagged
|
||||
// is_secondary so the UI shows it as its own "subagent model"
|
||||
// entry.
|
||||
let is_secondary = model_raw == "__secondary__";
|
||||
let from_env = model_raw == "__kimi_env_model__";
|
||||
let price_model = if is_secondary {
|
||||
secondary_alias.clone().unwrap_or_else(|| model_raw.clone())
|
||||
} else {
|
||||
model_raw.clone()
|
||||
};
|
||||
let time = obj.get("time").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let usage = &obj["usage"];
|
||||
let input_other = usage.get("inputOther").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let output = usage.get("output").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let cache_read = usage.get("inputCacheRead").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let cache_create = usage.get("inputCacheCreation").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
|
||||
// Resolve model display
|
||||
let bare = model_raw.rsplit_once('/').map(|x| x.1).unwrap_or(&model_raw);
|
||||
let (cost, est) = cost_for_usage(input_other, output, cache_read, cache_create, &price_model);
|
||||
// Subagent records are inherently estimates: the `__secondary__`
|
||||
// marker carries no model identity, so the cost is flagged as
|
||||
// estimated even when the current secondary config resolves to a
|
||||
// priced listing.
|
||||
let est = est || is_secondary;
|
||||
let (pid, _ch, _ip, _op, _) = match_price(&price_model);
|
||||
|
||||
records.push(UsageRecord {
|
||||
time,
|
||||
model: model_raw.clone(),
|
||||
model_resolved: bare.to_string(),
|
||||
model_display: model_raw.clone(),
|
||||
provider: resolve_provider(&price_model, &alias2prov),
|
||||
from_env,
|
||||
is_secondary,
|
||||
input_other,
|
||||
output,
|
||||
input_cache_read: cache_read,
|
||||
input_cache_creation: cache_create,
|
||||
cost_usd: cost,
|
||||
cost_estimated: est,
|
||||
price_id: pid,
|
||||
});
|
||||
}
|
||||
lines_seen += content.lines().count();
|
||||
records.extend(parse_wire_usage_records(&content, &secondary_alias, &alias2prov));
|
||||
}
|
||||
|
||||
records.sort_by(|a, b| b.time.cmp(&a.time));
|
||||
@@ -926,6 +877,180 @@ fn scan_usage(home: &Path) -> (Vec<UsageRecord>, ScanMeta) {
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse one `wire.jsonl` body into usage records. Extracted from
|
||||
/// `scan_usage` so a single session can be snapshotted without walking the
|
||||
/// whole sessions tree; the record filter chain is unchanged.
|
||||
fn parse_wire_usage_records(
|
||||
content: &str,
|
||||
secondary_alias: &Option<String>,
|
||||
alias2prov: &HashMap<String, String>,
|
||||
) -> Vec<UsageRecord> {
|
||||
let mut records = Vec::new();
|
||||
for line in content.lines() {
|
||||
if !line.contains("\"usage.record\"") { continue; }
|
||||
let obj: serde_json::Value = match serde_json::from_str(line) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if obj.get("type").and_then(|v| v.as_str()) != Some("usage.record") { continue; }
|
||||
let scope = obj.get("usageScope").and_then(|v| v.as_str()).unwrap_or("turn");
|
||||
if scope != "turn" { continue; }
|
||||
|
||||
let model_raw = obj.get("model").and_then(|v| v.as_str()).unwrap_or("unknown").to_string();
|
||||
// Kimi Code writes the internal marker `__secondary__` as the model
|
||||
// key for usage records emitted by subagents bound to the
|
||||
// configured secondary model. The record stays keyed on that
|
||||
// stable marker so historical records keep their semantics if the
|
||||
// secondary config changes later; cost billing follows the
|
||||
// currently configured secondary model, and the record is flagged
|
||||
// is_secondary so the UI shows it as its own "subagent model"
|
||||
// entry.
|
||||
let is_secondary = model_raw == "__secondary__";
|
||||
let from_env = model_raw == "__kimi_env_model__";
|
||||
let price_model = if is_secondary {
|
||||
secondary_alias.clone().unwrap_or_else(|| model_raw.clone())
|
||||
} else {
|
||||
model_raw.clone()
|
||||
};
|
||||
let time = obj.get("time").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let usage = &obj["usage"];
|
||||
let input_other = usage.get("inputOther").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let output = usage.get("output").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let cache_read = usage.get("inputCacheRead").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let cache_create = usage.get("inputCacheCreation").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
|
||||
// Resolve model display
|
||||
let bare = model_raw.rsplit_once('/').map(|x| x.1).unwrap_or(&model_raw);
|
||||
let (cost, est) = cost_for_usage(input_other, output, cache_read, cache_create, &price_model);
|
||||
// Subagent records are inherently estimates: the `__secondary__`
|
||||
// marker carries no model identity, so the cost is flagged as
|
||||
// estimated even when the current secondary config resolves to a
|
||||
// priced listing.
|
||||
let est = est || is_secondary;
|
||||
let (pid, _ch, _ip, _op, _) = match_price(&price_model);
|
||||
|
||||
records.push(UsageRecord {
|
||||
time,
|
||||
model: model_raw.clone(),
|
||||
model_resolved: bare.to_string(),
|
||||
model_display: model_raw.clone(),
|
||||
provider: resolve_provider(&price_model, &alias2prov),
|
||||
from_env,
|
||||
is_secondary,
|
||||
input_other,
|
||||
output,
|
||||
input_cache_read: cache_read,
|
||||
input_cache_creation: cache_create,
|
||||
cost_usd: cost,
|
||||
cost_estimated: est,
|
||||
price_id: pid,
|
||||
});
|
||||
}
|
||||
records
|
||||
}
|
||||
|
||||
/// Aggregate raw usage records into the per-day, per-model snapshot stored
|
||||
/// for an archived session.
|
||||
fn snapshot_from_records(records: &[UsageRecord]) -> Vec<DayStat> {
|
||||
let mut by_day: HashMap<String, (u64, HashMap<String, DayModelStat>)> = HashMap::new();
|
||||
for r in records {
|
||||
let entry = by_day
|
||||
.entry(day_key(r.time))
|
||||
.or_insert_with(|| (local_midnight_ms(r.time, 0), HashMap::new()));
|
||||
let m = entry.1.entry(r.model.clone()).or_insert_with(|| DayModelStat {
|
||||
model: r.model.clone(),
|
||||
requests: 0,
|
||||
input_other: 0,
|
||||
output: 0,
|
||||
input_cache_read: 0,
|
||||
input_cache_creation: 0,
|
||||
cost_usd: 0.0,
|
||||
});
|
||||
m.requests += 1;
|
||||
m.input_other += r.input_other;
|
||||
m.output += r.output;
|
||||
m.input_cache_read += r.input_cache_read;
|
||||
m.input_cache_creation += r.input_cache_creation;
|
||||
m.cost_usd += r.cost_usd;
|
||||
}
|
||||
let mut days: Vec<DayStat> = by_day
|
||||
.into_iter()
|
||||
.map(|(day, (day_start_ms, models))| {
|
||||
let mut models: Vec<DayModelStat> = models.into_values().collect();
|
||||
models.sort_by(|a, b| a.model.cmp(&b.model));
|
||||
DayStat { day, day_start_ms, models }
|
||||
})
|
||||
.collect();
|
||||
days.sort_by(|a, b| a.day.cmp(&b.day));
|
||||
days
|
||||
}
|
||||
|
||||
/// Synthesize usage records from stored archive snapshots. Only snapshots
|
||||
/// whose session directory is gone are synthesized: sessions still on disk
|
||||
/// are covered by the live wire.jsonl scan, and emitting both would double
|
||||
/// count their usage.
|
||||
fn synthesize_snapshot_records(rows: &[ArchivedSessionSnapshot], home: &Path) -> Vec<UsageRecord> {
|
||||
let root = sessions_root(home);
|
||||
let legacy_root = root.join(".kcd-archive");
|
||||
let alias2prov = build_alias_provider_map(home);
|
||||
let secondary_alias = read_secondary_model_alias(home);
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
if root.join(&row.workspace_id).join(&row.session_id).exists() { continue; }
|
||||
if legacy_root.join(&row.workspace_id).join(&row.session_id).exists() { continue; }
|
||||
for day in &row.day_stats {
|
||||
for m in &day.models {
|
||||
let is_secondary = m.model == "__secondary__";
|
||||
let price_model = if is_secondary {
|
||||
secondary_alias.clone().unwrap_or_else(|| m.model.clone())
|
||||
} else {
|
||||
m.model.clone()
|
||||
};
|
||||
let bare = m.model.rsplit_once('/').map(|x| x.1).unwrap_or(&m.model).to_string();
|
||||
out.push(UsageRecord {
|
||||
time: day.day_start_ms,
|
||||
model: m.model.clone(),
|
||||
model_resolved: bare,
|
||||
model_display: m.model.clone(),
|
||||
provider: resolve_provider(&price_model, &alias2prov),
|
||||
from_env: m.model == "__kimi_env_model__",
|
||||
is_secondary,
|
||||
input_other: m.input_other,
|
||||
output: m.output,
|
||||
input_cache_read: m.input_cache_read,
|
||||
input_cache_creation: m.input_cache_creation,
|
||||
cost_usd: m.cost_usd,
|
||||
cost_estimated: true,
|
||||
price_id: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Append the synthesized records for `rows` to a live scan result and
|
||||
/// restore the newest-first ordering `scan_usage` produces.
|
||||
fn merge_snapshot_records(
|
||||
mut records: Vec<UsageRecord>,
|
||||
rows: &[ArchivedSessionSnapshot],
|
||||
home: &Path,
|
||||
) -> Vec<UsageRecord> {
|
||||
// Nothing archived yet: keep the scan result untouched (and skip the
|
||||
// config reads the synthesis would do).
|
||||
if rows.is_empty() { return records; }
|
||||
records.extend(synthesize_snapshot_records(rows, home));
|
||||
records.sort_by(|a, b| b.time.cmp(&a.time));
|
||||
records
|
||||
}
|
||||
|
||||
/// Live scan records plus the stored usage of archived sessions whose files
|
||||
/// have been deleted, so dashboard stats survive session deletion.
|
||||
fn records_with_archive_merge(home: &Path, records: Vec<UsageRecord>) -> Vec<UsageRecord> {
|
||||
let rows = crate::db::list_archived_sessions().unwrap_or_default();
|
||||
merge_snapshot_records(records, &rows, home)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scan cache
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1332,6 +1457,147 @@ fn set_session_archived_meta(session_dir: &Path, archived: bool) -> Result<(), S
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse a `state.json` timestamp into epoch ms. kimi-code writes epoch-ms
|
||||
/// numbers, other builds write ISO-8601 strings — accept both, plus numeric
|
||||
/// strings.
|
||||
fn parse_state_time_ms(value: &serde_json::Value) -> Option<u64> {
|
||||
match value {
|
||||
serde_json::Value::Number(n) => n.as_u64().or_else(|| n.as_f64().map(|f| f.max(0.0) as u64)),
|
||||
serde_json::Value::String(s) => parse_time_string_ms(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_time_string_ms(s: &str) -> Option<u64> {
|
||||
let t = s.trim();
|
||||
if t.is_empty() { return None; }
|
||||
if let Ok(n) = t.parse::<u64>() {
|
||||
// Numeric string: epoch ms when it is already millisecond-sized.
|
||||
return Some(if n >= 1e12 as u64 { n } else { n * 1000 });
|
||||
}
|
||||
if let Ok(dt) = DateTime::parse_from_rfc3339(t) {
|
||||
return Some(dt.timestamp_millis().max(0) as u64);
|
||||
}
|
||||
for fmt in [
|
||||
"%Y-%m-%dT%H:%M:%S%.f",
|
||||
"%Y-%m-%dT%H:%M:%S",
|
||||
"%Y-%m-%d %H:%M:%S%.f",
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
] {
|
||||
if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(t, fmt) {
|
||||
return Some(naive.and_utc().timestamp_millis().max(0) as u64);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Read a session's `state.json` as a JSON object (None when missing or
|
||||
/// unparseable).
|
||||
fn read_state_obj(session_dir: &Path) -> Option<serde_json::Value> {
|
||||
let content = fs::read_to_string(session_dir.join("state.json")).ok()?;
|
||||
let obj: serde_json::Value = serde_json::from_str(&content).ok()?;
|
||||
if obj.is_object() { Some(obj) } else { None }
|
||||
}
|
||||
|
||||
/// Session directory mtime in epoch ms — the fallback when `state.json` has
|
||||
/// no parseable `updatedAt`.
|
||||
fn dir_mtime_ms(dir: &Path) -> Option<u64> {
|
||||
dir.metadata().ok().and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_millis() as u64)
|
||||
}
|
||||
|
||||
/// Usage records of one session: every `wire.jsonl` below its directory
|
||||
/// (agent subdirectories included), with the same blob/task filtering the
|
||||
/// full-tree scan applies.
|
||||
fn parse_session_wire_usage(
|
||||
dir: &Path,
|
||||
secondary_alias: &Option<String>,
|
||||
alias2prov: &HashMap<String, String>,
|
||||
) -> Vec<UsageRecord> {
|
||||
let mut records = Vec::new();
|
||||
for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) {
|
||||
if entry.file_name() != "wire.jsonl" || !entry.file_type().is_file() { continue; }
|
||||
let p = entry.path();
|
||||
if p.to_string_lossy().contains("blobs") || p.to_string_lossy().contains("tasks") { continue; }
|
||||
let Ok(content) = fs::read_to_string(p) else { continue };
|
||||
records.extend(parse_wire_usage_records(&content, secondary_alias, alias2prov));
|
||||
}
|
||||
records
|
||||
}
|
||||
|
||||
/// Bulk-archive every active session whose last activity predates `cutoff_ms`
|
||||
/// (epoch ms) and store a usage snapshot per session in SQLite, so dashboard
|
||||
/// stats keep counting their history after the directories are deleted.
|
||||
/// Sessions already archived, in `.kcd-archive`, or without a readable
|
||||
/// `state.json` are left alone.
|
||||
fn archive_sessions_before_cmd(home: &Path, cutoff_ms: u64) -> BulkArchiveResult {
|
||||
let root = sessions_root(home);
|
||||
let alias2prov = build_alias_provider_map(home);
|
||||
let secondary_alias = read_secondary_model_alias(home);
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis() as u64;
|
||||
let mut result = BulkArchiveResult { archived: 0, skipped: 0, errors: Vec::new() };
|
||||
|
||||
for wid in list_workspace_dirs(home) {
|
||||
for entry in safe_read_dir(&root.join(&wid)) {
|
||||
if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { continue; }
|
||||
let sid = entry.file_name().to_string_lossy().to_string();
|
||||
if !session_re().is_match(&sid) { continue; }
|
||||
let dir = entry.path();
|
||||
let Some(state) = read_state_obj(&dir) else {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
};
|
||||
if state.get("archived").and_then(|v| v.as_bool()).unwrap_or(false) { continue; }
|
||||
let Some(updated_ms) = state
|
||||
.get("updatedAt")
|
||||
.and_then(parse_state_time_ms)
|
||||
.or_else(|| dir_mtime_ms(&dir))
|
||||
else {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
};
|
||||
if updated_ms >= cutoff_ms {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(e) = set_session_archived_meta(&dir, true) {
|
||||
result.errors.push(format!("{wid}/{sid}: {e}"));
|
||||
continue;
|
||||
}
|
||||
let day_stats = snapshot_from_records(&parse_session_wire_usage(
|
||||
&dir, &secondary_alias, &alias2prov,
|
||||
));
|
||||
let mut total_tokens = 0u64;
|
||||
let mut total_cost_usd = 0.0f64;
|
||||
for day in &day_stats {
|
||||
for m in &day.models {
|
||||
total_tokens += m.input_other + m.output + m.input_cache_read + m.input_cache_creation;
|
||||
total_cost_usd += m.cost_usd;
|
||||
}
|
||||
}
|
||||
let snapshot = ArchivedSessionSnapshot {
|
||||
session_id: sid.clone(),
|
||||
workspace_id: wid.clone(),
|
||||
title: state.get("title").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
||||
archived_at_ms: now_ms,
|
||||
updated_at_ms: Some(updated_ms),
|
||||
created_at_ms: state.get("createdAt").and_then(parse_state_time_ms),
|
||||
total_tokens,
|
||||
total_cost_usd,
|
||||
day_stats,
|
||||
};
|
||||
result.archived += 1;
|
||||
if let Err(e) = crate::db::upsert_archived_session(&snapshot) {
|
||||
result.errors.push(format!("{wid}/{sid}: snapshot: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn humanize_workspace(id: &str) -> String {
|
||||
let re = Regex::new(r"^wd_(.+)_[0-9a-fA-F]{8,}$").unwrap();
|
||||
if let Some(caps) = re.captures(id) {
|
||||
@@ -1876,6 +2142,7 @@ pub fn get_summary(home_override: Option<String>, range: Option<String>, refresh
|
||||
let r = range.unwrap_or_else(|| "30d".into());
|
||||
|
||||
let (records, meta) = scan_usage_cached(&home, refresh);
|
||||
let records = records_with_archive_merge(&home, records);
|
||||
let t1 = std::time::Instant::now();
|
||||
let stats = aggregate(&records, &r, now_ms);
|
||||
let all_stats = aggregate(&records, "all", now_ms);
|
||||
@@ -1983,6 +2250,7 @@ fn build_day_detail(date: &str, records: &[UsageRecord]) -> Option<DailyRow> {
|
||||
pub fn get_day_detail(home_override: Option<String>, date: String) -> Option<DailyRow> {
|
||||
let home = resolve_kimi_home(home_override);
|
||||
let (records, _) = scan_usage_cached(&home, false);
|
||||
let records = records_with_archive_merge(&home, records);
|
||||
build_day_detail(&date, &records)
|
||||
}
|
||||
|
||||
@@ -1998,6 +2266,14 @@ pub fn archive_session(home_override: Option<String>, workspace_id: String, sess
|
||||
archive_session_cmd(&home, &workspace_id, &session_id)
|
||||
}
|
||||
|
||||
/// Bulk-archive every active session whose last activity predates `cutoff_ms`
|
||||
/// (epoch ms) across all workspaces, storing a usage snapshot per session.
|
||||
#[tauri::command]
|
||||
pub fn archive_sessions_before(home_override: Option<String>, cutoff_ms: u64) -> BulkArchiveResult {
|
||||
let home = resolve_kimi_home(home_override);
|
||||
archive_sessions_before_cmd(&home, cutoff_ms)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn unarchive_session(home_override: Option<String>, workspace_id: String, session_id: String) -> Result<ActionResponse, String> {
|
||||
let home = resolve_kimi_home(home_override);
|
||||
@@ -2073,8 +2349,8 @@ mod pricing_tests {
|
||||
let (id, _, input, output, est) = match_price("CodingPlan.site/deepseek-v4-flash");
|
||||
assert!(!est, "resolved via models.dev, not estimated");
|
||||
assert_eq!(id, "deepseek/deepseek-v4-flash");
|
||||
assert_eq!(input, 0.14);
|
||||
assert_eq!(output, 0.28);
|
||||
assert_eq!(input, 0.15);
|
||||
assert_eq!(output, 0.6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2432,3 +2708,271 @@ mod range_and_model_totals_tests {
|
||||
assert_eq!(total, 3 * 200);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod archive_snapshot_tests {
|
||||
use super::*;
|
||||
|
||||
const WID: &str = "wd_proj_a1b2c3d4e5f6";
|
||||
const LIVE_SID: &str = "session_11111111-2222-3333-4444-555555555555";
|
||||
const GONE_SID: &str = "session_99999999-8888-7777-6666-555555555555";
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64
|
||||
}
|
||||
|
||||
fn rec(time: u64, model: &str, tokens: (u64, u64, u64, u64), cost: f64) -> UsageRecord {
|
||||
UsageRecord {
|
||||
time,
|
||||
model: model.to_string(),
|
||||
input_other: tokens.0,
|
||||
output: tokens.1,
|
||||
input_cache_read: tokens.2,
|
||||
input_cache_creation: tokens.3,
|
||||
cost_usd: cost,
|
||||
cost_estimated: false,
|
||||
price_id: String::new(),
|
||||
model_resolved: model.rsplit_once('/').map(|x| x.1).unwrap_or(model).to_string(),
|
||||
model_display: model.to_string(),
|
||||
provider: None,
|
||||
from_env: false,
|
||||
is_secondary: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_row(
|
||||
sid: &str,
|
||||
day_start_ms: u64,
|
||||
model: &str,
|
||||
tokens: u64,
|
||||
) -> ArchivedSessionSnapshot {
|
||||
ArchivedSessionSnapshot {
|
||||
session_id: sid.to_string(),
|
||||
workspace_id: WID.to_string(),
|
||||
title: None,
|
||||
archived_at_ms: 0,
|
||||
updated_at_ms: None,
|
||||
created_at_ms: None,
|
||||
total_tokens: tokens,
|
||||
total_cost_usd: 0.0,
|
||||
day_stats: vec![DayStat {
|
||||
day: day_key(day_start_ms),
|
||||
day_start_ms,
|
||||
models: vec![DayModelStat {
|
||||
model: model.to_string(),
|
||||
requests: 2,
|
||||
input_other: tokens,
|
||||
output: 0,
|
||||
input_cache_read: 0,
|
||||
input_cache_creation: 0,
|
||||
cost_usd: 1.5,
|
||||
}],
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn write_state(dir: &Path, updated_ms: u64, archived: bool) {
|
||||
let state = serde_json::json!({
|
||||
"id": dir.file_name().unwrap().to_string_lossy(),
|
||||
"createdAt": updated_ms,
|
||||
"updatedAt": updated_ms,
|
||||
"archived": archived,
|
||||
"title": "archived candidate",
|
||||
});
|
||||
fs::write(dir.join("state.json"), state.to_string()).unwrap();
|
||||
}
|
||||
|
||||
fn read_state(dir: &Path) -> serde_json::Value {
|
||||
serde_json::from_str(&fs::read_to_string(dir.join("state.json")).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_groups_records_by_local_day_and_model() {
|
||||
let now = now_ms();
|
||||
let d1 = local_midnight_ms(now, 2);
|
||||
let d2 = local_midnight_ms(now, 1);
|
||||
let records = vec![
|
||||
rec(d1 + 3_600_000, "openai/gpt-x", (100, 10, 5, 1), 0.5),
|
||||
rec(d1 + 7_200_000, "openai/gpt-x", (200, 20, 0, 0), 0.25),
|
||||
rec(d1 + 60_000, "moonshotai/kimi-k3", (1, 2, 3, 4), 0.01),
|
||||
rec(d2 + 1_000, "openai/gpt-x", (7, 7, 7, 7), 0.07),
|
||||
];
|
||||
let days = snapshot_from_records(&records);
|
||||
assert_eq!(days.len(), 2, "one bucket per local day");
|
||||
|
||||
let first = &days[0];
|
||||
assert_eq!(first.day, day_key(d1));
|
||||
assert_eq!(first.day_start_ms, d1, "day bucket starts at local midnight");
|
||||
assert_eq!(first.models.len(), 2);
|
||||
let gpt = first.models.iter().find(|m| m.model == "openai/gpt-x").unwrap();
|
||||
assert_eq!(gpt.requests, 2);
|
||||
assert_eq!(gpt.input_other, 300);
|
||||
assert_eq!(gpt.output, 30);
|
||||
assert_eq!(gpt.input_cache_read, 5);
|
||||
assert_eq!(gpt.input_cache_creation, 1);
|
||||
assert_eq!(gpt.cost_usd, 0.75);
|
||||
// Deterministic ordering: models sorted by raw model key.
|
||||
assert_eq!(first.models[0].model, "moonshotai/kimi-k3");
|
||||
|
||||
assert_eq!(days[1].day, day_key(d2));
|
||||
assert_eq!(days[1].day_start_ms, d2);
|
||||
assert_eq!(days[1].models.len(), 1);
|
||||
assert_eq!(days[1].models[0].input_other, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthesize_only_covers_sessions_whose_files_are_gone() {
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
let home = td.path().join("home");
|
||||
fs::create_dir_all(home.join("sessions").join(WID).join(LIVE_SID)).unwrap();
|
||||
|
||||
let day_start = 1_700_000_000_000u64;
|
||||
let rows = vec![
|
||||
snapshot_row(LIVE_SID, day_start, "openai/gpt-x", 100),
|
||||
snapshot_row(GONE_SID, day_start, "openai/gpt-x", 200),
|
||||
];
|
||||
let out = synthesize_snapshot_records(&rows, &home);
|
||||
assert_eq!(out.len(), 1, "live session dirs stay with the live scan");
|
||||
|
||||
let r = &out[0];
|
||||
assert_eq!(r.time, day_start);
|
||||
assert_eq!(r.model, "openai/gpt-x");
|
||||
assert_eq!(r.model_resolved, "gpt-x");
|
||||
assert_eq!(r.model_display, "openai/gpt-x");
|
||||
assert_eq!(r.provider.as_deref(), Some("openai"));
|
||||
assert_eq!(r.input_other, 200);
|
||||
assert_eq!(r.cost_usd, 1.5);
|
||||
assert!(r.cost_estimated, "snapshot costs are estimates");
|
||||
assert_eq!(r.price_id, "");
|
||||
assert!(!r.is_secondary);
|
||||
assert!(!r.from_env);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_appends_missing_sessions_newest_first_without_double_counting() {
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
let home = td.path().join("home");
|
||||
fs::create_dir_all(home.join("sessions").join(WID).join(LIVE_SID)).unwrap();
|
||||
|
||||
let now = now_ms();
|
||||
let old_day = local_midnight_ms(now, 5);
|
||||
let new_day = local_midnight_ms(now, 1);
|
||||
let gone_old = "session_22222222-2222-3333-4444-555555555555";
|
||||
let gone_new = "session_33333333-2222-3333-4444-555555555555";
|
||||
let rows = vec![
|
||||
snapshot_row(LIVE_SID, new_day, "openai/gpt-x", 999),
|
||||
snapshot_row(gone_old, old_day, "openai/gpt-x", 10),
|
||||
snapshot_row(gone_new, new_day, "openai/gpt-x", 20),
|
||||
];
|
||||
let live = vec![rec(new_day + 5_000, "openai/gpt-x", (1, 1, 1, 1), 0.01)];
|
||||
let merged = merge_snapshot_records(live, &rows, &home);
|
||||
|
||||
assert_eq!(merged.len(), 3, "live record plus the two missing sessions");
|
||||
assert_eq!(merged[0].time, new_day + 5_000, "newest record first");
|
||||
assert_eq!(merged[1].time, new_day);
|
||||
assert_eq!(merged[2].time, old_day);
|
||||
assert_eq!(merged[1].input_other, 20);
|
||||
assert_eq!(merged[2].input_other, 10);
|
||||
assert!(
|
||||
merged.iter().all(|r| r.input_other != 999),
|
||||
"a live session's stored snapshot must never be synthesized"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_times_parse_epoch_numbers_and_iso_strings() {
|
||||
assert_eq!(
|
||||
parse_state_time_ms(&serde_json::json!(1_700_000_000_000u64)),
|
||||
Some(1_700_000_000_000)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_state_time_ms(&serde_json::json!("1700000000000")),
|
||||
Some(1_700_000_000_000)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_state_time_ms(&serde_json::json!("2025-01-02T03:04:05.000Z")),
|
||||
Some(1_735_787_045_000)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_state_time_ms(&serde_json::json!("2025-01-02T03:04:05")),
|
||||
Some(1_735_787_045_000)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_state_time_ms(&serde_json::json!("2025-01-02 03:04:05")),
|
||||
Some(1_735_787_045_000)
|
||||
);
|
||||
assert_eq!(parse_state_time_ms(&serde_json::json!(null)), None);
|
||||
assert_eq!(parse_state_time_ms(&serde_json::json!("not-a-date")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_archive_flags_old_sessions_and_snapshots_their_usage() {
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
let home = td.path().join("home");
|
||||
// Redirect the SQLite store so the test never touches the user's DB.
|
||||
std::env::set_var("KIMI_SWITCH_DB_PATH", td.path().join("test.db"));
|
||||
|
||||
let ws = home.join("sessions").join(WID);
|
||||
let now = now_ms();
|
||||
let old_ms = now - 60 * 24 * 3600 * 1000;
|
||||
|
||||
let old_dir = ws.join(LIVE_SID);
|
||||
fs::create_dir_all(&old_dir).unwrap();
|
||||
write_state(&old_dir, old_ms, false);
|
||||
let wire = serde_json::json!({
|
||||
"type": "usage.record",
|
||||
"usageScope": "turn",
|
||||
"time": old_ms,
|
||||
"model": "openai/gpt-x",
|
||||
"usage": {
|
||||
"inputOther": 100,
|
||||
"output": 50,
|
||||
"inputCacheRead": 10,
|
||||
"inputCacheCreation": 5,
|
||||
},
|
||||
});
|
||||
fs::write(old_dir.join("wire.jsonl"), format!("{wire}\n")).unwrap();
|
||||
|
||||
let new_sid = "session_44444444-2222-3333-4444-555555555555";
|
||||
let new_dir = ws.join(new_sid);
|
||||
fs::create_dir_all(&new_dir).unwrap();
|
||||
write_state(&new_dir, now - 3_600_000, false);
|
||||
|
||||
let cutoff = now - 30 * 24 * 3600 * 1000;
|
||||
let res = archive_sessions_before_cmd(&home, cutoff);
|
||||
assert_eq!(res.archived, 1, "only the stale session is archived");
|
||||
assert_eq!(res.skipped, 1, "the recent session is reported as skipped");
|
||||
assert!(res.errors.is_empty(), "{:?}", res.errors);
|
||||
|
||||
// Metadata archive happened in place, the recent session is untouched.
|
||||
assert_eq!(read_state(&old_dir)["archived"], true);
|
||||
assert!(read_state(&old_dir)["archivedAt"].as_u64().unwrap() > 0);
|
||||
assert_eq!(read_state(&new_dir)["archived"], false);
|
||||
|
||||
// Usage snapshot landed in SQLite with the session's totals.
|
||||
let rows = crate::db::list_archived_sessions().unwrap();
|
||||
let row = rows.iter().find(|r| r.session_id == LIVE_SID).expect("snapshot row");
|
||||
assert_eq!(row.workspace_id, WID);
|
||||
assert_eq!(row.title.as_deref(), Some("archived candidate"));
|
||||
assert_eq!(row.updated_at_ms, Some(old_ms));
|
||||
assert_eq!(row.total_tokens, 165);
|
||||
assert!(row.total_cost_usd > 0.0);
|
||||
assert_eq!(row.day_stats.len(), 1);
|
||||
assert_eq!(row.day_stats[0].day, day_key(old_ms));
|
||||
assert_eq!(row.day_stats[0].models[0].requests, 1);
|
||||
assert_eq!(row.day_stats[0].models[0].input_other, 100);
|
||||
|
||||
// While the files are still there, the live scan owns the usage.
|
||||
let merged = records_with_archive_merge(&home, vec![]);
|
||||
assert!(merged.is_empty(), "no synthesized records while the dir exists");
|
||||
|
||||
// Once the archived directory is deleted, stats come from the snapshot.
|
||||
fs::remove_dir_all(&old_dir).unwrap();
|
||||
let merged = records_with_archive_merge(&home, vec![]);
|
||||
assert_eq!(merged.len(), 1);
|
||||
assert_eq!(merged[0].input_other, 100);
|
||||
assert_eq!(merged[0].output, 50);
|
||||
assert_eq!(day_key(merged[0].time), day_key(old_ms));
|
||||
}
|
||||
}
|
||||
+146
-12
@@ -10,6 +10,7 @@ use std::path::PathBuf;
|
||||
use anyhow::Context;
|
||||
use indexmap::IndexMap;
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::models::{Agent, Config, Model, Provider, ProviderType};
|
||||
@@ -23,6 +24,13 @@ pub fn kimi_switch_data_dir() -> PathBuf {
|
||||
}
|
||||
|
||||
pub fn db_path() -> PathBuf {
|
||||
// Tests (and power users) can point the store elsewhere; production always
|
||||
// resolves to the default location under the home directory.
|
||||
if let Some(p) = std::env::var_os("KIMI_SWITCH_DB_PATH") {
|
||||
if !p.is_empty() {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
}
|
||||
kimi_switch_data_dir().join("kimi-switch.db")
|
||||
}
|
||||
|
||||
@@ -31,6 +39,10 @@ pub fn db_path() -> PathBuf {
|
||||
/// configuration. Safe to call on every startup — it is a no-op once the new
|
||||
/// directory exists.
|
||||
fn migrate_legacy_data_dir() {
|
||||
// A redirected store (tests) must never touch the real home directory.
|
||||
if std::env::var_os("KIMI_SWITCH_DB_PATH").is_some() {
|
||||
return;
|
||||
}
|
||||
let new_dir = kimi_switch_data_dir();
|
||||
if new_dir.exists() {
|
||||
return;
|
||||
@@ -69,6 +81,7 @@ pub fn init_db() -> DbResult<Connection> {
|
||||
provider_type TEXT NOT NULL,
|
||||
base_url TEXT,
|
||||
api_key TEXT,
|
||||
api_key_env TEXT,
|
||||
env TEXT,
|
||||
note TEXT,
|
||||
official_url TEXT,
|
||||
@@ -106,11 +119,32 @@ pub fn init_db() -> DbResult<Connection> {
|
||||
[],
|
||||
)?;
|
||||
|
||||
// Usage snapshots taken when sessions are bulk-archived: keep the session's
|
||||
// token/cost history queryable after its directory (and wire.jsonl) is
|
||||
// deleted from disk.
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS archived_sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL,
|
||||
title TEXT,
|
||||
archived_at_ms INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER,
|
||||
created_at_ms INTEGER,
|
||||
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_cost_usd REAL NOT NULL DEFAULT 0,
|
||||
day_stats TEXT NOT NULL DEFAULT '[]'
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// Add icon columns for provider icon picker (introduced in v0.3.1).
|
||||
// SQLite has no ADD COLUMN IF NOT EXISTS, so ignore duplicate-column errors.
|
||||
for stmt in [
|
||||
"ALTER TABLE providers ADD COLUMN icon TEXT",
|
||||
"ALTER TABLE providers ADD COLUMN icon_color TEXT",
|
||||
// `api_key_env` (kimi-code 2.0.0+): the name of the environment
|
||||
// variable holding the credential, an alternative to `api_key`.
|
||||
"ALTER TABLE providers ADD COLUMN api_key_env TEXT",
|
||||
] {
|
||||
if let Err(e) = conn.execute(stmt, []) {
|
||||
let msg = e.to_string();
|
||||
@@ -132,28 +166,29 @@ pub fn load_config(agent: &Agent) -> DbResult<Config> {
|
||||
let mut providers = IndexMap::new();
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"SELECT name, provider_type, base_url, api_key, env, note, official_url, managed, enabled, active, icon, icon_color, raw_other
|
||||
"SELECT name, provider_type, base_url, api_key, api_key_env, env, note, official_url, managed, enabled, active, icon, icon_color, raw_other
|
||||
FROM providers WHERE agent = ?1 ORDER BY id",
|
||||
)?;
|
||||
let provider_rows = stmt.query_map(params![agent.as_str()], |row| {
|
||||
let provider_type: String = row.get(1)?;
|
||||
let env_json: Option<String> = row.get(4)?;
|
||||
let raw_json: Option<String> = row.get(12)?;
|
||||
let env_json: Option<String> = row.get(5)?;
|
||||
let raw_json: Option<String> = row.get(13)?;
|
||||
Ok(Provider {
|
||||
name: row.get(0)?,
|
||||
provider_type: provider_type_for_str(&provider_type),
|
||||
base_url: row.get(2)?,
|
||||
api_key: row.get(3)?,
|
||||
api_key_env: row.get(4)?,
|
||||
env: env_json
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default(),
|
||||
note: row.get(5)?,
|
||||
official_url: row.get(6)?,
|
||||
managed: row.get::<_, i32>(7)? != 0,
|
||||
enabled: row.get::<_, i32>(8)? != 0,
|
||||
active: row.get::<_, i32>(9)? != 0,
|
||||
icon: row.get(10)?,
|
||||
icon_color: row.get(11)?,
|
||||
note: row.get(6)?,
|
||||
official_url: row.get(7)?,
|
||||
managed: row.get::<_, i32>(8)? != 0,
|
||||
enabled: row.get::<_, i32>(9)? != 0,
|
||||
active: row.get::<_, i32>(10)? != 0,
|
||||
icon: row.get(11)?,
|
||||
icon_color: row.get(12)?,
|
||||
raw_other: raw_json
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or(Value::Null),
|
||||
@@ -223,8 +258,8 @@ pub fn save_config(agent: &Agent, config: &Config) -> DbResult<()> {
|
||||
{
|
||||
let mut insert_provider = tx.prepare(
|
||||
"INSERT INTO providers
|
||||
(agent, name, provider_type, base_url, api_key, env, note, official_url, managed, enabled, active, icon, icon_color, raw_other)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
|
||||
(agent, name, provider_type, base_url, api_key, api_key_env, env, note, official_url, managed, enabled, active, icon, icon_color, raw_other)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
|
||||
)?;
|
||||
|
||||
for provider in config.providers.values() {
|
||||
@@ -234,6 +269,7 @@ pub fn save_config(agent: &Agent, config: &Config) -> DbResult<()> {
|
||||
provider.provider_type.as_str(),
|
||||
provider.base_url,
|
||||
provider.api_key,
|
||||
provider.api_key_env,
|
||||
serde_json::to_string(&provider.env).ok(),
|
||||
provider.note,
|
||||
provider.official_url,
|
||||
@@ -330,6 +366,104 @@ pub fn delete_setting_pub(key: &str) -> DbResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Archived-session usage snapshots
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Per-model usage totals for one calendar day of an archived session.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DayModelStat {
|
||||
/// Raw model key as written to wire.jsonl (`__secondary__` kept verbatim).
|
||||
pub model: String,
|
||||
pub requests: u64,
|
||||
pub input_other: u64,
|
||||
pub output: u64,
|
||||
pub input_cache_read: u64,
|
||||
pub input_cache_creation: u64,
|
||||
pub cost_usd: f64,
|
||||
}
|
||||
|
||||
/// One day's per-model usage, stored as JSON in `archived_sessions.day_stats`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DayStat {
|
||||
/// Local calendar day, `YYYY-MM-DD`.
|
||||
pub day: String,
|
||||
/// Local midnight of `day`, epoch ms — used as the synthesized record time.
|
||||
pub day_start_ms: u64,
|
||||
pub models: Vec<DayModelStat>,
|
||||
}
|
||||
|
||||
/// Usage snapshot of one session, taken at the moment it is archived.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ArchivedSessionSnapshot {
|
||||
pub session_id: String,
|
||||
pub workspace_id: String,
|
||||
pub title: Option<String>,
|
||||
pub archived_at_ms: u64,
|
||||
pub updated_at_ms: Option<u64>,
|
||||
pub created_at_ms: Option<u64>,
|
||||
pub total_tokens: u64,
|
||||
pub total_cost_usd: f64,
|
||||
pub day_stats: Vec<DayStat>,
|
||||
}
|
||||
|
||||
/// Insert or replace one archived-session snapshot (keyed by session id, which
|
||||
/// is a UUID and never reused).
|
||||
pub fn upsert_archived_session(row: &ArchivedSessionSnapshot) -> DbResult<()> {
|
||||
let conn = init_db()?;
|
||||
let day_stats = serde_json::to_string(&row.day_stats)?;
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO archived_sessions
|
||||
(session_id, workspace_id, title, archived_at_ms, updated_at_ms, created_at_ms, total_tokens, total_cost_usd, day_stats)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
params![
|
||||
row.session_id,
|
||||
row.workspace_id,
|
||||
row.title,
|
||||
row.archived_at_ms as i64,
|
||||
row.updated_at_ms.map(|v| v as i64),
|
||||
row.created_at_ms.map(|v| v as i64),
|
||||
row.total_tokens as i64,
|
||||
row.total_cost_usd,
|
||||
day_stats,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every stored archived-session snapshot. Unreadable `day_stats` JSON degrades
|
||||
/// to an empty breakdown instead of failing the whole list.
|
||||
pub fn list_archived_sessions() -> DbResult<Vec<ArchivedSessionSnapshot>> {
|
||||
let conn = init_db()?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT session_id, workspace_id, title, archived_at_ms, updated_at_ms, created_at_ms,
|
||||
total_tokens, total_cost_usd, day_stats
|
||||
FROM archived_sessions",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let day_stats_json: String = row.get(8)?;
|
||||
Ok(ArchivedSessionSnapshot {
|
||||
session_id: row.get(0)?,
|
||||
workspace_id: row.get(1)?,
|
||||
title: row.get(2)?,
|
||||
archived_at_ms: row.get::<_, i64>(3)? as u64,
|
||||
updated_at_ms: row.get::<_, Option<i64>>(4)?.map(|v| v as u64),
|
||||
created_at_ms: row.get::<_, Option<i64>>(5)?.map(|v| v as u64),
|
||||
total_tokens: row.get::<_, i64>(6)? as u64,
|
||||
total_cost_usd: row.get(7)?,
|
||||
day_stats: serde_json::from_str(&day_stats_json).unwrap_or_default(),
|
||||
})
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
out.push(row?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn provider_type_for_str(s: &str) -> ProviderType {
|
||||
ProviderType::from_kimi_type(s)
|
||||
}
|
||||
@@ -153,6 +153,14 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
|
||||
|
||||
let base_url = table.get("base_url").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
let api_key = table.get("api_key").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
// kimi-code 2.0.0+: `api_key_env` names the environment variable
|
||||
// holding the credential; it is mutually exclusive with `api_key`.
|
||||
// Captured as its own field (not left in raw_other) so export can
|
||||
// enforce the exclusivity and the UI can edit it.
|
||||
let api_key_env = table
|
||||
.get("api_key_env")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let managed = table.contains_key("oauth")
|
||||
|| table.get("managed").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let enabled = table.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
@@ -181,6 +189,7 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
|
||||
rest.remove("type");
|
||||
rest.remove("base_url");
|
||||
rest.remove("api_key");
|
||||
rest.remove("api_key_env");
|
||||
rest.remove("managed");
|
||||
rest.remove("enabled");
|
||||
rest.remove("env");
|
||||
@@ -200,6 +209,7 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
|
||||
provider_type,
|
||||
base_url: base_url.filter(|s| !s.is_empty()),
|
||||
api_key: api_key.filter(|s| !s.is_empty()),
|
||||
api_key_env: api_key_env.filter(|s| !s.is_empty()),
|
||||
env,
|
||||
note: None,
|
||||
official_url: None,
|
||||
@@ -289,11 +299,30 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
|
||||
if let Some(base_url) = provider.base_url.clone().filter(|s| !s.is_empty()) {
|
||||
pt.insert("base_url".to_string(), TomlValue::String(base_url));
|
||||
}
|
||||
// `api_key_env` (kimi-code 2.0.0+) and `api_key` are mutually
|
||||
// exclusive upstream: emitting both makes the CLI reject the provider
|
||||
// block, so the env var name alone is written whenever the provider is
|
||||
// configured that way. A legacy row that still carries the key in
|
||||
// raw_other (imports before the field existed) is used as the source
|
||||
// too, so the setting survives the upgrade.
|
||||
let api_key_env = provider
|
||||
.api_key_env
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
provider
|
||||
.raw_other
|
||||
.get("api_key_env")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.filter(|s| !s.is_empty());
|
||||
// Managed (OAuth) providers persist an empty api_key line so the block
|
||||
// matches the official CLI's provisioned shape (apiKey: ''). A
|
||||
// user-set api_key on a managed provider (api_key outranks OAuth in
|
||||
// the CLI's credential priority) must survive the round-trip.
|
||||
if provider.managed
|
||||
if let Some(env_key) = api_key_env {
|
||||
pt.insert("api_key_env".to_string(), TomlValue::String(env_key));
|
||||
} else if provider.managed
|
||||
&& provider.api_key.as_deref().map_or(true, |s| s.is_empty())
|
||||
{
|
||||
pt.insert("api_key".to_string(), TomlValue::String("".to_string()));
|
||||
@@ -328,10 +357,12 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
|
||||
});
|
||||
pt.insert("oauth".to_string(), oauth);
|
||||
}
|
||||
// Merge remaining raw fields (oauth and env are handled explicitly above).
|
||||
// Merge remaining raw fields (oauth, env and api_key_env are handled
|
||||
// explicitly above).
|
||||
if let TomlValue::Table(mut extra) = json_to_toml(&provider.raw_other).unwrap_or(TomlValue::Table(Table::new())) {
|
||||
extra.remove("oauth");
|
||||
extra.remove("env");
|
||||
extra.remove("api_key_env");
|
||||
// Strip Kimi-Switch-private field: the remembered per-provider
|
||||
// default model is stored in raw_other.default_model and must NOT
|
||||
// leak into the agent's config.toml.
|
||||
@@ -562,6 +593,7 @@ api_key = ""
|
||||
provider_type: ProviderType::Anthropic,
|
||||
base_url: Some("https://fast.cdks.work".to_string()),
|
||||
api_key: Some("sk-test".to_string()),
|
||||
api_key_env: None,
|
||||
env: IndexMap::new(),
|
||||
note: None,
|
||||
official_url: None,
|
||||
@@ -623,6 +655,7 @@ api_key = ""
|
||||
provider_type: ProviderType::Openai,
|
||||
base_url: Some(base_url.to_string()),
|
||||
api_key: Some("sk-test".to_string()),
|
||||
api_key_env: None,
|
||||
env: IndexMap::new(),
|
||||
note: None,
|
||||
official_url: None,
|
||||
@@ -760,6 +793,7 @@ default_effort = "low"
|
||||
provider_type: ProviderType::Anthropic,
|
||||
base_url: Some("https://example.com".to_string()),
|
||||
api_key: Some("sk-test".to_string()),
|
||||
api_key_env: None,
|
||||
env: IndexMap::new(),
|
||||
note: None,
|
||||
official_url: None,
|
||||
@@ -868,6 +902,7 @@ max_context_size = 1048576
|
||||
provider_type: ProviderType::Anthropic,
|
||||
base_url: Some("https://a.example.com".to_string()),
|
||||
api_key: Some("sk-a".to_string()),
|
||||
api_key_env: None,
|
||||
env: IndexMap::new(),
|
||||
note: None,
|
||||
official_url: None,
|
||||
@@ -888,6 +923,7 @@ max_context_size = 1048576
|
||||
provider_type: ProviderType::Openai,
|
||||
base_url: Some("https://b.example.com".to_string()),
|
||||
api_key: Some("sk-b".to_string()),
|
||||
api_key_env: None,
|
||||
env: IndexMap::new(),
|
||||
note: None,
|
||||
official_url: None,
|
||||
@@ -929,6 +965,7 @@ max_context_size = 1048576
|
||||
provider_type: ProviderType::Anthropic,
|
||||
base_url: None,
|
||||
api_key: Some("sk-x".to_string()),
|
||||
api_key_env: None,
|
||||
env: IndexMap::new(),
|
||||
note: None,
|
||||
official_url: None,
|
||||
@@ -974,6 +1011,7 @@ max_context_size = 1048576
|
||||
provider_type: ProviderType::Kimi,
|
||||
base_url: Some("https://api.kimi.com/coding/v1".to_string()),
|
||||
api_key: api_key.map(String::from),
|
||||
api_key_env: None,
|
||||
env: IndexMap::new(),
|
||||
note: None,
|
||||
official_url: None,
|
||||
@@ -1153,4 +1191,112 @@ enabled = true
|
||||
"user-removed section must be dropped on export"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kimi_code_import_extracts_api_key_env() {
|
||||
// kimi-code 2.0.0+: `api_key_env` names the environment variable that
|
||||
// holds the credential. It must become its own Provider field and stop
|
||||
// living in raw_other, so export can enforce the api_key exclusivity.
|
||||
let toml_str = r#"
|
||||
default_model = "p1/m1"
|
||||
|
||||
[providers.p1]
|
||||
type = "openai"
|
||||
base_url = "https://proxy.example.com/v1"
|
||||
api_key_env = "MY_PROXY_KEY"
|
||||
|
||||
[models."p1/m1"]
|
||||
provider = "p1"
|
||||
model = "m1"
|
||||
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.api_key_env.as_deref(), Some("MY_PROXY_KEY"));
|
||||
assert_eq!(provider.api_key, None);
|
||||
assert!(
|
||||
provider.raw_other.get("api_key_env").is_none(),
|
||||
"api_key_env must not stay in raw_other"
|
||||
);
|
||||
|
||||
let exported = config_to_kimi_code(&config, Some(&value));
|
||||
let exported_provider = exported
|
||||
.as_table().unwrap()
|
||||
.get("providers").unwrap()
|
||||
.as_table().unwrap()
|
||||
.get("p1").unwrap()
|
||||
.as_table().unwrap();
|
||||
assert_eq!(
|
||||
exported_provider.get("api_key_env").and_then(|v| v.as_str()),
|
||||
Some("MY_PROXY_KEY")
|
||||
);
|
||||
assert!(
|
||||
!exported_provider.contains_key("api_key"),
|
||||
"api_key and api_key_env are mutually exclusive upstream"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kimi_code_export_api_key_env_wins_over_stored_api_key() {
|
||||
// A provider switched to env-var credentials in the UI must export
|
||||
// ONLY `api_key_env`: writing both keys makes the CLI reject the block.
|
||||
// A legacy raw_other copy of the key acts as the source when the field
|
||||
// is empty (rows saved before the field existed).
|
||||
let make_provider = |api_key: Option<&str>, api_key_env: Option<&str>, raw: Value| Provider {
|
||||
name: "p".to_string(),
|
||||
provider_type: ProviderType::Openai,
|
||||
base_url: Some("https://proxy.example.com/v1".to_string()),
|
||||
api_key: api_key.map(String::from),
|
||||
api_key_env: api_key_env.map(String::from),
|
||||
env: IndexMap::new(),
|
||||
note: None,
|
||||
official_url: None,
|
||||
managed: false,
|
||||
enabled: true,
|
||||
active: false,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
raw_other: raw,
|
||||
usage_kinds: None,
|
||||
usage_config: None,
|
||||
};
|
||||
let export = |p: Provider| {
|
||||
let config = Config {
|
||||
default_model: None,
|
||||
providers: IndexMap::from([("p".to_string(), p)]),
|
||||
models: IndexMap::new(),
|
||||
raw_other: Value::Null,
|
||||
imported_section_keys: Vec::new(),
|
||||
};
|
||||
config_to_kimi_code(&config, None)
|
||||
.as_table().unwrap()
|
||||
.get("providers").unwrap()
|
||||
.as_table().unwrap()
|
||||
.get("p").unwrap()
|
||||
.as_table()
|
||||
.unwrap()
|
||||
.clone()
|
||||
};
|
||||
|
||||
// Both fields set (a DB row saved before the UI cleared the key).
|
||||
let pt = export(make_provider(Some("sk-stale"), Some("MY_PROXY_KEY"), Value::Null));
|
||||
assert_eq!(pt.get("api_key_env").and_then(|v| v.as_str()), Some("MY_PROXY_KEY"));
|
||||
assert!(!pt.contains_key("api_key"), "the stored key must be dropped");
|
||||
|
||||
// Legacy row: only raw_other carries the key.
|
||||
let pt = export(make_provider(
|
||||
Some("sk-stale"),
|
||||
None,
|
||||
serde_json::json!({"api_key_env": "LEGACY_KEY"}),
|
||||
));
|
||||
assert_eq!(pt.get("api_key_env").and_then(|v| v.as_str()), Some("LEGACY_KEY"));
|
||||
assert!(!pt.contains_key("api_key"));
|
||||
|
||||
// No env reference → the stored key is written as before.
|
||||
let pt = export(make_provider(Some("sk-keep"), None, Value::Null));
|
||||
assert_eq!(pt.get("api_key").and_then(|v| v.as_str()), Some("sk-keep"));
|
||||
assert!(!pt.contains_key("api_key_env"));
|
||||
}
|
||||
}
|
||||
@@ -103,10 +103,14 @@ pub fn run() {
|
||||
commands::list_provider_models,
|
||||
commands::test_connectivity,
|
||||
commands::query_provider_usage,
|
||||
commands::health_check_all,
|
||||
commands::debug_log,
|
||||
commands::get_app_setting,
|
||||
commands::set_app_setting,
|
||||
commands::check_for_update,
|
||||
commands::get_kimi_code_version,
|
||||
commands::get_kimi_code_latest,
|
||||
commands::get_kimi_code_version_info,
|
||||
commands::download_update,
|
||||
commands::open_installer,
|
||||
commands::open_external_url,
|
||||
@@ -121,6 +125,7 @@ pub fn run() {
|
||||
dashboard::get_day_detail,
|
||||
dashboard::list_sessions,
|
||||
dashboard::archive_session,
|
||||
dashboard::archive_sessions_before,
|
||||
dashboard::unarchive_session,
|
||||
dashboard::delete_session,
|
||||
dashboard::delete_workspace,
|
||||
|
||||
@@ -107,6 +107,12 @@ pub struct Provider {
|
||||
pub provider_type: ProviderType,
|
||||
pub base_url: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
/// kimi-code 2.0.0+: name of the process environment variable holding the
|
||||
/// credential, as an alternative to a stored `api_key` (the two are
|
||||
/// mutually exclusive upstream — exporting both makes the CLI reject the
|
||||
/// provider block). Only the variable *name* is stored, never its value.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub api_key_env: Option<String>,
|
||||
#[serde(default)]
|
||||
pub env: IndexMap<String, String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -149,6 +155,7 @@ impl PartialEq for Provider {
|
||||
&& self.provider_type == other.provider_type
|
||||
&& self.base_url == other.base_url
|
||||
&& self.api_key == other.api_key
|
||||
&& self.api_key_env == other.api_key_env
|
||||
&& self.env == other.env
|
||||
&& self.note == other.note
|
||||
&& self.official_url == other.official_url
|
||||
@@ -172,6 +179,7 @@ impl std::fmt::Debug for Provider {
|
||||
.field("provider_type", &self.provider_type)
|
||||
.field("base_url", &self.base_url)
|
||||
.field("api_key", &"<redacted>")
|
||||
.field("api_key_env", &self.api_key_env)
|
||||
.field("env", &"<redacted>")
|
||||
.field("managed", &self.managed)
|
||||
.field("enabled", &self.enabled)
|
||||
@@ -285,6 +293,11 @@ pub struct UsageConfig {
|
||||
/// Per-request timeout in seconds; 0/None = default (8s).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub timeout_seconds: Option<u64>,
|
||||
/// 用量预警阈值(百分比,0-100);None/0 = 关闭。判定在前端
|
||||
/// src/hooks/useUsageQuery.ts,Rust 只负责透传——缺了此字段,serde 会在
|
||||
/// 保存时把前端写下的 threshold 静默丢掉。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub threshold: Option<u32>,
|
||||
}
|
||||
|
||||
impl UsageConfig {
|
||||
|
||||
@@ -659,6 +659,7 @@ fn provision_managed_provider(region: KimiRegion, oauth_key: &str) -> Result<(),
|
||||
provider_type: crate::models::ProviderType::Kimi,
|
||||
base_url: None,
|
||||
api_key: None,
|
||||
api_key_env: None,
|
||||
env: IndexMap::new(),
|
||||
note: None,
|
||||
official_url: None,
|
||||
|
||||
@@ -332,6 +332,9 @@ pub fn pi_file_to_config(file: &PiModelsFile) -> Config {
|
||||
provider_type,
|
||||
base_url: pi_provider.base_url.clone().filter(|s| !s.is_empty()),
|
||||
api_key: pi_provider.api_key.clone().filter(|s| !s.is_empty()),
|
||||
// Pi's provider file has no env-var credential reference; the
|
||||
// field stays empty so a Kimi-Code-only setting never leaks here.
|
||||
api_key_env: None,
|
||||
env: IndexMap::new(),
|
||||
note: None,
|
||||
official_url: None,
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
//! "weekly_limit" / "monthly_limit"),`used` = 已用百分比(0-100),`total` = 100,
|
||||
//! `remaining` = 剩余百分比,`resets_at` 为 ISO 8601 字符串。
|
||||
//!
|
||||
//! 例外:Kimi 的加力钱包(`PLAN_BOOSTER_WALLET`)不是百分比窗口而是金额行,
|
||||
//! `used` / `total` / `remaining` 均为货币金额(分换算而来),`unit` 为币种。
|
||||
//!
|
||||
//! 错误通道语义与 balance.rs 一致(Err = 瞬时,Ok(success:false) = 确定性)。
|
||||
|
||||
use super::balance::{get_json, get_json_with_ua, AuthStyle, Fetched};
|
||||
@@ -16,11 +19,17 @@ use super::usage_types::{UsageData, UsageResult};
|
||||
use std::time::Duration;
|
||||
|
||||
// 套餐类 tier id 的唯一来源:所有套餐供应商(Kimi/智谱/MiniMax/OpenCode Go 及
|
||||
// 未来新增)都只用这三个 id。前端 src/lib/usage-display.ts 的 planLabel() 依赖
|
||||
// 未来新增)都只用这四个 id。前端 src/lib/usage-display.ts 的 planLabel() 依赖
|
||||
// 此约定做本地化映射——新增 tier id 时必须同步加映射。
|
||||
const TIER_FIVE_HOUR: &str = "five_hour";
|
||||
const TIER_WEEKLY_LIMIT: &str = "weekly_limit";
|
||||
const TIER_MONTHLY_LIMIT: &str = "monthly_limit";
|
||||
/// Kimi quota 模型的月度代码窗口(kimi-code #3787 后 `limit_month_code`)。
|
||||
const TIER_MONTH_CODE: &str = "month_code";
|
||||
/// Kimi quota 响应里的加力钱包(boosterWallet)。**不是百分比 tier**:这条是
|
||||
/// 金额行,前端 src/lib/usage-display.ts 的 planLabel() 负责本地化,
|
||||
/// UsageFooter 按「金额行」而非配额行渲染(used/total 是钱数,不是配额比例)。
|
||||
const PLAN_BOOSTER_WALLET: &str = "booster_wallet";
|
||||
|
||||
/// 套餐条目的统一构造:按百分比表示用量。
|
||||
fn percent_tier(name: &str, used_percent: f64, resets_at: Option<String>) -> UsageData {
|
||||
@@ -70,8 +79,14 @@ fn parse_f64(value: &serde_json::Value) -> Option<f64> {
|
||||
// GET {base_url}/usages
|
||||
// 默认 https://api.kimi.com/coding/v1/usages
|
||||
// global: https://api.kimi.ai/coding/v1/usages
|
||||
// Response: { limits: [{ detail: { limit, remaining, resetTime } }],
|
||||
// usage: { limit, remaining, resetTime } }
|
||||
// Response(kimi-code #3787 / 0.43.1 起服务端切换为 quota 模型):
|
||||
// { usages: { limit_5h, limit_7d, limit_month_total, limit_month_code:
|
||||
// { used_ratio: 0-1, reset_time?: ISO 8601 } },
|
||||
// boosterWallet: { balanceCents, totalCents, monthlyChargeLimitEnabled,
|
||||
// monthlyChargeLimitCents, monthlyUsedCents, currency },
|
||||
// goods_version }
|
||||
// 旧结构(兼容保留): { limits: [{ detail: { limit, remaining, resetTime } }],
|
||||
// usage: { limit, remaining, resetTime } }
|
||||
|
||||
/// 由 base_url 拼接 usages 查询 URL;base_url 为空/空白时回退大陆默认。
|
||||
/// 纯函数,便于单测。
|
||||
@@ -95,8 +110,9 @@ pub async fn query_kimi_coding(
|
||||
Fetched::Body(body) => {
|
||||
let tiers = parse_kimi_coding(&body);
|
||||
if tiers.is_empty() {
|
||||
// 响应里没有可解析的套餐档位(limits/usage 缺失或字段变了)。
|
||||
// 把原始响应(仅用量数字,无密钥)透出,方便对照接口结构修复。
|
||||
// 响应里没有可解析的套餐档位(quota/usages 与旧 limits/usage
|
||||
// 均缺失或字段变了)。把原始响应(仅用量数字,无密钥)透出,
|
||||
// 方便对照接口结构修复。
|
||||
let preview = serde_json::to_string(&body)
|
||||
.unwrap_or_else(|_| "<unserializable body>".into());
|
||||
let trimmed: String = preview.chars().take(400).collect();
|
||||
@@ -111,6 +127,11 @@ pub async fn query_kimi_coding(
|
||||
}
|
||||
|
||||
fn parse_kimi_coding(body: &serde_json::Value) -> Vec<UsageData> {
|
||||
// 新 quota 模型(kimi-code #3787 / 0.43.1 起服务端下发):优先解析。
|
||||
if let Some(tiers) = parse_kimi_quota(body) {
|
||||
return tiers;
|
||||
}
|
||||
|
||||
let mut tiers = Vec::new();
|
||||
|
||||
// 5 小时窗口限额(优先显示)
|
||||
@@ -130,6 +151,67 @@ fn parse_kimi_coding(body: &serde_json::Value) -> Vec<UsageData> {
|
||||
tiers
|
||||
}
|
||||
|
||||
/// 解析 quota 模型的 `usages` 窗口映射(`limit_5h` / `limit_7d` /
|
||||
/// `limit_month_total` / `limit_month_code`)+ 加力钱包。窗口与钱包都缺失或
|
||||
/// 无可解析条目时返回 None,让调用方回退旧结构。
|
||||
fn parse_kimi_quota(body: &serde_json::Value) -> Option<Vec<UsageData>> {
|
||||
let usages = body.get("usages")?;
|
||||
let mut tiers = Vec::new();
|
||||
for (key, tier) in [
|
||||
("limit_5h", TIER_FIVE_HOUR),
|
||||
("limit_7d", TIER_WEEKLY_LIMIT),
|
||||
("limit_month_total", TIER_MONTHLY_LIMIT),
|
||||
("limit_month_code", TIER_MONTH_CODE),
|
||||
] {
|
||||
let Some(entry) = usages.get(key) else { continue };
|
||||
let Some(ratio) = entry.get("used_ratio").and_then(parse_f64) else {
|
||||
continue;
|
||||
};
|
||||
// used_ratio 规范为 0-1 比率;>1 时视为已是百分数,原样使用。
|
||||
let used_percent = if ratio <= 1.0 { ratio * 100.0 } else { ratio };
|
||||
let resets_at = entry.get("reset_time").and_then(extract_reset_time);
|
||||
tiers.push(percent_tier(tier, used_percent, resets_at));
|
||||
}
|
||||
// 加力钱包排在四个窗口之后(钱包是余额,不是滚动窗口)。
|
||||
if let Some(wallet) = body.get("boosterWallet").and_then(booster_wallet_tier) {
|
||||
tiers.push(wallet);
|
||||
}
|
||||
if tiers.is_empty() { None } else { Some(tiers) }
|
||||
}
|
||||
|
||||
/// 解析加力钱包(boosterWallet)为金额行。字段单位为「分」,统一换算成元/美元。
|
||||
///
|
||||
/// 只在响应含新版 `*Cents` 字段时产出条目:kimi-code #3787 之前的
|
||||
/// `boosterWallet` 只有 `balance`(无币种/精度语义),产出全 0 的伪行只会误导。
|
||||
fn booster_wallet_tier(wallet: &serde_json::Value) -> Option<UsageData> {
|
||||
let cents = |key: &str| wallet.get(key).and_then(parse_f64);
|
||||
let (balance, total, used) = (
|
||||
cents("balanceCents"),
|
||||
cents("totalCents"),
|
||||
cents("monthlyUsedCents"),
|
||||
);
|
||||
if balance.is_none() && total.is_none() && used.is_none() {
|
||||
return None;
|
||||
}
|
||||
// 币种缺失时不猜(前端 formatAmount 会退化为纯数字展示)。
|
||||
let unit = wallet
|
||||
.get("currency")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string);
|
||||
Some(UsageData {
|
||||
plan_name: Some(PLAN_BOOSTER_WALLET.to_string()),
|
||||
remaining: balance.map(|c| c / 100.0),
|
||||
total: total.map(|c| c / 100.0),
|
||||
used: used.map(|c| c / 100.0),
|
||||
unit,
|
||||
is_valid: Some(true),
|
||||
// 钱包没有窗口重置时间。
|
||||
resets_at: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn kimi_limit_tier(name: &str, detail: &serde_json::Value) -> UsageData {
|
||||
let limit = detail.get("limit").and_then(parse_f64).unwrap_or(1.0);
|
||||
let remaining = detail.get("remaining").and_then(parse_f64).unwrap_or(0.0);
|
||||
@@ -459,6 +541,97 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kimi_coding_quota_model_four_windows() {
|
||||
// kimi-code #3787 / 0.43.1 起服务端下发的 quota 模型:used_ratio 为
|
||||
// 0-1 比率,reset_time 为 ISO 8601 字符串。
|
||||
let body = json!({
|
||||
"goods_version": 3,
|
||||
"usages": {
|
||||
"limit_5h": { "used_ratio": 0.6, "reset_time": "2026-09-15T17:00:00Z" },
|
||||
"limit_7d": { "used_ratio": 0.25 },
|
||||
"limit_month_total": { "used_ratio": 0.1, "reset_time": "2026-10-01T00:00:00Z" },
|
||||
"limit_month_code": { "used_ratio": 0.88 }
|
||||
},
|
||||
"boosterWallet": { "balance": 0 }
|
||||
});
|
||||
let tiers = parse_kimi_coding(&body);
|
||||
// 旧版 boosterWallet(只有 balance,无 *Cents)不产出钱包行。
|
||||
assert_eq!(tiers.len(), 4);
|
||||
assert_eq!(tiers[0].plan_name.as_deref(), Some("five_hour"));
|
||||
assert_eq!(tiers[0].used, Some(60.0));
|
||||
assert_eq!(tiers[0].remaining, Some(40.0));
|
||||
assert_eq!(tiers[0].total, Some(100.0));
|
||||
assert_eq!(
|
||||
tiers[0].resets_at.as_deref(),
|
||||
Some("2026-09-15T17:00:00Z")
|
||||
);
|
||||
assert_eq!(tiers[1].plan_name.as_deref(), Some("weekly_limit"));
|
||||
assert_eq!(tiers[1].used, Some(25.0));
|
||||
assert!(tiers[1].resets_at.is_none());
|
||||
assert_eq!(tiers[2].plan_name.as_deref(), Some("monthly_limit"));
|
||||
assert_eq!(tiers[2].used, Some(10.0));
|
||||
assert_eq!(tiers[3].plan_name.as_deref(), Some("month_code"));
|
||||
assert_eq!(tiers[3].used, Some(88.0));
|
||||
assert_eq!(tiers[3].remaining, Some(12.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kimi_coding_quota_appends_booster_wallet_row() {
|
||||
// 加力钱包是金额行(分 → 元/美元),追加在四个窗口之后。
|
||||
let body = json!({
|
||||
"usages": {
|
||||
"limit_5h": { "used_ratio": 0.6 },
|
||||
"limit_7d": { "used_ratio": 0.25 },
|
||||
"limit_month_total": { "used_ratio": 0.1 },
|
||||
"limit_month_code": { "used_ratio": 0.88 }
|
||||
},
|
||||
"boosterWallet": {
|
||||
"balanceCents": 1234,
|
||||
"totalCents": 5000,
|
||||
"monthlyChargeLimitEnabled": true,
|
||||
"monthlyChargeLimitCents": 2000,
|
||||
"monthlyUsedCents": 800,
|
||||
"currency": "USD"
|
||||
}
|
||||
});
|
||||
let tiers = parse_kimi_coding(&body);
|
||||
assert_eq!(tiers.len(), 5);
|
||||
assert_eq!(tiers[0].plan_name.as_deref(), Some("five_hour"));
|
||||
let wallet = &tiers[4];
|
||||
assert_eq!(wallet.plan_name.as_deref(), Some("booster_wallet"));
|
||||
assert_eq!(wallet.used, Some(8.0));
|
||||
assert_eq!(wallet.total, Some(50.0));
|
||||
assert_eq!(wallet.remaining, Some(12.34));
|
||||
assert_eq!(wallet.unit.as_deref(), Some("USD"));
|
||||
assert_eq!(wallet.is_valid, Some(true));
|
||||
assert!(wallet.resets_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kimi_coding_quota_ratio_above_one_is_percent() {
|
||||
// 防御:>1 的 used_ratio 视为已是百分数,不再乘 100。
|
||||
let body = json!({
|
||||
"usages": { "limit_5h": { "used_ratio": 42.0 } }
|
||||
});
|
||||
let tiers = parse_kimi_coding(&body);
|
||||
assert_eq!(tiers.len(), 1);
|
||||
assert_eq!(tiers[0].used, Some(42.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kimi_coding_quota_missing_windows_falls_back_to_legacy() {
|
||||
// usages 存在但没有任何可解析条目 → 回退旧结构解析。
|
||||
let body = json!({
|
||||
"usages": { "limit_5h": {} },
|
||||
"usage": { "limit": 1000, "remaining": 900, "resetTime": "2026-08-01T00:00:00Z" }
|
||||
});
|
||||
let tiers = parse_kimi_coding(&body);
|
||||
assert_eq!(tiers.len(), 1);
|
||||
assert_eq!(tiers[0].plan_name.as_deref(), Some("weekly_limit"));
|
||||
assert_eq!(tiers[0].used, Some(10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kimi_coding_reset_time_seconds_vs_millis() {
|
||||
// 秒级时间戳自动 ×1000
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
pub mod balance;
|
||||
pub mod coding_plan;
|
||||
pub mod probe;
|
||||
pub mod usage_types;
|
||||
|
||||
pub use usage_types::{UsageData, UsageResult};
|
||||
@@ -336,6 +337,7 @@ mod tests {
|
||||
user_id: uid.map(str::to_string),
|
||||
auto_query_interval_minutes: None,
|
||||
timeout_seconds: None,
|
||||
threshold: None,
|
||||
};
|
||||
// 缺 config / 缺任一项 / 空字符串 → None(确定性配置错误)
|
||||
assert_eq!(newapi_creds(None), None);
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//! 供应商推理端点探活(「一键体检」的第二步)。
|
||||
//!
|
||||
//! GET `{base_url}/v1/models`,带 `Authorization: Bearer <api_key>`。
|
||||
//! 2xx 且响应体是合法 JSON 即算通过(不校验内容结构,只验证端点能应答);
|
||||
//! 其余状态码 / 网络错误归类成确定性错误文案。
|
||||
//!
|
||||
//! 错误文案与 balance.rs 一致用英文短句,前端 localizeHealthError 负责
|
||||
//! 双语展示(见 src/hooks/useHealthCheck.ts)。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
/// 探活请求超时。体检要快速出结果、不卡住 UI,比账单查询的 8s 更短。
|
||||
pub const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// 由 base_url 拼出 `/v1/models` 探测地址。
|
||||
///
|
||||
/// 供应商配置里的 base_url 多数已经带 `/v1` 后缀(如
|
||||
/// `https://api.kimi.com/coding/v1`),直接再拼 `/v1/models` 会得到
|
||||
/// `/v1/v1/models`,所以先剥掉重复后缀;结尾斜杠与首尾空白一并归一。
|
||||
/// 空/纯空白返回 None(调用方直接判失败,不发请求)。纯函数,便于单测。
|
||||
fn models_url(base_url: &str) -> Option<String> {
|
||||
let base = base_url.trim().trim_end_matches('/');
|
||||
if base.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let root = base.strip_suffix("/v1").unwrap_or(base);
|
||||
Some(format!("{root}/v1/models"))
|
||||
}
|
||||
|
||||
/// 探活前置校验:返回最终请求 URL。base_url 缺失、api_key 为空都是确定性
|
||||
/// 配置错误,不发请求即失败(没有 key 时必然 401,直接给出更可操作的结论)。
|
||||
fn validate_probe_input(base_url: &str, api_key: &str) -> Result<String, String> {
|
||||
let Some(url) = models_url(base_url) else {
|
||||
return Err("missing base_url".to_string());
|
||||
};
|
||||
if api_key.trim().is_empty() {
|
||||
return Err("no API key configured".to_string());
|
||||
}
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
/// 探活一个供应商的推理端点。`Ok(())` = 端点正常应答且返回合法 JSON。
|
||||
pub async fn probe_provider(
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<(), String> {
|
||||
let url = validate_probe_input(base_url, api_key)?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(timeout)
|
||||
.build()
|
||||
.map_err(|e| format!("failed to build HTTP client: {e}"))?;
|
||||
|
||||
// 注意:api_key 只允许进请求头,严禁拼进 URL / 日志 / 错误信息。
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("Accept", "application/json")
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if e.is_timeout() {
|
||||
"request timed out".to_string()
|
||||
} else if e.is_connect() {
|
||||
"connection failed (DNS or refused)".to_string()
|
||||
} else {
|
||||
format!("network error: {e}")
|
||||
}
|
||||
})?;
|
||||
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
// 先取完整响应体再解析:读体超时/中断是瞬时错误,与「返回的不是
|
||||
// JSON」区分开(reqwest 的 .json() 会把两者都包成 decode)。
|
||||
let raw = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("failed to read response: {e}"))?;
|
||||
return match serde_json::from_slice::<serde_json::Value>(&raw) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(_) => Err("response is not valid JSON".to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
match status.as_u16() {
|
||||
401 | 403 => Err(format!("Authentication failed (HTTP {status})")),
|
||||
404 => Err(format!("endpoint not found (HTTP {status})")),
|
||||
code => Err(format!("API error (HTTP {code})")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn models_url_dedupes_v1_suffix() {
|
||||
assert_eq!(
|
||||
models_url("https://api.kimi.com/coding/v1").as_deref(),
|
||||
Some("https://api.kimi.com/coding/v1/models")
|
||||
);
|
||||
// 不带 /v1 的裸域补上 /v1
|
||||
assert_eq!(
|
||||
models_url("https://api.deepseek.com").as_deref(),
|
||||
Some("https://api.deepseek.com/v1/models")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn models_url_normalizes_slashes_and_whitespace() {
|
||||
// 结尾斜杠(含多个)与首尾空白都要归一,且不得拼出 /v1/v1
|
||||
assert_eq!(
|
||||
models_url("https://api.kimi.com/coding/v1/").as_deref(),
|
||||
Some("https://api.kimi.com/coding/v1/models")
|
||||
);
|
||||
assert_eq!(
|
||||
models_url(" https://api.deepseek.com/v1/// ").as_deref(),
|
||||
Some("https://api.deepseek.com/v1/models")
|
||||
);
|
||||
assert_eq!(
|
||||
models_url("https://api.deepseek.com/").as_deref(),
|
||||
Some("https://api.deepseek.com/v1/models")
|
||||
);
|
||||
// 非 /v1 的路径后缀原样保留(只有 /v1 需要去重)
|
||||
assert_eq!(
|
||||
models_url("https://api.novita.ai/v3").as_deref(),
|
||||
Some("https://api.novita.ai/v3/v1/models")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn models_url_rejects_empty_base() {
|
||||
assert_eq!(models_url(""), None);
|
||||
assert_eq!(models_url(" "), None);
|
||||
assert_eq!(models_url("/"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_input_validation_rejects_empty_base_and_missing_key() {
|
||||
// 两者都是确定性配置错误,不发请求即返回。
|
||||
assert_eq!(
|
||||
validate_probe_input(" ", "sk-x").unwrap_err(),
|
||||
"missing base_url"
|
||||
);
|
||||
assert_eq!(
|
||||
validate_probe_input("https://example.com", " ").unwrap_err(),
|
||||
"no API key configured"
|
||||
);
|
||||
assert_eq!(
|
||||
validate_probe_input("https://example.com/v1", "sk-x").unwrap(),
|
||||
"https://example.com/v1/models"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"productName": "Kimi Switch",
|
||||
"version": "0.7.17",
|
||||
"version": "0.7.20",
|
||||
"identifier": "com.kimiswitch.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -78,6 +78,10 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
|
||||
update({ background: { ...settings.background, ...patch } });
|
||||
};
|
||||
|
||||
const updateWatch = (patch: Partial<AgentSettings["watch"]>) => {
|
||||
update({ watch: { ...settings.watch, ...patch } });
|
||||
};
|
||||
|
||||
const setRules = (rules: PermissionRule[]) => {
|
||||
update({ permission: { rules } });
|
||||
};
|
||||
@@ -141,6 +145,15 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
|
||||
value={settings.loop_control?.reserved_context_size ?? 50000}
|
||||
onChange={(v) => updateLoopControl({ reserved_context_size: v })}
|
||||
/>
|
||||
{/* kimi-code 0.43.0+ `loop_control.compaction_max_attempts`. The
|
||||
upstream default is 5; an empty field or 0 removes the key instead
|
||||
of writing a literal 0 (see setAgentSettings). */}
|
||||
<NumberField
|
||||
label={t("compactionMaxAttempts")}
|
||||
value={settings.loop_control?.compaction_max_attempts ?? 5}
|
||||
onChange={(v) => updateLoopControl({ compaction_max_attempts: v })}
|
||||
/>
|
||||
<p className="text-xs text-content-muted">{t("compactionMaxAttemptsHint")}</p>
|
||||
</Card>
|
||||
|
||||
<Card title={t("backgroundSettings")}>
|
||||
@@ -156,6 +169,20 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title={t("watchSettings")}>
|
||||
{/* Top-level `[watch] enabled` (kimi-code 2.0.1+; off by default since
|
||||
2.0.2). KIMI_CODE_WATCH outranks this config at runtime — the env
|
||||
var is probed by get_experimental_env_status as a non-flag entry
|
||||
but deliberately locks nothing here, the toggle just documents the
|
||||
config value that applies when the env var is unset. */}
|
||||
<Checkbox
|
||||
label={t("watchEnabled")}
|
||||
checked={settings.watch?.enabled ?? false}
|
||||
onChange={(checked) => updateWatch({ enabled: checked })}
|
||||
/>
|
||||
<p className="text-xs text-content-muted">{t("watchEnabledDesc")}</p>
|
||||
</Card>
|
||||
|
||||
<Card title={t("permissionRules")}>
|
||||
{/* kimi-code `[permission] dangerous_command_guard`. The env
|
||||
var KIMI_CODE_DANGEROUS_COMMAND_GUARD (literal "true"/"false")
|
||||
|
||||
@@ -11,6 +11,7 @@ import { AgentSettingsPanel } from "./AgentSettingsPanel";
|
||||
import { KimiOAuthDialog } from "./KimiOAuthDialog";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { IconPicker } from "./IconPicker";
|
||||
import { Segmented } from "./ui/controls";
|
||||
import type { Agent, DiscoveredModel, Model, Provider, ProviderType } from "../types";
|
||||
|
||||
const KNOWN_CAPABILITIES = [
|
||||
@@ -127,6 +128,19 @@ export function ProviderEdit({
|
||||
// Preset this provider was created from (if any) — provides the
|
||||
// "Get API Key" / referral links shown under the key input.
|
||||
const preset = findPresetForProvider(provider);
|
||||
/**
|
||||
* Credential source. `api_key_env` (kimi-code 2.0.0+) names an environment
|
||||
* variable instead of storing the key in config.toml; the two are mutually
|
||||
* exclusive upstream, so the UI is a mode switch and each mode clears the
|
||||
* other field. Derived from the provider itself (no local state) so the
|
||||
* switch follows provider switches and reloads; an empty-but-present value
|
||||
* ("env mode picked, name not typed yet") still reads as env mode.
|
||||
* Only Kimi Code understands `api_key_env` — Pi's provider file has no
|
||||
* equivalent, so the switch and the env input are both gated on the agent
|
||||
* (a Pi provider that somehow carries the key keeps its plain key input).
|
||||
*/
|
||||
const apiKeyEnvSupported = agent === "kimi_code";
|
||||
const apiKeyEnvMode = apiKeyEnvSupported && provider.api_key_env != null;
|
||||
|
||||
useEffect(() => {
|
||||
const def = defaultBaseUrl(agent, provider.provider_type);
|
||||
@@ -322,27 +336,73 @@ export function ProviderEdit({
|
||||
|
||||
{!provider.managed && (
|
||||
<div>
|
||||
{apiKeyEnvSupported && (
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="text-sm text-content-muted">
|
||||
{t("apiKeySource")}
|
||||
</span>
|
||||
<Segmented
|
||||
options={[
|
||||
{ key: "direct", label: t("apiKeySourceDirect") },
|
||||
{ key: "env", label: t("apiKeySourceEnv") },
|
||||
]}
|
||||
value={apiKeyEnvMode ? "env" : "direct"}
|
||||
onChange={(mode) => {
|
||||
// Mutual exclusion: picking a source clears the
|
||||
// other one so export never writes both keys.
|
||||
if (mode === "env") {
|
||||
onChange({
|
||||
...provider,
|
||||
api_key: null,
|
||||
api_key_env: provider.api_key_env ?? "",
|
||||
});
|
||||
} else {
|
||||
onChange({ ...provider, api_key_env: null });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<label htmlFor={apiKeyId} className="block text-sm text-content-muted mb-1.5">
|
||||
{t("apiKey")} <span className="text-red-500">*</span>
|
||||
{apiKeyEnvMode ? t("apiKeyEnvName") : t("apiKey")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
{apiKeyEnvMode ? (
|
||||
<input
|
||||
id={apiKeyId}
|
||||
type={showApiKey ? "text" : "password"}
|
||||
className="w-full bg-input border border-border rounded-lg px-3 py-2 pr-10 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
value={provider.api_key || ""}
|
||||
type="text"
|
||||
className="w-full bg-input border border-border rounded-lg px-3 py-2 text-sm font-mono focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
value={provider.api_key_env ?? ""}
|
||||
placeholder="KIMI_API_KEY"
|
||||
onChange={(e) =>
|
||||
onChange({ ...provider, api_key: e.target.value || null })
|
||||
onChange({ ...provider, api_key_env: e.target.value })
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey((v) => !v)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-content-muted hover:text-content-primary text-xs px-1"
|
||||
>
|
||||
{showApiKey ? t("hide") : t("show")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<input
|
||||
id={apiKeyId}
|
||||
type={showApiKey ? "text" : "password"}
|
||||
className="w-full bg-input border border-border rounded-lg px-3 py-2 pr-10 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
value={provider.api_key || ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...provider, api_key: e.target.value || null })
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey((v) => !v)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-content-muted hover:text-content-primary text-xs px-1"
|
||||
>
|
||||
{showApiKey ? t("hide") : t("show")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{apiKeyEnvMode && (
|
||||
<p className="mt-1.5 text-xs text-content-muted">
|
||||
{t("apiKeyEnvHint")}
|
||||
</p>
|
||||
)}
|
||||
{(() => {
|
||||
const linkUrl = preset?.apiKeyUrl ?? provider.official_url ?? null;
|
||||
if (!linkUrl) return null;
|
||||
|
||||
+133
-23
@@ -1,10 +1,26 @@
|
||||
import { useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { Pencil, Copy, Activity, Loader2, Trash2, BarChart3 } from "lucide-react";
|
||||
import {
|
||||
Pencil,
|
||||
Copy,
|
||||
Activity,
|
||||
Loader2,
|
||||
Trash2,
|
||||
BarChart3,
|
||||
Stethoscope,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "../i18n";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { UsageFooter } from "./UsageFooter";
|
||||
import { useUsageQuery } from "../hooks/useUsageQuery";
|
||||
import {
|
||||
useHealthCheck,
|
||||
localizeHealthError,
|
||||
probeLine,
|
||||
usageLine,
|
||||
healthDotClass,
|
||||
type HealthResult,
|
||||
} from "../hooks/useHealthCheck";
|
||||
import type { Agent, Model, Provider } from "../types";
|
||||
|
||||
interface ConnectivityResult {
|
||||
@@ -40,6 +56,12 @@ interface ProviderCardProps {
|
||||
models: Record<string, Model>;
|
||||
/** Inline connectivity-test state for this provider, or undefined when idle. */
|
||||
ts: TestState | undefined;
|
||||
/** 一键体检结论;undefined = 本轮未出结果(检测中或未检测)。 */
|
||||
health: HealthResult | undefined;
|
||||
/** 体检请求进行中。 */
|
||||
healthRunning: boolean;
|
||||
/** 体检至少跑过一轮(决定是否渲染灰色「未检测」点)。 */
|
||||
healthRan: boolean;
|
||||
onEdit: (name: string) => void;
|
||||
onDelete: (name: string) => void;
|
||||
onDuplicate: (name: string) => void;
|
||||
@@ -54,6 +76,9 @@ function ProviderCard({
|
||||
defaultModel,
|
||||
models,
|
||||
ts,
|
||||
health,
|
||||
healthRunning,
|
||||
healthRan,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
@@ -70,7 +95,8 @@ function ProviderCard({
|
||||
provider.name,
|
||||
provider.usageKinds,
|
||||
provider.usageConfig?.autoQueryIntervalMinutes,
|
||||
provider.usageConfig?.enabled === false
|
||||
provider.usageConfig?.enabled === false,
|
||||
provider.usageConfig?.threshold
|
||||
);
|
||||
const providerModels = Object.values(models).filter(
|
||||
(m) => m.provider === provider.name
|
||||
@@ -80,6 +106,17 @@ function ProviderCard({
|
||||
: null;
|
||||
const isActive = provider.active === true;
|
||||
|
||||
// 一键体检状态点 tooltip:两项检测各自如实展示——探活可能是「不支持模型
|
||||
// 列表」(中性),账单可能是「失败: xxx」,都不掩盖。
|
||||
const healthTip = health
|
||||
? [
|
||||
health.probe ? probeLine(health.probe, health.latencyMs, t) : null,
|
||||
health.usage ? usageLine(health.usage, t) : null,
|
||||
]
|
||||
.filter((s): s is string => s !== null)
|
||||
.join(" · ")
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative flex flex-col gap-3 p-4 rounded-xl border transition-colors cursor-pointer w-full ${
|
||||
@@ -102,6 +139,34 @@ function ProviderCard({
|
||||
<h3 className="font-semibold text-content-primary truncate">
|
||||
{provider.name}
|
||||
</h3>
|
||||
{/* 一键体检状态点:绿=正常、红=失败、灰=未判定(未检测 / 检测中) */}
|
||||
{health ? (
|
||||
<span
|
||||
role="img"
|
||||
className={`w-2.5 h-2.5 shrink-0 rounded-full ${healthDotClass(
|
||||
health.verdict
|
||||
)}`}
|
||||
title={healthTip}
|
||||
aria-label={healthTip}
|
||||
/>
|
||||
) : healthRunning ? (
|
||||
<span
|
||||
className="shrink-0 flex items-center"
|
||||
title={t("healthCheckRunning")}
|
||||
aria-label={t("healthCheckRunning")}
|
||||
>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-content-muted" />
|
||||
</span>
|
||||
) : healthRan ? (
|
||||
<span
|
||||
role="img"
|
||||
className={`w-2.5 h-2.5 shrink-0 rounded-full ${healthDotClass(
|
||||
"neutral"
|
||||
)}`}
|
||||
title={t("healthNotChecked")}
|
||||
aria-label={t("healthNotChecked")}
|
||||
/>
|
||||
) : null}
|
||||
{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")}
|
||||
@@ -273,6 +338,7 @@ export function ProviderList({
|
||||
}: ProviderListProps) {
|
||||
const { t } = useTranslation();
|
||||
const [testState, setTestState] = useState<Record<string, TestState>>({});
|
||||
const health = useHealthCheck(agent);
|
||||
|
||||
// managed(OAuth 登录托管)供应商排在最前;其余保持原有顺序(稳定排序)。
|
||||
const sortedProviders = [...providers].sort(
|
||||
@@ -312,28 +378,69 @@ export function ProviderList({
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<h2 className="font-medium text-content-primary">{t("providers")}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
title={t("addProvider")}
|
||||
className="w-9 h-9 flex items-center justify-center rounded-full bg-orange-500 hover:bg-orange-600 text-white shadow-lg focus:ring-2 focus:ring-orange-400 focus:outline-none transition-colors"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-3 border-b border-border">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<h2 className="font-medium text-content-primary shrink-0">
|
||||
{t("providers")}
|
||||
</h2>
|
||||
{/* 体检汇总:检测中 / 请求失败 / x 正常 / y 失败 */}
|
||||
{(health.running || health.ran || health.error) && (
|
||||
<span
|
||||
className={`text-xs tabular-nums truncate ${
|
||||
health.error ? "text-red-500 dark:text-red-400" : "text-content-muted"
|
||||
}`}
|
||||
title={health.error ? localizeHealthError(health.error, t) : undefined}
|
||||
>
|
||||
{health.error
|
||||
? t("healthRequestFailed", {
|
||||
error: localizeHealthError(health.error, t),
|
||||
})
|
||||
: health.running
|
||||
? t("healthCheckRunning")
|
||||
: t("healthSummary", {
|
||||
ok: health.okCount,
|
||||
fail: health.failCount,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={health.run}
|
||||
disabled={health.running}
|
||||
title={t("healthCheckHint")}
|
||||
aria-label={t("healthCheck")}
|
||||
className="flex items-center gap-1.5 h-9 px-3 rounded-full border border-border text-sm text-content-muted hover:text-content-primary hover:bg-hover-2 disabled:opacity-50 focus:ring-2 focus:ring-blue-500 focus:outline-none transition-colors whitespace-nowrap"
|
||||
>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
</button>
|
||||
{health.running ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Stethoscope className="w-4 h-4" />
|
||||
)}
|
||||
{t("healthCheck")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
title={t("addProvider")}
|
||||
className="w-9 h-9 flex items-center justify-center rounded-full bg-orange-500 hover:bg-orange-600 text-white shadow-lg focus:ring-2 focus:ring-orange-400 focus:outline-none transition-colors"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
@@ -371,6 +478,9 @@ export function ProviderList({
|
||||
defaultModel={defaultModel}
|
||||
models={models}
|
||||
ts={testState[provider.name]}
|
||||
health={health.results[provider.name]}
|
||||
healthRunning={health.running}
|
||||
healthRan={health.ran}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onDuplicate={onDuplicate}
|
||||
|
||||
@@ -5,12 +5,20 @@ import { listen } from "@tauri-apps/api/event";
|
||||
import { useTranslation, type Language } from "../i18n";
|
||||
import { useTheme, type Theme } from "../hooks/useTheme";
|
||||
import type { UpdateInfo } from "../hooks/useUpdateCheck";
|
||||
import {
|
||||
useKimiCodeVersion,
|
||||
isNewerVersion,
|
||||
type KimiCodeStatus,
|
||||
} from "../hooks/useKimiCodeVersion";
|
||||
|
||||
function formatVersion(version: string): string {
|
||||
const v = version.trim();
|
||||
return v.startsWith("v") || v.startsWith("V") ? v : `v${v}`;
|
||||
}
|
||||
|
||||
/** 上游 kimi-code releases 页面。 */
|
||||
const KIMI_CODE_RELEASES_URL = "https://github.com/MoonshotAI/kimi-code/releases";
|
||||
|
||||
interface SettingsModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -30,6 +38,11 @@ export function SettingsModal({
|
||||
}: SettingsModalProps) {
|
||||
const { t, lang, setLang } = useTranslation();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const {
|
||||
info: kimiCodeInfo,
|
||||
checking: kimiCodeChecking,
|
||||
checkNow: checkKimiCode,
|
||||
} = useKimiCodeVersion();
|
||||
const [appVersion, setAppVersion] = useState("");
|
||||
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
|
||||
const [downloadedPath, setDownloadedPath] = useState<string | null>(null);
|
||||
@@ -158,6 +171,47 @@ export function SettingsModal({
|
||||
return d.toLocaleString(lang === "zh" ? "zh-CN" : "en-US");
|
||||
};
|
||||
|
||||
// 状态徽标:绿=已适配 / 黄=建议升级 KimiSwitch / 橙=部分兼容 / 灰=未知或未安装
|
||||
const kimiCodeBadge = (status: KimiCodeStatus) => {
|
||||
switch (status) {
|
||||
case "current":
|
||||
return {
|
||||
label: t("kimiCodeStatusCurrent"),
|
||||
className:
|
||||
"bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 border-green-300 dark:border-green-500/30",
|
||||
};
|
||||
case "outdated":
|
||||
return {
|
||||
label: t("kimiCodeStatusOutdated"),
|
||||
className:
|
||||
"bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 border-yellow-300 dark:border-yellow-500/30",
|
||||
};
|
||||
case "partial":
|
||||
return {
|
||||
label: t("kimiCodeStatusPartial"),
|
||||
className:
|
||||
"bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 border-orange-300 dark:border-orange-500/30",
|
||||
};
|
||||
case "unknown":
|
||||
return {
|
||||
label: t("kimiCodeStatusUnknown"),
|
||||
className: "bg-hover-2 text-content-muted border-border",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
label: t("kimiCodeStatusNotInstalled"),
|
||||
className: "bg-hover-2 text-content-muted border-border",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const kimiCodeUpdateAvailable =
|
||||
!!kimiCodeInfo?.installed &&
|
||||
!!kimiCodeInfo?.latest &&
|
||||
isNewerVersion(kimiCodeInfo.latest, kimiCodeInfo.installed);
|
||||
|
||||
const kimiCodeBadgeInfo = kimiCodeInfo ? kimiCodeBadge(kimiCodeInfo.status) : null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
||||
@@ -362,6 +416,72 @@ export function SettingsModal({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Kimi Code CLI version & compatibility */}
|
||||
<section>
|
||||
<h3 className="mb-3 text-[11px] uppercase tracking-wider text-content-muted">
|
||||
{t("kimiCodeSection")}
|
||||
</h3>
|
||||
<div className="space-y-2 rounded-lg border border-border bg-input p-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-content-muted">{t("kimiCodeInstalled")}</span>
|
||||
<span className="font-medium text-content-primary tabular-nums">
|
||||
{kimiCodeInfo?.installed ? formatVersion(kimiCodeInfo.installed) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-content-muted">{t("kimiCodeLatest")}</span>
|
||||
<span className="font-medium text-content-primary tabular-nums">
|
||||
{kimiCodeInfo?.latest ? formatVersion(kimiCodeInfo.latest) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-content-muted">{t("kimiCodeStatusLabel")}</span>
|
||||
{kimiCodeBadgeInfo ? (
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full border ${kimiCodeBadgeInfo.className}`}
|
||||
>
|
||||
{kimiCodeBadgeInfo.label}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-content-muted">—</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="pt-2 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={checkKimiCode}
|
||||
disabled={kimiCodeChecking}
|
||||
className="px-3 py-1.5 text-sm rounded border border-border hover:bg-hover-2 disabled:opacity-50 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={kimiCodeChecking ? "animate-spin" : ""}
|
||||
>
|
||||
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
|
||||
<polyline points="21 3 21 9 15 9" />
|
||||
</svg>
|
||||
{kimiCodeChecking ? t("checking") : t("kimiCodeCheck")}
|
||||
</button>
|
||||
{kimiCodeUpdateAvailable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openExternal(KIMI_CODE_RELEASES_URL)}
|
||||
className="px-3 py-1.5 text-sm rounded border border-border hover:bg-hover-2 transition-colors"
|
||||
>
|
||||
{t("kimiCodeOpenReleases")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* About */}
|
||||
<section>
|
||||
<h3 className="mb-3 text-[11px] uppercase tracking-wider text-content-muted">
|
||||
|
||||
@@ -48,10 +48,6 @@ const FLAG_LABELS: Record<string, { name: TranslationKey; desc: TranslationKey }
|
||||
notify_user: { name: "flagNotifyUser", desc: "flagNotifyUserDesc" },
|
||||
tower: { name: "flagTower", desc: "flagTowerDesc" },
|
||||
subagent_fork: { name: "flagSubagentFork", desc: "flagSubagentForkDesc" },
|
||||
auto_session_title: {
|
||||
name: "flagAutoSessionTitle",
|
||||
desc: "flagAutoSessionTitleDesc",
|
||||
},
|
||||
};
|
||||
|
||||
/** Validation-error i18n key per engine rule, for the pre-write self-check. */
|
||||
|
||||
@@ -364,7 +364,7 @@ export function UsageConfigModal({ open, agent, provider, onClose, onSave }: Usa
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* timeout + auto query interval */}
|
||||
{/* timeout + auto query interval + alert threshold */}
|
||||
<div className="rounded-lg border border-border px-4 py-4 grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm text-content-muted mb-1.5">{t("usageTimeout")}</label>
|
||||
@@ -394,6 +394,31 @@ export function UsageConfigModal({ open, agent, provider, onClose, onSave }: Usa
|
||||
/>
|
||||
<p className="mt-1 text-xs text-content-muted">{t("usageAutoIntervalHint")}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-content-muted mb-1.5">{t("usageThreshold")} %</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
className="w-32 bg-input border border-border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
value={cfg.threshold ?? ""}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value.trim();
|
||||
// 清空输入框写回 undefined(关闭),而不是 0:两者判定等价,
|
||||
// 但 undefined 会被 serde 的 skip_serializing_if 从 SQLite
|
||||
// JSON 里省掉,不留冗余字段。
|
||||
if (raw === "") {
|
||||
setCfg({ ...cfg, threshold: undefined });
|
||||
return;
|
||||
}
|
||||
const n = parseInt(raw, 10);
|
||||
// 非数字(如 "1e" / "-")不落库,保留上一次的有效值。
|
||||
if (Number.isNaN(n)) return;
|
||||
setCfg({ ...cfg, threshold: Math.min(100, Math.max(0, n)) });
|
||||
}}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-content-muted">{t("usageThresholdHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Loader2, RefreshCw } from "lucide-react";
|
||||
import { useTranslation } from "../i18n";
|
||||
import { formatAmount, localizeUsageError, planLabel } from "../lib/usage-display";
|
||||
import {
|
||||
BOOSTER_WALLET_PLAN,
|
||||
formatAmount,
|
||||
localizeUsageError,
|
||||
planLabel,
|
||||
} from "../lib/usage-display";
|
||||
import { isAlertTier } from "../hooks/useUsageQuery";
|
||||
import type { UsageData, UsageQueryState } from "../hooks/useUsageQuery";
|
||||
|
||||
interface UsageFooterProps {
|
||||
@@ -14,6 +20,20 @@ interface UsageFooterProps {
|
||||
variant?: "detail" | "compact";
|
||||
}
|
||||
|
||||
/**
|
||||
* 金额行 vs 配额行:NewAPI 与 Kimi 加力钱包(booster_wallet)只报金额,其
|
||||
* used/total 是钱数而不是配额比例,必须走 formatAmount 渲染路径。判定只能看
|
||||
* planName —— sub2api 的 USD 行 unit 同样不是 "%",但语义仍是「额度用了百分之
|
||||
* 多少」,按 unit 判定会把它误渲染成余额。
|
||||
*/
|
||||
function isMoneyRow(d: UsageData): boolean {
|
||||
return d.planName === "NewAPI" || d.planName === BOOSTER_WALLET_PLAN;
|
||||
}
|
||||
|
||||
function isPlanRow(d: UsageData): boolean {
|
||||
return !!d.planName && !isMoneyRow(d) && (d.total != null || d.used != null);
|
||||
}
|
||||
|
||||
export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) {
|
||||
const { t } = useTranslation();
|
||||
const { status, data, error, updatedAt, supported, refresh } = usage;
|
||||
@@ -85,10 +105,7 @@ export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) {
|
||||
// A "plan" is a quota tier (used/total, e.g. 5h / weekly limit). Balance
|
||||
// entries carry only remaining + unit — their plan_name is a currency /
|
||||
// brand label and must render as an amount, not a percentage.
|
||||
const isPlan =
|
||||
!!d.planName &&
|
||||
d.planName !== "NewAPI" &&
|
||||
(d.total != null || d.used != null);
|
||||
const isPlan = isPlanRow(d);
|
||||
if (d.isValid === false) {
|
||||
return { icon: "⚡", text: t("usageInvalidKey"), color: "text-red-500 dark:text-red-400" };
|
||||
}
|
||||
@@ -207,13 +224,14 @@ export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) {
|
||||
{data.map((d, i) => {
|
||||
// Same plan-vs-balance rule as compactSummary: balance rows have no
|
||||
// quota fields (total/used), so they render as an amount.
|
||||
const isPlan =
|
||||
!!d.planName &&
|
||||
d.planName !== "NewAPI" &&
|
||||
(d.total != null || d.used != null);
|
||||
const isPlan = isPlanRow(d);
|
||||
const pct = isPlan ? percentOf(d) : null;
|
||||
const color =
|
||||
pct == null
|
||||
// 预警高亮:usage.alert 已按「查询成功」门控,失败时保留的上次数据
|
||||
// 不会被误标红(此处是唯一的高亮来源,随每次查询结果重算)。
|
||||
const rowAlert = usage.alert && isAlertTier(d, usage.threshold);
|
||||
const color = rowAlert
|
||||
? "text-red-500 dark:text-red-400"
|
||||
: pct == null
|
||||
? "text-content-muted"
|
||||
: pct < 70
|
||||
? "text-green-600 dark:text-green-400"
|
||||
@@ -248,6 +266,15 @@ export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) {
|
||||
>
|
||||
{main}
|
||||
</span>
|
||||
{rowAlert && (
|
||||
<span
|
||||
className="shrink-0 text-red-500 dark:text-red-400"
|
||||
title={t("usageThresholdAlert")}
|
||||
aria-label={t("usageThresholdAlert")}
|
||||
>
|
||||
⚠
|
||||
</span>
|
||||
)}
|
||||
{resetText && (
|
||||
<span className="text-content-muted shrink-0">{resetText}</span>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSessions } from "../../hooks/useSessions";
|
||||
import { fmtInt, fmtTime } from "../../lib/dashboard-format";
|
||||
import { useTranslation } from "../../i18n";
|
||||
import type { SessionRow, WorkspaceRow } from "../../types/sessions";
|
||||
import type { BulkArchiveResult, SessionRow, WorkspaceRow } from "../../types/sessions";
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
const v = Number(n) || 0;
|
||||
@@ -23,6 +23,44 @@ function rowKey(row: SessionRow): string {
|
||||
return `${row.workspaceId}/${row.id}`;
|
||||
}
|
||||
|
||||
type ArchiveAllMode = "month" | "half" | "week" | "custom";
|
||||
|
||||
type ArchiveAllState = {
|
||||
mode: ArchiveAllMode;
|
||||
/** YYYY-MM-DD, used when mode is "custom". */
|
||||
date: string;
|
||||
busy: boolean;
|
||||
result: BulkArchiveResult | null;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
function localDateInput(d: Date): string {
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/** Today minus one month — the default value of the custom-date picker. */
|
||||
function defaultCustomDate(): string {
|
||||
const d = new Date();
|
||||
d.setMonth(d.getMonth() - 1);
|
||||
return localDateInput(d);
|
||||
}
|
||||
|
||||
/** Cutoff epoch ms for the selected option (custom dates start at local midnight). */
|
||||
function cutoffFor(state: ArchiveAllState): number {
|
||||
const now = new Date();
|
||||
if (state.mode === "month" || state.mode === "half" || state.mode === "week") {
|
||||
const d = new Date(now);
|
||||
if (state.mode === "month") d.setMonth(d.getMonth() - 1);
|
||||
if (state.mode === "half") d.setDate(d.getDate() - 15);
|
||||
if (state.mode === "week") d.setDate(d.getDate() - 7);
|
||||
return d.getTime();
|
||||
}
|
||||
const [y, m, day] = state.date.split("-").map(Number);
|
||||
return new Date(y || now.getFullYear(), (m || 1) - 1, day || 1, 0, 0, 0, 0).getTime();
|
||||
}
|
||||
|
||||
type ConfirmState =
|
||||
| { mode: "one"; row: SessionRow }
|
||||
| { mode: "bulk"; rows: SessionRow[] }
|
||||
@@ -52,6 +90,7 @@ export function SessionsPage() {
|
||||
refresh,
|
||||
archiveSession,
|
||||
unarchiveSession,
|
||||
archiveBefore,
|
||||
deleteSession,
|
||||
deleteWorkspace,
|
||||
getPreview,
|
||||
@@ -62,6 +101,7 @@ export function SessionsPage() {
|
||||
const [confirm, setConfirm] = useState<ConfirmState>(null);
|
||||
const [confirmBusy, setConfirmBusy] = useState(false);
|
||||
const [preview, setPreview] = useState<PreviewState>(null);
|
||||
const [archiveAll, setArchiveAll] = useState<ArchiveAllState | null>(null);
|
||||
|
||||
const workspaces = data?.workspaces ?? [];
|
||||
const sessions = data?.sessions ?? [];
|
||||
@@ -199,6 +239,30 @@ export function SessionsPage() {
|
||||
0
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!archiveAll) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setArchiveAll((s) => (s && s.busy ? s : null));
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [archiveAll]);
|
||||
|
||||
const runArchiveAll = async () => {
|
||||
if (!archiveAll || archiveAll.busy) return;
|
||||
const cutoffMs = cutoffFor(archiveAll);
|
||||
setArchiveAll((s) => (s ? { ...s, busy: true, result: null, error: null } : s));
|
||||
try {
|
||||
const res = await archiveBefore(cutoffMs);
|
||||
setArchiveAll((s) => (s ? { ...s, busy: false, result: res } : s));
|
||||
setSelected(new Set());
|
||||
} catch (e) {
|
||||
setArchiveAll((s) =>
|
||||
s ? { ...s, busy: false, error: e instanceof Error ? e.message : String(e) } : s
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden p-4 space-y-3">
|
||||
<div className="flex shrink-0 flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
@@ -376,6 +440,23 @@ export function SessionsPage() {
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{status !== "archived" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setArchiveAll({
|
||||
mode: "month",
|
||||
date: defaultCustomDate(),
|
||||
busy: false,
|
||||
result: null,
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
className="px-2 py-1 text-xs rounded border border-border hover:bg-hover-2"
|
||||
>
|
||||
{t("sessionsArchiveAll")}
|
||||
</button>
|
||||
)}
|
||||
<select
|
||||
value={workspace}
|
||||
onChange={(e) => setWorkspace(e.target.value)}
|
||||
@@ -548,6 +629,112 @@ export function SessionsPage() {
|
||||
{data?.home && <>{data.home}</>}
|
||||
</div>
|
||||
|
||||
{/* Bulk archive dialog */}
|
||||
{archiveAll && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="w-full max-w-md rounded-xl border border-border bg-panel shadow-xl">
|
||||
<div className="space-y-3 p-5">
|
||||
<h3 className="text-base font-semibold text-content-primary">
|
||||
{t("archiveAllTitle")}
|
||||
</h3>
|
||||
<p className="text-sm text-content-muted">{t("archiveAllDesc")}</p>
|
||||
<div className="space-y-0.5">
|
||||
{(
|
||||
[
|
||||
["month", t("archiveOptionMonth")],
|
||||
["half", t("archiveOptionHalfMonth")],
|
||||
["week", t("archiveOptionWeek")],
|
||||
["custom", t("archiveOptionCustom")],
|
||||
] as const
|
||||
).map(([mode, label]) => (
|
||||
<label
|
||||
key={mode}
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-content-primary hover:bg-input"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="archiveAllMode"
|
||||
className="h-4 w-4 accent-blue-600"
|
||||
checked={archiveAll.mode === mode}
|
||||
disabled={archiveAll.busy}
|
||||
onChange={() =>
|
||||
setArchiveAll((s) =>
|
||||
s ? { ...s, mode, result: null, error: null } : s
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{archiveAll.mode === "custom" && (
|
||||
<input
|
||||
type="date"
|
||||
value={archiveAll.date}
|
||||
disabled={archiveAll.busy}
|
||||
onChange={(e) =>
|
||||
setArchiveAll((s) => (s ? { ...s, date: e.target.value } : s))
|
||||
}
|
||||
className="bg-input border border-border rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
<div className="rounded-md border border-border bg-input px-3 py-2.5 text-sm text-content-muted">
|
||||
{t("archiveAllConfirm", {
|
||||
date: new Date(cutoffFor(archiveAll)).toLocaleDateString(
|
||||
locale === "zh" ? "zh-CN" : "en-US"
|
||||
),
|
||||
})}
|
||||
</div>
|
||||
{archiveAll.error && (
|
||||
<div className="rounded-md border border-red-500/40 bg-red-900/20 px-3 py-2 text-sm text-red-300">
|
||||
{t("archiveAllFailed")}: {archiveAll.error}
|
||||
</div>
|
||||
)}
|
||||
{archiveAll.result && archiveAll.result.archived === 0 && (
|
||||
<div className="rounded-md border border-border bg-input px-3 py-2 text-sm text-content-muted">
|
||||
{t("archiveAllEmpty")}
|
||||
</div>
|
||||
)}
|
||||
{archiveAll.result && archiveAll.result.archived > 0 && (
|
||||
<div className="rounded-md border border-emerald-500/40 bg-emerald-900/20 px-3 py-2 text-sm text-emerald-300">
|
||||
{t("archiveAllDone", {
|
||||
archived: archiveAll.result.archived,
|
||||
skipped: archiveAll.result.skipped,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{archiveAll.result && archiveAll.result.errors.length > 0 && (
|
||||
<div className="space-y-1 rounded-md border border-red-500/40 bg-red-900/20 px-3 py-2 text-xs text-red-300">
|
||||
{archiveAll.result.errors.slice(0, 3).map((err, i) => (
|
||||
<div key={i} className="break-all">
|
||||
{err}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-border bg-footer px-5 py-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={archiveAll.busy}
|
||||
onClick={() => setArchiveAll(null)}
|
||||
className="px-3 py-1.5 text-sm rounded border border-border hover:bg-hover-2 disabled:opacity-50"
|
||||
>
|
||||
{archiveAll.result ? t("close") : t("cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={archiveAll.busy}
|
||||
onClick={runArchiveAll}
|
||||
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-500 disabled:opacity-50"
|
||||
>
|
||||
{archiveAll.busy ? t("archiveAllRunning") : t("sessionsArchiveAll")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirm dialog */}
|
||||
{confirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
localizeHealthError,
|
||||
probeLine,
|
||||
usageLine,
|
||||
healthDotClass,
|
||||
type CheckResult,
|
||||
} from "./useHealthCheck";
|
||||
|
||||
/**
|
||||
* 只回显 key,便于断言选中的是哪条文案(真实插值由 i18n Provider 负责)。
|
||||
*/
|
||||
const t = ((key: string) => `<${key}>`) as unknown as Parameters<
|
||||
typeof localizeHealthError
|
||||
>[1];
|
||||
|
||||
/** 带插值的假 t:`<key|{a}=1>`,用于断言参数透传。 */
|
||||
const tWithVars = ((key: string, vars?: Record<string, string | number>) =>
|
||||
vars
|
||||
? `<${key}|${Object.entries(vars)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(",")}>`
|
||||
: `<${key}>`) as unknown as Parameters<typeof localizeHealthError>[1];
|
||||
|
||||
describe("localizeHealthError", () => {
|
||||
it("探活错误按前缀映射到对应文案", () => {
|
||||
expect(localizeHealthError("missing base_url", t)).toBe(
|
||||
"<healthErrNoBaseUrl>"
|
||||
);
|
||||
expect(localizeHealthError("no API key configured", t)).toBe(
|
||||
"<healthErrNoKey>"
|
||||
);
|
||||
expect(localizeHealthError("Authentication failed (HTTP 401)", t)).toBe(
|
||||
"<healthErrAuth>"
|
||||
);
|
||||
expect(localizeHealthError("endpoint not found (HTTP 404)", t)).toBe(
|
||||
"<healthErrNotFound>"
|
||||
);
|
||||
expect(localizeHealthError("request timed out", t)).toBe(
|
||||
"<healthErrTimeout>"
|
||||
);
|
||||
expect(
|
||||
localizeHealthError("connection failed (DNS or refused)", t)
|
||||
).toBe("<healthErrUnreachable>");
|
||||
expect(localizeHealthError("response is not valid JSON", t)).toBe(
|
||||
"<healthErrBadJson>"
|
||||
);
|
||||
});
|
||||
|
||||
it("未知错误原样透出(HTTP 状态码等诊断细节)", () => {
|
||||
expect(localizeHealthError("API error (HTTP 502)", t)).toBe(
|
||||
"API error (HTTP 502)"
|
||||
);
|
||||
});
|
||||
|
||||
it("探活 + 账单两段拼接时逐段映射", () => {
|
||||
expect(
|
||||
localizeHealthError(
|
||||
"endpoint not found (HTTP 404); usage query disabled in config panel",
|
||||
t
|
||||
)
|
||||
).toBe("<healthErrNotFound>; <usageErrDisabled>");
|
||||
});
|
||||
|
||||
it("账单链路的报错复用用量错误映射", () => {
|
||||
// commands.rs 的 kinds 循环会把单条错误包成 `{kind}: {msg}`。
|
||||
expect(
|
||||
localizeHealthError(
|
||||
"Authentication failed (HTTP 401); balance:deepseek: Authentication failed (HTTP 401)",
|
||||
t
|
||||
)
|
||||
).toBe("<healthErrAuth>; <usageInvalidKey>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("probeLine", () => {
|
||||
it("通过时带延迟", () => {
|
||||
expect(probeLine({ state: "pass" }, 123, tWithVars)).toBe(
|
||||
"<healthProbeOk|latency=123>"
|
||||
);
|
||||
});
|
||||
|
||||
it("中性(404)说明端点不支持模型列表,而不是报失败", () => {
|
||||
expect(
|
||||
probeLine(
|
||||
{ state: "neutral", error: "endpoint not found (HTTP 404)" },
|
||||
0,
|
||||
tWithVars
|
||||
)
|
||||
).toBe("<healthProbeUnsupported>");
|
||||
});
|
||||
|
||||
it("失败时透出本地化后的原因", () => {
|
||||
expect(
|
||||
probeLine({ state: "fail", error: "request timed out" }, 0, tWithVars)
|
||||
).toBe("<healthProbeFail|error=<healthErrTimeout>>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("usageLine", () => {
|
||||
it("通过 / 失败 / 兜底三态", () => {
|
||||
const pass: CheckResult = { state: "pass" };
|
||||
expect(usageLine(pass, tWithVars)).toBe("<healthUsageOk>");
|
||||
|
||||
expect(
|
||||
usageLine(
|
||||
{ state: "fail", error: "Authentication failed (HTTP 401)" },
|
||||
tWithVars
|
||||
)
|
||||
).toBe("<healthUsageFail|error=<healthErrAuth>>");
|
||||
|
||||
// 账单链路不会产生中性结论,兜底按「未判定」展示
|
||||
expect(usageLine({ state: "neutral" }, tWithVars)).toBe(
|
||||
"<healthNotChecked>"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("healthDotClass", () => {
|
||||
it("绿=通过、红=失败、灰=未判定(中性)", () => {
|
||||
expect(healthDotClass("pass")).toBe("bg-green-500");
|
||||
expect(healthDotClass("fail")).toBe("bg-red-500");
|
||||
expect(healthDotClass("neutral")).toBe("bg-gray-400 dark:bg-gray-500");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Agent } from "../types";
|
||||
import type { TranslationKey } from "../i18n/zh";
|
||||
import { localizeUsageError } from "../lib/usage-display";
|
||||
|
||||
type TranslateFn = (
|
||||
key: TranslationKey,
|
||||
vars?: Record<string, string | number>
|
||||
) => string;
|
||||
|
||||
/** 单项检测的三态结论(mirrors Rust `HealthCheck`)。 */
|
||||
export type HealthCheck = "pass" | "fail" | "neutral";
|
||||
|
||||
/** 单项检测结论(探活 / 账单各一份,mirrors Rust `CheckResult`)。 */
|
||||
export interface CheckResult {
|
||||
state: HealthCheck;
|
||||
/** 失败 / 中性原因的原始文案;通过为 null。 */
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
/** Mirrors the Rust `HealthResult` (camelCase via serde). */
|
||||
export interface HealthResult {
|
||||
providerName: string;
|
||||
/** 总体判定(Rust 侧是唯一事实来源):pass=绿 / fail=红 / neutral=灰。 */
|
||||
verdict: HealthCheck;
|
||||
/** verdict === "pass" 的便捷布尔。 */
|
||||
ok: boolean;
|
||||
/** 失败原因汇总(仅 fail 项,`; ` 分隔);无失败为 null。 */
|
||||
error?: string | null;
|
||||
/** 账单/用量结论;null/undefined = 未执行。 */
|
||||
usage?: CheckResult | null;
|
||||
/** 推理端点探活结论;探活是必做项,恒为 Some。 */
|
||||
probe?: CheckResult | null;
|
||||
/** 探活耗时(毫秒)。 */
|
||||
latencyMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条 Rust 错误文案 → 本地化文案。未知错误原样透出(HTTP 状态码等诊断
|
||||
* 细节)。前缀与 src-tauri/src/services/probe.rs、commands.rs 保持一致。
|
||||
*/
|
||||
function localizeHealthPart(error: string, t: TranslateFn): string {
|
||||
if (error.startsWith("missing base_url")) return t("healthErrNoBaseUrl");
|
||||
if (error.startsWith("no API key configured")) return t("healthErrNoKey");
|
||||
if (error.startsWith("Authentication failed")) return t("healthErrAuth");
|
||||
if (error.startsWith("endpoint not found")) return t("healthErrNotFound");
|
||||
if (error.startsWith("request timed out")) return t("healthErrTimeout");
|
||||
if (error.startsWith("connection failed")) return t("healthErrUnreachable");
|
||||
if (error.startsWith("response is not valid JSON")) return t("healthErrBadJson");
|
||||
// 账单链路的报错复用已有的用量错误映射(禁用 / OAuth 过期 / NewAPI 凭据…)。
|
||||
return localizeUsageError(error, t);
|
||||
}
|
||||
|
||||
/**
|
||||
* 体检结论里的错误文案本地化。探活与账单两条链路的错误由 Rust 用 `; `
|
||||
* 拼进同一个字段,逐段映射后再拼回,保证两段都能翻译。
|
||||
*/
|
||||
export function localizeHealthError(error: string, t: TranslateFn): string {
|
||||
return error
|
||||
.split("; ")
|
||||
.map((part) => localizeHealthPart(part, t))
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
/** 探活一项的 tooltip 文案。 */
|
||||
export function probeLine(
|
||||
probe: CheckResult,
|
||||
latencyMs: number,
|
||||
t: TranslateFn
|
||||
): string {
|
||||
if (probe.state === "pass") return t("healthProbeOk", { latency: latencyMs });
|
||||
// 中性 = 端点未实现 /v1/models(404),如实说明「不支持」而不是报失败。
|
||||
if (probe.state === "neutral") return t("healthProbeUnsupported");
|
||||
return t("healthProbeFail", {
|
||||
error: probe.error ? localizeHealthError(probe.error, t) : "",
|
||||
});
|
||||
}
|
||||
|
||||
/** 账单一项的 tooltip 文案。 */
|
||||
export function usageLine(usage: CheckResult, t: TranslateFn): string {
|
||||
if (usage.state === "pass") return t("healthUsageOk");
|
||||
if (usage.state === "fail") {
|
||||
return t("healthUsageFail", {
|
||||
error: usage.error ? localizeHealthError(usage.error, t) : "",
|
||||
});
|
||||
}
|
||||
// 账单链路不产生中性结论(只有探活 404 会),兜底按「未判定」展示。
|
||||
return t("healthNotChecked");
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态点颜色:绿=正常、红=失败、灰=未判定(未检测 / 检测中 / 全部中性)。
|
||||
* 判定本身由 Rust 的 overall_verdict 给出,这里只做映射。
|
||||
*/
|
||||
export function healthDotClass(verdict: HealthCheck): string {
|
||||
switch (verdict) {
|
||||
case "pass":
|
||||
return "bg-green-500";
|
||||
case "fail":
|
||||
return "bg-red-500";
|
||||
default:
|
||||
return "bg-gray-400 dark:bg-gray-500";
|
||||
}
|
||||
}
|
||||
|
||||
export interface HealthCheckState {
|
||||
/** 体检进行中:按钮转圈、尚未出结果的行显示灰色检测中指示。 */
|
||||
running: boolean;
|
||||
/** 至少跑过一轮(决定是否渲染状态点)。 */
|
||||
ran: boolean;
|
||||
/** provider name → 结果。 */
|
||||
results: Record<string, HealthResult>;
|
||||
okCount: number;
|
||||
failCount: number;
|
||||
/** 整体请求失败(配置读取失败 / 总超时)的文案;null = 无错误。 */
|
||||
error: string | null;
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一键体检:invoke `health_check_all`,把结果按 provider 名索引。
|
||||
* 结果保留到下次点击或组件卸载(页面刷新),不在后台轮询。
|
||||
*/
|
||||
export function useHealthCheck(agent: Agent): HealthCheckState {
|
||||
const [running, setRunning] = useState(false);
|
||||
const [ran, setRan] = useState(false);
|
||||
const [results, setResults] = useState<Record<string, HealthResult>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Generation counter: 重跑/卸载后到达的旧响应直接丢弃,不写回状态。
|
||||
const genRef = useRef(0);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
genRef.current += 1;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const run = useCallback(async () => {
|
||||
const gen = ++genRef.current;
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
// 清空上一轮结果:检测中的行显示灰色转圈,而不是留着旧结论误导。
|
||||
setResults({});
|
||||
try {
|
||||
const list = await invoke<HealthResult[]>("health_check_all", { agent });
|
||||
if (genRef.current !== gen) return;
|
||||
const map: Record<string, HealthResult> = {};
|
||||
for (const r of list) map[r.providerName] = r;
|
||||
setResults(map);
|
||||
} catch (e) {
|
||||
if (genRef.current !== gen) return;
|
||||
// invoke 拒绝时 Rust 侧 Err(String) 是字符串,不是 Error 实例。
|
||||
setError(typeof e === "string" ? e : e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
if (genRef.current === gen) {
|
||||
setRunning(false);
|
||||
setRan(true);
|
||||
}
|
||||
}
|
||||
}, [agent]);
|
||||
|
||||
const values = Object.values(results);
|
||||
return {
|
||||
running,
|
||||
ran,
|
||||
results,
|
||||
// 中性(灰)既不算正常也不算失败,不计入汇总。
|
||||
okCount: values.filter((r) => r.verdict === "pass").length,
|
||||
failCount: values.filter((r) => r.verdict === "fail").length,
|
||||
error,
|
||||
run: () => void run(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isNewerVersion } from "./useKimiCodeVersion";
|
||||
|
||||
describe("isNewerVersion", () => {
|
||||
it("同版本不算有更新", () => {
|
||||
expect(isNewerVersion("2.0.2", "2.0.2")).toBe(false);
|
||||
expect(isNewerVersion("v2.0.2", "2.0.2")).toBe(false);
|
||||
});
|
||||
|
||||
it("按 major/minor/patch 逐段比较", () => {
|
||||
expect(isNewerVersion("2.0.2", "2.0.1")).toBe(true);
|
||||
expect(isNewerVersion("2.1.0", "2.0.9")).toBe(true);
|
||||
expect(isNewerVersion("3.0.0", "2.99.99")).toBe(true);
|
||||
expect(isNewerVersion("2.0.1", "2.0.2")).toBe(false);
|
||||
expect(isNewerVersion("1.9.9", "2.0.0")).toBe(false);
|
||||
});
|
||||
|
||||
it("忽略 v 前缀与预发布后缀", () => {
|
||||
expect(isNewerVersion("v0.44.0-beta.1", "0.43.1")).toBe(true);
|
||||
expect(isNewerVersion("0.43.1", "0.43.1-rc.2")).toBe(false);
|
||||
});
|
||||
|
||||
it("缺位补 0,无法解析时不显示更新", () => {
|
||||
expect(isNewerVersion("2.1", "2.0.9")).toBe(true);
|
||||
expect(isNewerVersion("abc", "2.0.1")).toBe(false);
|
||||
expect(isNewerVersion("2.0.2", "abc")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
/** kimi-code 与本应用的适配状态(Rust 侧基线表决定)。 */
|
||||
export type KimiCodeStatus =
|
||||
| "current"
|
||||
| "outdated"
|
||||
| "partial"
|
||||
| "unknown"
|
||||
| "not_installed";
|
||||
|
||||
export interface KimiCodeVersionInfo {
|
||||
/** 本机安装的 kimi-code 版本;未安装 / 检测失败为 null。 */
|
||||
installed: string | null;
|
||||
/** 上游最新 release tag(已去掉 v 前缀);查询失败为 null。 */
|
||||
latest: string | null;
|
||||
status: KimiCodeStatus;
|
||||
}
|
||||
|
||||
const THROTTLE_KEY = "kimi-switch-last-kc-version-check";
|
||||
// 与 useUpdateCheck 一致:节流窗口与定时复查同为 8h,长时间挂着的窗口也能
|
||||
// 自动拿到新版本,同时避免短时间反复触发后端调用。
|
||||
const CHECK_INTERVAL_MS = 8 * 60 * 60 * 1000; // 8 hours
|
||||
|
||||
/** 解析 major.minor.patch 三元组;缺位补 0,无法解析返回 null。 */
|
||||
function versionTriple(version: string): [number, number, number] | null {
|
||||
const core = version.trim().replace(/^[vV]/, "").split(/[-+]/)[0];
|
||||
const nums = core.split(".").slice(0, 3).map((p) => Number.parseInt(p.trim(), 10));
|
||||
if (nums.length === 0 || nums.some((n) => !Number.isFinite(n) || n < 0)) return null;
|
||||
while (nums.length < 3) nums.push(0);
|
||||
return [nums[0], nums[1], nums[2]];
|
||||
}
|
||||
|
||||
/** latest 是否严格高于 installed(用于决定是否显示 releases 链接)。 */
|
||||
export function isNewerVersion(latest: string, installed: string): boolean {
|
||||
const l = versionTriple(latest);
|
||||
const i = versionTriple(installed);
|
||||
if (!l || !i) return false;
|
||||
for (let k = 0; k < 3; k += 1) {
|
||||
if (l[k] !== i[k]) return l[k] > i[k];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取「本机 kimi-code 版本 / 上游最新版 / 适配状态」,模式与 useUpdateCheck
|
||||
* 相同:挂载时按 8h 节流自动查一次,之后定时复查,checkNow 强制刷新。
|
||||
*/
|
||||
export function useKimiCodeVersion() {
|
||||
const [info, setInfo] = useState<KimiCodeVersionInfo | null>(null);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [lastChecked, setLastChecked] = useState<number | null>(() => {
|
||||
try {
|
||||
const v = localStorage.getItem(THROTTLE_KEY);
|
||||
return v ? Number(v) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const doCheck = useCallback(
|
||||
async (force: boolean) => {
|
||||
if (!force) {
|
||||
if (lastChecked && Date.now() - lastChecked < CHECK_INTERVAL_MS) return;
|
||||
}
|
||||
setChecking(true);
|
||||
try {
|
||||
const next = await invoke<KimiCodeVersionInfo>("get_kimi_code_version_info");
|
||||
setInfo(next);
|
||||
const now = Date.now();
|
||||
setLastChecked(now);
|
||||
try {
|
||||
localStorage.setItem(THROTTLE_KEY, String(now));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} catch {
|
||||
// 静默失败:检测不到 kimi / 网络不通都不该打扰用户,手动检查仍可用。
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
},
|
||||
[lastChecked]
|
||||
);
|
||||
|
||||
// 定时器不随 lastChecked 重建,所以用 ref 持有最新的 doCheck。
|
||||
const doCheckRef = useRef(doCheck);
|
||||
useEffect(() => {
|
||||
doCheckRef.current = doCheck;
|
||||
}, [doCheck]);
|
||||
|
||||
useEffect(() => {
|
||||
doCheckRef.current(false);
|
||||
const interval = setInterval(() => doCheckRef.current(false), CHECK_INTERVAL_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const checkNow = useCallback(() => doCheck(true), [doCheck]);
|
||||
|
||||
return { info, checking, checkNow, lastChecked };
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
ActionResponse,
|
||||
BulkArchiveResult,
|
||||
PreviewResult,
|
||||
SessionStatusFilter,
|
||||
SessionsResult,
|
||||
@@ -54,6 +55,17 @@ export function useSessions() {
|
||||
[refresh]
|
||||
);
|
||||
|
||||
const archiveBefore = useCallback(
|
||||
async (cutoffMs: number) => {
|
||||
const result = await invoke<BulkArchiveResult>("archive_sessions_before", {
|
||||
cutoffMs,
|
||||
});
|
||||
await refresh();
|
||||
return result;
|
||||
},
|
||||
[refresh]
|
||||
);
|
||||
|
||||
const deleteSession = useCallback(
|
||||
async (workspaceId: string, sessionId: string, sessionStatus?: string) => {
|
||||
await invoke<ActionResponse>("delete_session", {
|
||||
@@ -100,6 +112,7 @@ export function useSessions() {
|
||||
refresh,
|
||||
archiveSession,
|
||||
unarchiveSession,
|
||||
archiveBefore,
|
||||
deleteSession,
|
||||
deleteWorkspace,
|
||||
getPreview,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isAlertTier, type UsageData } from "./useUsageQuery";
|
||||
|
||||
/** 百分比窗口行(five_hour 等);套餐类 tier 的 unit 恒为 "%"。 */
|
||||
function percentTier(used: number): UsageData {
|
||||
return { planName: "five_hour", used, total: 100, remaining: 100 - used, unit: "%" };
|
||||
}
|
||||
|
||||
describe("isAlertTier", () => {
|
||||
it("阈值缺省或 0(关闭)时不告警", () => {
|
||||
expect(isAlertTier(percentTier(100), undefined)).toBe(false);
|
||||
expect(isAlertTier(percentTier(100), null)).toBe(false);
|
||||
expect(isAlertTier(percentTier(100), 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("低于阈值不告警", () => {
|
||||
expect(isAlertTier(percentTier(79), 80)).toBe(false);
|
||||
expect(isAlertTier(percentTier(79.9), 80)).toBe(false);
|
||||
});
|
||||
|
||||
it("等于或超过阈值告警", () => {
|
||||
expect(isAlertTier(percentTier(80), 80)).toBe(true);
|
||||
expect(isAlertTier(percentTier(80.5), 80)).toBe(true);
|
||||
expect(isAlertTier(percentTier(100), 80)).toBe(true);
|
||||
});
|
||||
|
||||
it("非 % unit 的行不告警(金额行的 used 是钱数)", () => {
|
||||
// Kimi 加力钱包:used = monthlyUsedCents/100,与百分比阈值不可比。
|
||||
expect(
|
||||
isAlertTier(
|
||||
{ planName: "booster_wallet", used: 8, total: 50, remaining: 12.34, unit: "USD" },
|
||||
5
|
||||
)
|
||||
).toBe(false);
|
||||
// sub2api 的 USD 配额行同理,不参与百分比预警。
|
||||
expect(
|
||||
isAlertTier({ planName: "daily_limit", used: 90, total: 100, unit: "USD" }, 50)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("used 缺失时不告警", () => {
|
||||
expect(isAlertTier({ planName: "five_hour", unit: "%", remaining: 20 }, 50)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,23 @@ export interface UsageQueryState {
|
||||
updatedAt: number | null;
|
||||
refresh: (force: boolean) => void;
|
||||
supported: boolean;
|
||||
/** 生效的预警阈值(0/缺失已归一为 undefined);footer 用它判定单行高亮。 */
|
||||
threshold?: number;
|
||||
/** 查询成功且任一百分比行 used ≥ threshold。失败/加载中恒为 false, */
|
||||
/** 所以失败时保留的上次数据不会被误高亮。 */
|
||||
alert: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单行是否触发预警。只判定百分比行(unit === "%"):余额/钱包等金额行的
|
||||
* used 是钱数,与百分比阈值不可比。threshold 缺失或 ≤0(关闭)时恒为 false。
|
||||
*/
|
||||
export function isAlertTier(
|
||||
d: UsageData,
|
||||
threshold?: number | null
|
||||
): boolean {
|
||||
if (threshold == null || threshold <= 0) return false;
|
||||
return d.unit === "%" && d.used != null && d.used >= threshold;
|
||||
}
|
||||
|
||||
export function useUsageQuery(
|
||||
@@ -63,7 +80,8 @@ export function useUsageQuery(
|
||||
providerName: string,
|
||||
usageKinds?: string[],
|
||||
autoIntervalMinutes?: number,
|
||||
disabled?: boolean
|
||||
disabled?: boolean,
|
||||
threshold?: number
|
||||
): UsageQueryState {
|
||||
const supported = (usageKinds?.length ?? 0) > 0;
|
||||
// Cache key includes the agent so a Kimi Code provider and a Pi provider
|
||||
@@ -175,6 +193,15 @@ export function useUsageQuery(
|
||||
return () => clearInterval(id);
|
||||
}, [supported, autoIntervalMinutes, runQuery, disabled]);
|
||||
|
||||
// 阈值 0/负数/非有限值一律视为关闭(与配置面板「0 或空 = 关闭」一致)。
|
||||
// 每次查询结果写入 data 后此处重新求值,因此刷新后预警自动出现/消除。
|
||||
const alertThreshold =
|
||||
typeof threshold === "number" && Number.isFinite(threshold) && threshold > 0
|
||||
? threshold
|
||||
: undefined;
|
||||
const alert =
|
||||
status === "success" && data.some((d) => isAlertTier(d, alertThreshold));
|
||||
|
||||
return {
|
||||
status,
|
||||
data,
|
||||
@@ -182,5 +209,7 @@ export function useUsageQuery(
|
||||
updatedAt,
|
||||
refresh: runQuery,
|
||||
supported,
|
||||
threshold: alertThreshold,
|
||||
alert,
|
||||
};
|
||||
}
|
||||
+63
-2
@@ -43,6 +43,26 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
connectivityOk: "{name} reachable ({latency}ms)",
|
||||
connectivitySlow: "{name} reachable but slow ({latency}ms)",
|
||||
connectivityFail: "{name} unreachable: {error}",
|
||||
// Health check (ProviderList toolbar button + per-row status dot)
|
||||
healthCheck: "Health check",
|
||||
healthCheckHint:
|
||||
"Probe every enabled provider's inference endpoint and billing query in parallel",
|
||||
healthCheckRunning: "Checking…",
|
||||
healthSummary: "{ok} OK / {fail} failed",
|
||||
healthProbeOk: "Endpoint OK ({latency}ms)",
|
||||
healthProbeUnsupported: "Endpoint exposes no model list (404)",
|
||||
healthProbeFail: "Endpoint failed: {error}",
|
||||
healthUsageOk: "Billing OK",
|
||||
healthUsageFail: "Billing failed: {error}",
|
||||
healthNotChecked: "Not checked",
|
||||
healthRequestFailed: "Health check request failed: {error}",
|
||||
healthErrNoBaseUrl: "Missing base URL",
|
||||
healthErrNoKey: "No API key configured",
|
||||
healthErrAuth: "Authentication failed",
|
||||
healthErrNotFound: "Endpoint not found",
|
||||
healthErrTimeout: "Connection timed out",
|
||||
healthErrUnreachable: "Cannot connect (DNS failure or connection refused)",
|
||||
healthErrBadJson: "Response is not valid JSON",
|
||||
copiedProvider: "Duplicated as {name}",
|
||||
|
||||
// Provider edit
|
||||
@@ -78,6 +98,12 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
authField: "Auth Field",
|
||||
apiKey: "API Key",
|
||||
getApiKeyLink: "Get API Key",
|
||||
apiKeySource: "Credential source",
|
||||
apiKeySourceDirect: "Enter key",
|
||||
apiKeySourceEnv: "Environment variable",
|
||||
apiKeyEnvName: "Variable name",
|
||||
apiKeyEnvHint:
|
||||
"Only the variable name (api_key_env) is written; Kimi Code reads the secret from its environment. The two sources are mutually exclusive, so switching clears the other.",
|
||||
show: "Show",
|
||||
hide: "Hide",
|
||||
requestUrl: "Request URL",
|
||||
@@ -142,9 +168,16 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
loopControlSettings: "Loop Control",
|
||||
maxAttemptsPerStep: "Max attempts per step",
|
||||
reservedContextSize: "Reserved context size",
|
||||
compactionMaxAttempts: "Max compaction attempts",
|
||||
compactionMaxAttemptsHint:
|
||||
"Retry limit after a failed context compaction. Empty or 0 leaves the key out of the config (upstream default 5).",
|
||||
backgroundSettings: "Background Tasks",
|
||||
maxRunningTasks: "Max running tasks",
|
||||
keepAliveOnExit: "Keep alive on exit",
|
||||
watchSettings: "File Watching",
|
||||
watchEnabled: "Enable file watching",
|
||||
watchEnabledDesc:
|
||||
"Watch config files and the workspace for changes (configurable since kimi-code 2.0.1, off by default since 2.0.2). The KIMI_CODE_WATCH environment variable overrides this setting.",
|
||||
permissionRules: "Permission Rules",
|
||||
permissionDecision: "Decision",
|
||||
permissionPattern: "Pattern",
|
||||
@@ -225,6 +258,19 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
roleUser: "User",
|
||||
roleAssistant: "Assistant",
|
||||
roleSystem: "System",
|
||||
sessionsArchiveAll: "Archive old sessions",
|
||||
archiveAllTitle: "Archive old sessions",
|
||||
archiveAllDesc:
|
||||
"Archives every active session whose last activity is older than the selected date. Files are kept and usage stats stay intact.",
|
||||
archiveOptionMonth: "Older than 1 month",
|
||||
archiveOptionHalfMonth: "Older than 15 days",
|
||||
archiveOptionWeek: "Older than 1 week",
|
||||
archiveOptionCustom: "Specific date",
|
||||
archiveAllConfirm: "Archive every active session last active before {date}. Continue?",
|
||||
archiveAllRunning: "Archiving…",
|
||||
archiveAllDone: "Archived {archived} sessions, skipped {skipped}",
|
||||
archiveAllFailed: "Archive failed",
|
||||
archiveAllEmpty: "No sessions match",
|
||||
|
||||
// Heatmap
|
||||
noData: "No data",
|
||||
@@ -270,6 +316,18 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
install: "Install",
|
||||
updateCheckFailed: "Update check failed",
|
||||
lastChecked: "Last checked",
|
||||
// Kimi Code version tracking
|
||||
kimiCodeSection: "Kimi Code version",
|
||||
kimiCodeInstalled: "Installed",
|
||||
kimiCodeLatest: "Latest",
|
||||
kimiCodeStatusLabel: "Compatibility",
|
||||
kimiCodeStatusCurrent: "Supported",
|
||||
kimiCodeStatusOutdated: "Update KimiSwitch recommended",
|
||||
kimiCodeStatusPartial: "Partially compatible",
|
||||
kimiCodeStatusUnknown: "Unknown version",
|
||||
kimiCodeStatusNotInstalled: "Not installed",
|
||||
kimiCodeCheck: "Check",
|
||||
kimiCodeOpenReleases: "Open releases",
|
||||
appDescription: "Windows desktop LLM provider config manager",
|
||||
settingsWebsite: "Official website",
|
||||
kimiEyesSite: "Kimi Eyes plugin site",
|
||||
@@ -436,6 +494,11 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
usageTierDaily: "Daily",
|
||||
usageTierWeekly: "Weekly",
|
||||
usageTierMonthly: "Monthly",
|
||||
usageTierMonthCode: "Monthly (code)",
|
||||
usageTierBoosterWallet: "Booster",
|
||||
usageThreshold: "Alert threshold",
|
||||
usageThresholdHint: "Highlight any window that reaches this percentage; 0 = off",
|
||||
usageThresholdAlert: "Threshold reached",
|
||||
usageErrNoKey: "No API key configured",
|
||||
usageErrDisabled: "Usage query is disabled in the config panel",
|
||||
usageErrLoginExpired: "Kimi Code login expired and auto-refresh failed; run `kimi login` again",
|
||||
@@ -477,8 +540,6 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
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",
|
||||
flagNotifyUser: "NotifyUser tool",
|
||||
flagNotifyUserDesc: "Experimental Updates panel: paginated progress messages from the main agent and subagents (off by default)",
|
||||
flagDefaultOn: "On by default",
|
||||
|
||||
+60
-2
@@ -41,6 +41,25 @@ export const zhTranslations = {
|
||||
connectivityOk: "{name} 连通正常({latency}ms)",
|
||||
connectivitySlow: "{name} 连通较慢({latency}ms)",
|
||||
connectivityFail: "{name} 无法连通:{error}",
|
||||
// 一键体检(ProviderList 工具栏按钮 + 行内状态点)
|
||||
healthCheck: "一键体检",
|
||||
healthCheckHint: "并行检测所有启用供应商的推理端点与账单查询",
|
||||
healthCheckRunning: "体检中…",
|
||||
healthSummary: "{ok} 正常 / {fail} 失败",
|
||||
healthProbeOk: "端点正常({latency}ms)",
|
||||
healthProbeUnsupported: "端点不支持模型列表(404)",
|
||||
healthProbeFail: "端点失败:{error}",
|
||||
healthUsageOk: "账单正常",
|
||||
healthUsageFail: "账单失败:{error}",
|
||||
healthNotChecked: "未检测",
|
||||
healthRequestFailed: "体检请求失败:{error}",
|
||||
healthErrNoBaseUrl: "缺少 base_url",
|
||||
healthErrNoKey: "未配置 API Key",
|
||||
healthErrAuth: "鉴权失败",
|
||||
healthErrNotFound: "端点不存在",
|
||||
healthErrTimeout: "连接超时",
|
||||
healthErrUnreachable: "无法连接(DNS 解析失败或连接被拒绝)",
|
||||
healthErrBadJson: "响应不是合法 JSON",
|
||||
copiedProvider: "已复制为 {name}",
|
||||
|
||||
// Provider edit
|
||||
@@ -76,6 +95,12 @@ export const zhTranslations = {
|
||||
authField: "认证字段",
|
||||
apiKey: "API Key",
|
||||
getApiKeyLink: "获取 API Key",
|
||||
apiKeySource: "凭证来源",
|
||||
apiKeySourceDirect: "直接填写",
|
||||
apiKeySourceEnv: "环境变量",
|
||||
apiKeyEnvName: "环境变量名",
|
||||
apiKeyEnvHint:
|
||||
"只写入变量名(api_key_env),密钥由 Kimi Code 从运行环境读取;两者互斥,切换后会清空另一种。",
|
||||
show: "显示",
|
||||
hide: "隐藏",
|
||||
requestUrl: "请求地址",
|
||||
@@ -140,9 +165,15 @@ export const zhTranslations = {
|
||||
loopControlSettings: "循环控制",
|
||||
maxAttemptsPerStep: "单步最大尝试次数",
|
||||
reservedContextSize: "上下文预留大小",
|
||||
compactionMaxAttempts: "压缩最大尝试次数",
|
||||
compactionMaxAttemptsHint: "上下文压缩失败后的重试上限,留空或 0 表示不写入该项(上游默认 5)。",
|
||||
backgroundSettings: "后台任务",
|
||||
maxRunningTasks: "最大并发数",
|
||||
keepAliveOnExit: "退出时保持运行",
|
||||
watchSettings: "文件监听",
|
||||
watchEnabled: "启用文件监听",
|
||||
watchEnabledDesc:
|
||||
"监听配置文件与工作目录变化(kimi-code 2.0.1 起可配置,2.0.2 起默认关闭)。环境变量 KIMI_CODE_WATCH 会覆盖本配置。",
|
||||
permissionRules: "权限规则",
|
||||
permissionDecision: "处置",
|
||||
permissionPattern: "模式",
|
||||
@@ -220,6 +251,18 @@ export const zhTranslations = {
|
||||
roleUser: "用户",
|
||||
roleAssistant: "助手",
|
||||
roleSystem: "系统",
|
||||
sessionsArchiveAll: "一键归档",
|
||||
archiveAllTitle: "一键归档会话",
|
||||
archiveAllDesc: "将归档最后活跃时间早于所选日期的全部活跃会话;文件保留,统计不受影响。",
|
||||
archiveOptionMonth: "1 个月前",
|
||||
archiveOptionHalfMonth: "半个月前",
|
||||
archiveOptionWeek: "1 星期前",
|
||||
archiveOptionCustom: "指定日期",
|
||||
archiveAllConfirm: "将归档最后活跃时间早于 {date} 的全部活跃会话。确认?",
|
||||
archiveAllRunning: "正在归档…",
|
||||
archiveAllDone: "已归档 {archived} 个会话,跳过 {skipped} 个",
|
||||
archiveAllFailed: "归档失败",
|
||||
archiveAllEmpty: "没有符合条件的会话",
|
||||
|
||||
// Heatmap
|
||||
noData: "暂无数据",
|
||||
@@ -265,6 +308,18 @@ export const zhTranslations = {
|
||||
install: "安装",
|
||||
updateCheckFailed: "检查更新失败",
|
||||
lastChecked: "上次检查",
|
||||
// Kimi Code 版本跟踪
|
||||
kimiCodeSection: "Kimi Code 版本",
|
||||
kimiCodeInstalled: "已安装版本",
|
||||
kimiCodeLatest: "最新版本",
|
||||
kimiCodeStatusLabel: "适配状态",
|
||||
kimiCodeStatusCurrent: "已适配",
|
||||
kimiCodeStatusOutdated: "建议升级 KimiSwitch",
|
||||
kimiCodeStatusPartial: "部分兼容",
|
||||
kimiCodeStatusUnknown: "未知版本",
|
||||
kimiCodeStatusNotInstalled: "未安装",
|
||||
kimiCodeCheck: "检查",
|
||||
kimiCodeOpenReleases: "打开 Releases",
|
||||
appDescription: "Windows 桌面端 LLM 供应商配置管理器",
|
||||
settingsWebsite: "官方网站",
|
||||
kimiEyesSite: "Kimi Eyes 插件站",
|
||||
@@ -431,6 +486,11 @@ export const zhTranslations = {
|
||||
usageTierDaily: "每日",
|
||||
usageTierWeekly: "7天",
|
||||
usageTierMonthly: "30天",
|
||||
usageTierMonthCode: "30天(代码)",
|
||||
usageTierBoosterWallet: "加力钱包",
|
||||
usageThreshold: "预警阈值",
|
||||
usageThresholdHint: "任一用量窗口达到该百分比时高亮提醒,0 为关闭",
|
||||
usageThresholdAlert: "已达预警阈值",
|
||||
usageErrNoKey: "未配置 API Key",
|
||||
usageErrDisabled: "用量查询已在配置面板中停用",
|
||||
usageErrLoginExpired: "Kimi Code 登录已过期且自动续期失败,请重新运行 `kimi login`",
|
||||
@@ -472,8 +532,6 @@ export const zhTranslations = {
|
||||
flagSubagentForkDesc: "Agent/AgentSwarm 工具可携带主代理会话快照启动子代理(fork 参数)",
|
||||
flagWaitFor: "WaitFor 工具",
|
||||
flagWaitForDesc: "代理可在当前回合内等待后台任务完成,而无需结束回合再被唤醒",
|
||||
flagAutoSessionTitle: "自动会话标题",
|
||||
flagAutoSessionTitleDesc: "通过托管 chat_title 工具自动生成会话标题",
|
||||
flagNotifyUser: "NotifyUser 工具",
|
||||
flagNotifyUserDesc: "实验性 Updates 面板:分页展示主代理与子代理的进度消息(默认关闭)",
|
||||
flagDefaultOn: "默认开启",
|
||||
|
||||
@@ -134,10 +134,104 @@ describe("loop_control — v1/v2 key handling", () => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// [permission] dangerous_command_guard (kimi-code 0.40.1) — absent key means
|
||||
// upstream default ON; an explicit false must survive even with no rules
|
||||
// loop_control.compaction_max_attempts (kimi-code 0.43.0) — read via the loop
|
||||
// spread, written only when set, and 0/empty removes the key (upstream
|
||||
// default 5 applies)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("loop_control.compaction_max_attempts", () => {
|
||||
it("reads an existing value through the loop spread", () => {
|
||||
expect(
|
||||
getAgentSettings({ loop_control: { compaction_max_attempts: 7 } })
|
||||
.loop_control?.compaction_max_attempts
|
||||
).toBe(7);
|
||||
});
|
||||
|
||||
it("reads as undefined when the key is absent (UI falls back to 5)", () => {
|
||||
expect(
|
||||
getAgentSettings({ loop_control: {} }).loop_control?.compaction_max_attempts
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("writes the value when the patch sets it", () => {
|
||||
const next = setAgentSettings({}, {
|
||||
loop_control: { compaction_max_attempts: 8 },
|
||||
});
|
||||
expect(loopOf(next).compaction_max_attempts).toBe(8);
|
||||
});
|
||||
|
||||
it("drops the key for 0 and for a cleared (undefined) field", () => {
|
||||
const raw = { loop_control: { compaction_max_attempts: 6 } };
|
||||
expect(loopOf(setAgentSettings(raw, {
|
||||
loop_control: { compaction_max_attempts: 0 },
|
||||
}))).not.toHaveProperty("compaction_max_attempts");
|
||||
expect(loopOf(setAgentSettings(raw, {
|
||||
loop_control: { compaction_max_attempts: undefined },
|
||||
}))).not.toHaveProperty("compaction_max_attempts");
|
||||
});
|
||||
|
||||
it("does not materialize the upstream default on an unrelated save", () => {
|
||||
// An absent key must stay absent — a plain save must not pin `5`.
|
||||
const next = setAgentSettings({ thinking: { effort: "high" } }, {});
|
||||
expect(loopOf(next)).not.toHaveProperty("compaction_max_attempts");
|
||||
// An existing value survives a save that does not touch it.
|
||||
const kept = setAgentSettings(
|
||||
{ loop_control: { compaction_max_attempts: 4 } },
|
||||
{ thinking: { effort: "low" } }
|
||||
);
|
||||
expect(loopOf(kept).compaction_max_attempts).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// [watch] enabled (kimi-code 2.0.1+) — top-level section, off by default since
|
||||
// 2.0.2; mirrors the [background] handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function watchOf(raw: unknown): Record<string, unknown> {
|
||||
const w = (raw as { watch?: unknown })?.watch;
|
||||
return w && typeof w === "object" && !Array.isArray(w)
|
||||
? (w as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
describe("[watch] enabled", () => {
|
||||
it("reads as false when the section or key is absent (2.0.2 default)", () => {
|
||||
expect(getAgentSettings({}).watch?.enabled).toBe(false);
|
||||
expect(getAgentSettings({ watch: {} }).watch?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("reads an explicit value", () => {
|
||||
expect(getAgentSettings({ watch: { enabled: true } }).watch?.enabled).toBe(true);
|
||||
expect(getAgentSettings({ watch: { enabled: false } }).watch?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("writes the top-level [watch] section", () => {
|
||||
const next = setAgentSettings({}, { watch: { enabled: true } }) as {
|
||||
watch?: Record<string, unknown>;
|
||||
};
|
||||
expect(next.watch).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it("keeps the section on saves that do not touch it", () => {
|
||||
const next = setAgentSettings(
|
||||
{ watch: { enabled: true } },
|
||||
{ thinking: { effort: "high" } }
|
||||
) as { watch?: Record<string, unknown> };
|
||||
expect(next.watch).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it("does not disturb the other managed sections", () => {
|
||||
const next = setAgentSettings(
|
||||
{ background: { max_running_tasks: 2 } },
|
||||
{ watch: { enabled: true } }
|
||||
) as { background?: Record<string, unknown> };
|
||||
expect(next.background?.max_running_tasks).toBe(2);
|
||||
expect(watchOf(next).enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("permission.dangerous_command_guard", () => {
|
||||
it("reads as undefined when the key is absent (default on)", () => {
|
||||
expect(
|
||||
|
||||
@@ -13,6 +13,11 @@ const DEFAULT_SETTINGS: AgentSettings = {
|
||||
background: {
|
||||
keep_alive_on_exit: false,
|
||||
},
|
||||
// `[watch] enabled` — kimi-code 2.0.1 added the key, 2.0.2 flipped the
|
||||
// default to off, so an absent key reads as "off".
|
||||
watch: {
|
||||
enabled: false,
|
||||
},
|
||||
permission: { rules: [] },
|
||||
hooks: [],
|
||||
};
|
||||
@@ -76,6 +81,10 @@ export function getAgentSettings(rawOther: unknown): AgentSettings {
|
||||
...DEFAULT_SETTINGS.background,
|
||||
...getSection<AgentSettings["background"]>(rawOther, "background"),
|
||||
},
|
||||
watch: {
|
||||
...DEFAULT_SETTINGS.watch,
|
||||
...getSection<AgentSettings["watch"]>(rawOther, "watch"),
|
||||
},
|
||||
permission: {
|
||||
rules: sectionPermission?.rules ?? [],
|
||||
// `dangerous_command_guard` stays undefined unless the config carries
|
||||
@@ -111,6 +120,7 @@ export function setAgentSettings(
|
||||
thinking: { ...current.thinking, ...patch.thinking },
|
||||
loop_control: { ...current.loop_control, ...patch.loop_control },
|
||||
background: { ...current.background, ...patch.background },
|
||||
watch: { ...current.watch, ...patch.watch },
|
||||
permission,
|
||||
hooks: patch.hooks ?? current.hooks ?? [],
|
||||
};
|
||||
@@ -129,9 +139,16 @@ export function setAgentSettings(
|
||||
if (!opts?.legacyV1) {
|
||||
delete next.loop_control?.max_retries_per_step;
|
||||
}
|
||||
// `compaction_max_attempts` (kimi-code 0.43.0+): 0 / undefined means "leave
|
||||
// the key out" — the config then falls back to the upstream default (5)
|
||||
// instead of pinning a literal 0.
|
||||
if (!next.loop_control?.compaction_max_attempts) {
|
||||
delete next.loop_control?.compaction_max_attempts;
|
||||
}
|
||||
if (next.thinking) root.thinking = next.thinking;
|
||||
if (next.loop_control) root.loop_control = next.loop_control;
|
||||
if (next.background) root.background = next.background;
|
||||
if (next.watch) root.watch = next.watch;
|
||||
// Keep `[permission]` when it carries rules *or* an explicit
|
||||
// dangerous_command_guard — dropping the section would silently lose a
|
||||
// guard=false write (absent key = upstream default on). An empty rules
|
||||
|
||||
+2966
-511
File diff suppressed because it is too large.
Load diff
+2489
-397
File diff suppressed because it is too large.
Load diff
@@ -461,22 +461,23 @@ describe("validateSubagentPool", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("experimental flag registry", () => {
|
||||
it("mirrors the 0.42.0 v2 registry (6 flags)", () => {
|
||||
it("mirrors the 0.43.0 v2 registry (5 flags)", () => {
|
||||
expect(EXPERIMENTAL_FLAGS.map((f) => f.id)).toEqual([
|
||||
"wait_for",
|
||||
"tool-select",
|
||||
"notify_user",
|
||||
"tower",
|
||||
"subagent_fork",
|
||||
"auto_session_title",
|
||||
]);
|
||||
// Removed upstream: file_history (0.41.0, turn-level file history is
|
||||
// always on) plus secondary-model / persistence_minidb_readmodel /
|
||||
// remote-control / search_worker (0.42.0 — promoted or moved to
|
||||
// dedicated config, the minidb read-model now lives under [database]),
|
||||
// so the adapter must not mirror them either.
|
||||
// always on), auto_session_title (0.43.0, graduated — AI session titles
|
||||
// are always on, #3749), plus secondary-model /
|
||||
// persistence_minidb_readmodel / remote-control / search_worker
|
||||
// (0.42.0 — promoted or moved to dedicated config, the minidb read-model
|
||||
// now lives under [database]), so the adapter must not mirror them either.
|
||||
for (const id of [
|
||||
"file_history",
|
||||
"auto_session_title",
|
||||
"secondary-model",
|
||||
"persistence_minidb_readmodel",
|
||||
"remote-control",
|
||||
|
||||
@@ -33,10 +33,11 @@ export interface ExperimentalFlagDef {
|
||||
defaultEnabled?: boolean;
|
||||
}
|
||||
|
||||
/** Known experimental flags — mirrors the kimi-code 0.42.0 v2 flag registry
|
||||
/** Known experimental flags — mirrors the kimi-code 0.43.0 v2 flag registry
|
||||
* (the per-feature flag.ts files under packages/agent-core-v2/src):
|
||||
* wait_for, tool-select, notify_user, tower, subagent_fork,
|
||||
* auto_session_title. Removed in 0.42.0: secondary-model, remote-control
|
||||
* wait_for, tool-select, notify_user, tower, subagent_fork.
|
||||
* Removed in 0.43.0: auto_session_title (graduated — AI session titles are
|
||||
* always on, #3749). Removed in 0.42.0: secondary-model, remote-control
|
||||
* and search_worker (promoted or dropped upstream — no flag left), and
|
||||
* persistence_minidb_readmodel (now controlled by the `[database]` config
|
||||
* section). `acp-v2` was removed upstream earlier; `file_history` was
|
||||
@@ -53,10 +54,6 @@ export const EXPERIMENTAL_FLAGS: ExperimentalFlagDef[] = [
|
||||
{ id: "notify_user", envVar: "KIMI_CODE_EXPERIMENTAL_NOTIFY_USER" },
|
||||
{ id: "tower", envVar: "KIMI_CODE_EXPERIMENTAL_TOWER" },
|
||||
{ id: "subagent_fork", envVar: "KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK" },
|
||||
{
|
||||
id: "auto_session_title",
|
||||
envVar: "KIMI_CODE_EXPERIMENTAL_AUTO_SESSION_TITLE",
|
||||
},
|
||||
];
|
||||
|
||||
export const EXPERIMENTAL_MASTER_ENV = "KIMI_CODE_EXPERIMENTAL_FLAG";
|
||||
|
||||
@@ -59,6 +59,7 @@ describe("planLabel", () => {
|
||||
expect(planLabel("five_hour", t)).toBe("usageTier5h");
|
||||
expect(planLabel("weekly_limit", t)).toBe("usageTierWeekly");
|
||||
expect(planLabel("monthly_limit", t)).toBe("usageTierMonthly");
|
||||
expect(planLabel("booster_wallet", t)).toBe("usageTierBoosterWallet");
|
||||
expect(planLabel("NewAPI", t)).toBe("NewAPI");
|
||||
expect(planLabel("¥", t)).toBe("¥");
|
||||
});
|
||||
|
||||
@@ -5,15 +5,23 @@ type TranslateFn = (
|
||||
vars?: Record<string, string | number>
|
||||
) => string;
|
||||
|
||||
/**
|
||||
* Kimi quota 的加力钱包行 id(src-tauri/src/services/coding_plan.rs 的
|
||||
* PLAN_BOOSTER_WALLET)。它是金额行而非配额百分比行,planLabel() 与
|
||||
* UsageFooter 的金额行判定共用此常量,避免字面量散落。
|
||||
*/
|
||||
export const BOOSTER_WALLET_PLAN = "booster_wallet";
|
||||
|
||||
/**
|
||||
* Map a raw Rust plan/tier name to a localized label. Unknown names (proper
|
||||
* nouns like "OpenRouter", "NewAPI", currency codes) pass through unchanged.
|
||||
*
|
||||
* Coverage note: ALL plan-type (套餐) queries flow through
|
||||
* src-tauri/src/services/coding_plan.rs::percent_tier, whose names come only
|
||||
* from TIER_FIVE_HOUR / TIER_WEEKLY_LIMIT / TIER_MONTHLY_LIMIT — so this
|
||||
* mapping covers every current and future plan provider automatically. If a
|
||||
* new tier id is ever added there, add a case here too.
|
||||
* from TIER_FIVE_HOUR / TIER_WEEKLY_LIMIT / TIER_MONTHLY_LIMIT /
|
||||
* TIER_MONTH_CODE — so this mapping covers every current and future plan
|
||||
* provider automatically. If a new tier id is ever added there, add a case
|
||||
* here too.
|
||||
*/
|
||||
export function planLabel(name: string, t: TranslateFn): string {
|
||||
switch (name) {
|
||||
@@ -25,6 +33,10 @@ export function planLabel(name: string, t: TranslateFn): string {
|
||||
return t("usageTierWeekly");
|
||||
case "monthly_limit":
|
||||
return t("usageTierMonthly");
|
||||
case "month_code":
|
||||
return t("usageTierMonthCode");
|
||||
case BOOSTER_WALLET_PLAN:
|
||||
return t("usageTierBoosterWallet");
|
||||
default:
|
||||
return name;
|
||||
}
|
||||
|
||||
@@ -50,7 +50,10 @@ export function validateProviders(
|
||||
const reasons: string[] = [];
|
||||
|
||||
const apiKey = provider.api_key?.trim();
|
||||
if (!apiKey && !provider.managed) {
|
||||
// kimi-code 2.0.0+: the credential may come from an environment variable
|
||||
// instead of a stored key — a provider configured that way is complete.
|
||||
const apiKeyEnv = provider.api_key_env?.trim();
|
||||
if (!apiKey && !apiKeyEnv && !provider.managed) {
|
||||
reasons.push(t("validationNoApiKey"));
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,12 @@ export interface Provider {
|
||||
provider_type: ProviderType;
|
||||
base_url: string | null;
|
||||
api_key: string | null;
|
||||
/**
|
||||
* Name of the process environment variable holding the credential
|
||||
* (kimi-code 2.0.0+). Mutually exclusive with `api_key` upstream — the
|
||||
* export writes only one of the two. Only the variable *name* is stored.
|
||||
*/
|
||||
api_key_env?: string | null;
|
||||
env: Record<string, string>;
|
||||
/** Optional note / remark for the provider. */
|
||||
note?: string | null;
|
||||
@@ -90,6 +96,9 @@ export interface UsageConfig {
|
||||
autoQueryIntervalMinutes?: number;
|
||||
/** Per-request timeout in seconds; 0/undefined = default (8s). */
|
||||
timeoutSeconds?: number;
|
||||
/** 用量预警阈值(百分比 0-100);0/undefined = 关闭预警。
|
||||
* 判定见 src/hooks/useUsageQuery.ts 的 isAlertTier()。 */
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
/** A model discovered from a provider's API endpoint. */
|
||||
@@ -125,6 +134,9 @@ export interface LoopControlConfig {
|
||||
* KIMI_CODE_LEGACY_FLAG=1 (v1 engine compat). v2 saves strip it. */
|
||||
max_retries_per_step?: number;
|
||||
reserved_context_size?: number;
|
||||
/** Retry budget for context compaction (kimi-code 0.43.0+). Absent/0 = the
|
||||
* key is not written and upstream's default (5) applies. */
|
||||
compaction_max_attempts?: number;
|
||||
}
|
||||
|
||||
export interface BackgroundConfig {
|
||||
@@ -132,6 +144,13 @@ export interface BackgroundConfig {
|
||||
keep_alive_on_exit?: boolean;
|
||||
}
|
||||
|
||||
/** Top-level `[watch]` section (kimi-code 2.0.1+). The key was added in
|
||||
* 2.0.1 and its default flipped to off in 2.0.2, so an absent key reads as
|
||||
* disabled. `KIMI_CODE_WATCH` overrides the config value at runtime. */
|
||||
export interface WatchConfig {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface PermissionRule {
|
||||
decision?: "allow" | "deny" | "ask";
|
||||
scope?: string;
|
||||
@@ -150,6 +169,8 @@ export interface AgentSettings {
|
||||
thinking?: ThinkingConfig;
|
||||
loop_control?: LoopControlConfig;
|
||||
background?: BackgroundConfig;
|
||||
/** Top-level `[watch]` section (kimi-code 2.0.1+). */
|
||||
watch?: WatchConfig;
|
||||
permission?: {
|
||||
rules?: PermissionRule[];
|
||||
/** kimi-code `[permission]` key. Default true when the key is
|
||||
|
||||
@@ -39,6 +39,12 @@ export interface ActionResponse {
|
||||
deleted?: boolean | null;
|
||||
}
|
||||
|
||||
export interface BulkArchiveResult {
|
||||
archived: number;
|
||||
skipped: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface PreviewMessage {
|
||||
role: string;
|
||||
time: number | null;
|
||||
|
||||
Reference in new issue
Block a user