Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f421b4e44 | ||
|
|
00cf194b5c | ||
|
|
b9002281a3 | ||
|
|
0ea9e5b3de | ||
|
|
36e64a1f72 | ||
|
|
9ce1ed65b0 | ||
|
|
33a27ba31b | ||
|
|
7bc2dee8b2 |
No files matched your search
@@ -253,6 +253,17 @@ KimiSwitch 现有 `usageKinds` 枚举是**每家中转站一个 Rust 函数**的
|
|||||||
|
|
||||||
## 5. 新增能力实现清单(改动点)
|
## 5. 新增能力实现清单(改动点)
|
||||||
|
|
||||||
|
> 实施状态更新(2026-09-13):**Sub2API 兼容已实现(`balance:sub2api`)**。
|
||||||
|
> 实测澄清:`codingplan.site`(主域)**不是** NewAPI 而是 Wei-Shaw/sub2api 面板
|
||||||
|
> (NewAPI 端点 `/api/status`、`/api/user/self` 全 404;`/api/v1/settings/public`
|
||||||
|
> 返回 `data.affiliate_enabled` 指纹命中;`/v1/usage`、`/v1/sub2api/billing` 为
|
||||||
|
> API-key 网关端点)。`ai.codingplan.site` 才是 NewAPI 实例。detect_provider
|
||||||
|
> 已按此拆分家族规则。Sub2API 查询复用推理 sk- key(`GET {base}/v1/usage`,
|
||||||
|
> Bearer),解析三种 mode:quota_limited(quota + 5h/1d/7d rate_limits)、
|
||||||
|
> unrestricted 订阅组(daily/weekly/monthly usage/limit USD)、钱包余额。
|
||||||
|
> UsageConfigModal 模板加 "sub2api"(无需凭据,可选 Base URL 覆盖)。
|
||||||
|
> 验证:cargo test 124 通过(含 6 个 sub2api 解析用例)、tsc 零错误。
|
||||||
|
|
||||||
> 实施状态(2026-07-31):**P0 已实现,未验证、未提交**。
|
> 实施状态(2026-07-31):**P0 已实现,未验证、未提交**。
|
||||||
> 展示方案已确认为 **A(供应商卡片底部 UsageFooter,零前端改动)**。
|
> 展示方案已确认为 **A(供应商卡片底部 UsageFooter,零前端改动)**。
|
||||||
> 改动文件:`src-tauri/src/services/balance.rs`(query_kimi/parse_kimi + 单测)、`src-tauri/src/services/mod.rs`(枚举/路由/detect + 单测)、`src/config/providerPresets.ts`(UsageKind/SUPPORTED_USAGE_KINDS + moonshot 预设挂 `balance:kimi`)。
|
> 改动文件:`src-tauri/src/services/balance.rs`(query_kimi/parse_kimi + 单测)、`src-tauri/src/services/mod.rs`(枚举/路由/detect + 单测)、`src/config/providerPresets.ts`(UsageKind/SUPPORTED_USAGE_KINDS + moonshot 预设挂 `balance:kimi`)。
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "kimiswitch",
|
"name": "kimiswitch",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.7.15",
|
"version": "0.7.18",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+771
-379
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,7 @@
|
|||||||
|
# KimiSwitch v0.7.16
|
||||||
|
|
||||||
|
## 适配
|
||||||
|
|
||||||
|
- **适配 kimi-code 0.42.0 实验 flag 镜像(9→6)**:移除已转正/删除的 4 个开关(子代理次主力模型——模型池恒开、MiniDB 读模型——改由 `[database]` 配置节、远程控制、搜索 Worker),新增 `notify_user`(实验性 Updates 面板,默认关闭);环境变量探测清单同步;老配置残留旧键上游静默忽略,无需迁移
|
||||||
|
- 用量统计「模型用量统计」图表固定最大高度 360px 并支持滚动,与右侧模型用量表一致
|
||||||
|
- models-dev 快照同步至 2026-09-10(7615 模型 / 213 供应商),`public/` 前端静态目录同步
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# KimiSwitch v0.7.17
|
||||||
|
|
||||||
|
## 新增
|
||||||
|
|
||||||
|
- **账单查询适配 Sub2API 中转站(`balance:sub2api`)**:支持 Wei-Shaw/sub2api 面板的余额/额度查询(`GET {base}/v1/usage`),自动复用推理 `sk-` API Key,**无需**网页后台 Access Token;解析三种模式——单 Key 总额度(quota)、5 小时/每日/7 天速率窗口(含重置倒计时)、订阅组日/周/月额度与钱包余额(USD)
|
||||||
|
- 用量配置面板新增「Sub2API 中转站」模板,可选覆盖查询地址(Base URL)
|
||||||
|
- `detect_provider` 家族规则拆分:`codingplan.site` 主域识别为 Sub2API,`ai.codingplan.site` 识别为 NewAPI(修正此前主域被误判为 NewAPI 导致查询 404 的问题)
|
||||||
|
- **模型映射「获取模型列表」新增批量选择**:发现列表标题右侧新增「全选 / 取消全选 / 反选」三个按钮,配合已有的已添加标记与「添加选中的」,批量接入中转站模型列表更省事
|
||||||
|
|
||||||
|
## 修复
|
||||||
|
|
||||||
|
- 修复 `codingplan.site`(Sub2API 面板)走 NewAPI 模板查询必然失败的问题——NewAPI 模板(Access Token + User ID)仍适用于 NewAPI / OneAPI 站点,两者互不影响
|
||||||
|
|
||||||
|
## 备注
|
||||||
|
|
||||||
|
- Sub2API 查询的金额单位由上游硬编码为 USD;速率窗口标签沿用现有本地化(5 小时 / 每日 / 7 天)
|
||||||
@@ -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,65 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Publish a release (notes + assets) to the domestic Gitea mirror
|
||||||
|
# (git.codingplan.site/admin/KimiCodeSwitch).
|
||||||
|
#
|
||||||
|
# Usage: publish-gitea.sh <tag> <asset> [asset...]
|
||||||
|
# tag : e.g. v0.7.15; notes read from release-notes-<tag>.md
|
||||||
|
# assets : files to attach (MSI, install-macos.sh, ...)
|
||||||
|
#
|
||||||
|
# Auth: reuses the stored git credential for git.codingplan.site
|
||||||
|
# (`git credential fill`); the secret never appears in argv or output.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
GITEA="https://git.codingplan.site/api/v1"
|
||||||
|
REPO="admin/KimiCodeSwitch"
|
||||||
|
TAG="${1:?usage: publish-gitea.sh <tag> <asset> [asset...]}"
|
||||||
|
shift
|
||||||
|
|
||||||
|
NOTES_FILE="release-notes-${TAG}.md"
|
||||||
|
json_escape() {
|
||||||
|
python - "$1" <<'PYEOF'
|
||||||
|
import json, sys
|
||||||
|
print(json.dumps(open(sys.argv[1], encoding="utf-8").read()))
|
||||||
|
PYEOF
|
||||||
|
}
|
||||||
|
if [[ -f "$NOTES_FILE" ]]; then
|
||||||
|
BODY=$(json_escape "$NOTES_FILE")
|
||||||
|
else
|
||||||
|
BODY="\"${TAG}\""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Resolve stored git credential for this host (no secret echoed).
|
||||||
|
CRED=$(printf 'protocol=https\nhost=git.codingplan.site\n\n' | git credential fill)
|
||||||
|
USER=$(grep '^username=' <<<"$CRED" | cut -d= -f2-)
|
||||||
|
PASS=$(grep '^password=' <<<"$CRED" | cut -d= -f2-)
|
||||||
|
[[ -n "$USER" && -n "$PASS" ]] || { echo "no stored credential for git.codingplan.site" >&2; exit 1; }
|
||||||
|
AUTH=(-u "$USER:$PASS")
|
||||||
|
|
||||||
|
# Create (or reuse) the release for this tag.
|
||||||
|
EXISTING=$(curl -sf --max-time 20 "${AUTH[@]}" "$GITEA/repos/$REPO/releases/tags/$TAG" || true)
|
||||||
|
if [[ -n "$EXISTING" && "$EXISTING" != *"message"* ]]; then
|
||||||
|
RELEASE_ID=$(grep -o '"id":[0-9]*' <<<"$EXISTING" | head -1 | cut -d: -f2)
|
||||||
|
echo "release $TAG exists (id=$RELEASE_ID), attaching assets"
|
||||||
|
else
|
||||||
|
CREATED=$(curl -sf --max-time 20 "${AUTH[@]}" \
|
||||||
|
-H 'Content-Type: application/json' -X POST \
|
||||||
|
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":$BODY,\"draft\":false,\"prerelease\":false}" \
|
||||||
|
"$GITEA/repos/$REPO/releases")
|
||||||
|
RELEASE_ID=$(grep -o '"id":[0-9]*' <<<"$CREATED" | head -1 | cut -d: -f2)
|
||||||
|
echo "created release $TAG (id=$RELEASE_ID)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
for ASSET in "$@"; do
|
||||||
|
[[ -f "$ASSET" ]] || { echo "skip missing asset: $ASSET" >&2; continue; }
|
||||||
|
NAME=$(basename "$ASSET" | tr ' ' '.')
|
||||||
|
STAGED=$(mktemp "${TMPDIR:-/tmp}/upload.XXXXXX.${NAME##*.}")
|
||||||
|
cp "$ASSET" "$STAGED"
|
||||||
|
CODE=$(curl -s -o /dev/null -w '%{http_code}' --max-time 600 "${AUTH[@]}" \
|
||||||
|
-F "attachment=@$STAGED" \
|
||||||
|
"$GITEA/repos/$REPO/releases/$RELEASE_ID/assets?name=$NAME")
|
||||||
|
rm -f "$STAGED"
|
||||||
|
echo "upload $NAME -> HTTP $CODE"
|
||||||
|
[[ "$CODE" == 2* ]] || { echo "upload failed: $NAME" >&2; exit 1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "done: https://git.codingplan.site/$REPO/releases/tag/$TAG"
|
||||||
Generated
+1
-1
@@ -1978,7 +1978,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kimiswitch"
|
name = "kimiswitch"
|
||||||
version = "0.7.14"
|
version = "0.7.18"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "kimiswitch"
|
name = "kimiswitch"
|
||||||
version = "0.7.15"
|
version = "0.7.18"
|
||||||
description = "Kimi Switch - model config manager"
|
description = "Kimi Switch - model config manager"
|
||||||
authors = ["codingplan.site"]
|
authors = ["codingplan.site"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
+19
-19
@@ -690,19 +690,20 @@ pub async fn query_provider_usage(
|
|||||||
|
|
||||||
let base_url = resolve_base_url(provider);
|
let base_url = resolve_base_url(provider);
|
||||||
|
|
||||||
// NewAPI template: query the gateway's own usage endpoints with its
|
// NewAPI / Sub2API template: query the gateway's own usage endpoints.
|
||||||
// dedicated credentials, bypassing usage_kinds entirely.
|
// NewAPI needs web-console credentials (accessToken + userId); Sub2API
|
||||||
|
// reuses the inference API key directly — both bypass usage_kinds.
|
||||||
if let Some(cfg) = &provider.usage_config {
|
if let Some(cfg) = &provider.usage_config {
|
||||||
if cfg.template_type == UsageConfig::TEMPLATE_NEWAPI {
|
let template_kind = match cfg.template_type.as_str() {
|
||||||
|
UsageConfig::TEMPLATE_NEWAPI => Some(UsageKind::BalanceNewapi),
|
||||||
|
UsageConfig::TEMPLATE_SUB2API => Some(UsageKind::BalanceSub2Api),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(kind) = template_kind {
|
||||||
// Err = transient (network) → propagate for retry, same semantics
|
// Err = transient (network) → propagate for retry, same semantics
|
||||||
// as the kinds loop below. Config errors surface as Ok(failure).
|
// as the kinds loop below. Config errors surface as Ok(failure).
|
||||||
let result = services::query_kind(
|
let result =
|
||||||
UsageKind::BalanceNewapi,
|
services::query_kind(kind, &base_url, &api_key, Some(cfg)).await?;
|
||||||
&base_url,
|
|
||||||
&api_key,
|
|
||||||
Some(cfg),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
if result.success {
|
if result.success {
|
||||||
usage_cache()
|
usage_cache()
|
||||||
.lock()
|
.lock()
|
||||||
@@ -1335,7 +1336,7 @@ pub async fn kimi_oauth_poll(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Experimental feature env-var probe (Kimi Code secondary model etc.)
|
// Experimental feature env-var probe (Kimi Code experimental flags)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Read the Kimi Code environment variables that are currently set (non-empty)
|
/// Read the Kimi Code environment variables that are currently set (non-empty)
|
||||||
@@ -1348,6 +1349,11 @@ pub async fn kimi_oauth_poll(
|
|||||||
/// are probed too — the latter lets AgentSettingsPanel decide whether to
|
/// are probed too — the latter lets AgentSettingsPanel decide whether to
|
||||||
/// dual-write the v1 engine's loop_control keys.
|
/// dual-write the v1 engine's loop_control keys.
|
||||||
///
|
///
|
||||||
|
/// As of Kimi Code 0.42.0 the upstream registry holds 6 experimental flags;
|
||||||
|
/// `secondary-model`, `persistence_minidb_readmodel`, `remote-control` and
|
||||||
|
/// `search_worker` have been promoted or removed upstream and are no longer
|
||||||
|
/// probed here.
|
||||||
|
///
|
||||||
/// The flag id → env var mapping mirrors `EXPERIMENTAL_FLAGS` in
|
/// The flag id → env var mapping mirrors `EXPERIMENTAL_FLAGS` in
|
||||||
/// src/lib/subagent-settings.ts; keep both lists in sync.
|
/// src/lib/subagent-settings.ts; keep both lists in sync.
|
||||||
///
|
///
|
||||||
@@ -1355,19 +1361,13 @@ pub async fn kimi_oauth_poll(
|
|||||||
/// the raw values are returned and the truthy check happens on the frontend.
|
/// the raw values are returned and the truthy check happens on the frontend.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_experimental_env_status() -> HashMap<String, String> {
|
pub fn get_experimental_env_status() -> HashMap<String, String> {
|
||||||
const VARS: [(&str, &str); 11] = [
|
const VARS: [(&str, &str); 8] = [
|
||||||
("secondary-model", "KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL"),
|
|
||||||
("tool-select", "KIMI_CODE_EXPERIMENTAL_TOOL_SELECT"),
|
("tool-select", "KIMI_CODE_EXPERIMENTAL_TOOL_SELECT"),
|
||||||
(
|
|
||||||
"persistence_minidb_readmodel",
|
|
||||||
"KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL",
|
|
||||||
),
|
|
||||||
("tower", "KIMI_CODE_EXPERIMENTAL_TOWER"),
|
("tower", "KIMI_CODE_EXPERIMENTAL_TOWER"),
|
||||||
("subagent_fork", "KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK"),
|
("subagent_fork", "KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK"),
|
||||||
("wait_for", "KIMI_CODE_EXPERIMENTAL_WAIT_FOR"),
|
("wait_for", "KIMI_CODE_EXPERIMENTAL_WAIT_FOR"),
|
||||||
("auto_session_title", "KIMI_CODE_EXPERIMENTAL_AUTO_SESSION_TITLE"),
|
("auto_session_title", "KIMI_CODE_EXPERIMENTAL_AUTO_SESSION_TITLE"),
|
||||||
("remote-control", "KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL"),
|
("notify_user", "KIMI_CODE_EXPERIMENTAL_NOTIFY_USER"),
|
||||||
("search_worker", "KIMI_CODE_EXPERIMENTAL_SEARCH_WORKER"),
|
|
||||||
// Non-flag probes consumed by the frontend:
|
// Non-flag probes consumed by the frontend:
|
||||||
("master", "KIMI_CODE_EXPERIMENTAL_FLAG"),
|
("master", "KIMI_CODE_EXPERIMENTAL_FLAG"),
|
||||||
("legacy", "KIMI_CODE_LEGACY_FLAG"),
|
("legacy", "KIMI_CODE_LEGACY_FLAG"),
|
||||||
|
|||||||
+554
-10
@@ -1,4 +1,5 @@
|
|||||||
use chrono::{DateTime, Datelike, Local, TimeZone};
|
use chrono::{DateTime, Datelike, Local, TimeZone};
|
||||||
|
use crate::db::{ArchivedSessionSnapshot, DayModelStat, DayStat};
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -277,6 +278,15 @@ pub struct ActionResponse {
|
|||||||
pub deleted: Option<bool>,
|
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)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct PreviewResult {
|
pub struct PreviewResult {
|
||||||
@@ -853,8 +863,30 @@ fn scan_usage(home: &Path) -> (Vec<UsageRecord>, ScanMeta) {
|
|||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(_) => continue,
|
Err(_) => continue,
|
||||||
};
|
};
|
||||||
|
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));
|
||||||
|
let count = records.len();
|
||||||
|
(records, ScanMeta {
|
||||||
|
files_scanned, lines_seen, record_count: count,
|
||||||
|
home: home.to_string_lossy().to_string(),
|
||||||
|
sessions_root: root.to_string_lossy().to_string(),
|
||||||
|
errors: errors.into_iter().take(20).collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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() {
|
for line in content.lines() {
|
||||||
lines_seen += 1;
|
|
||||||
if !line.contains("\"usage.record\"") { continue; }
|
if !line.contains("\"usage.record\"") { continue; }
|
||||||
let obj: serde_json::Value = match serde_json::from_str(line) {
|
let obj: serde_json::Value = match serde_json::from_str(line) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
@@ -914,16 +946,109 @@ fn scan_usage(home: &Path) -> (Vec<UsageRecord>, ScanMeta) {
|
|||||||
price_id: pid,
|
price_id: pid,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
records
|
||||||
}
|
}
|
||||||
|
|
||||||
records.sort_by(|a, b| b.time.cmp(&a.time));
|
/// Aggregate raw usage records into the per-day, per-model snapshot stored
|
||||||
let count = records.len();
|
/// for an archived session.
|
||||||
(records, ScanMeta {
|
fn snapshot_from_records(records: &[UsageRecord]) -> Vec<DayStat> {
|
||||||
files_scanned, lines_seen, record_count: count,
|
let mut by_day: HashMap<String, (u64, HashMap<String, DayModelStat>)> = HashMap::new();
|
||||||
home: home.to_string_lossy().to_string(),
|
for r in records {
|
||||||
sessions_root: root.to_string_lossy().to_string(),
|
let entry = by_day
|
||||||
errors: errors.into_iter().take(20).collect(),
|
.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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1332,6 +1457,147 @@ fn set_session_archived_meta(session_dir: &Path, archived: bool) -> Result<(), S
|
|||||||
Ok(())
|
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 {
|
fn humanize_workspace(id: &str) -> String {
|
||||||
let re = Regex::new(r"^wd_(.+)_[0-9a-fA-F]{8,}$").unwrap();
|
let re = Regex::new(r"^wd_(.+)_[0-9a-fA-F]{8,}$").unwrap();
|
||||||
if let Some(caps) = re.captures(id) {
|
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 r = range.unwrap_or_else(|| "30d".into());
|
||||||
|
|
||||||
let (records, meta) = scan_usage_cached(&home, refresh);
|
let (records, meta) = scan_usage_cached(&home, refresh);
|
||||||
|
let records = records_with_archive_merge(&home, records);
|
||||||
let t1 = std::time::Instant::now();
|
let t1 = std::time::Instant::now();
|
||||||
let stats = aggregate(&records, &r, now_ms);
|
let stats = aggregate(&records, &r, now_ms);
|
||||||
let all_stats = aggregate(&records, "all", 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> {
|
pub fn get_day_detail(home_override: Option<String>, date: String) -> Option<DailyRow> {
|
||||||
let home = resolve_kimi_home(home_override);
|
let home = resolve_kimi_home(home_override);
|
||||||
let (records, _) = scan_usage_cached(&home, false);
|
let (records, _) = scan_usage_cached(&home, false);
|
||||||
|
let records = records_with_archive_merge(&home, records);
|
||||||
build_day_detail(&date, &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)
|
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]
|
#[tauri::command]
|
||||||
pub fn unarchive_session(home_override: Option<String>, workspace_id: String, session_id: String) -> Result<ActionResponse, String> {
|
pub fn unarchive_session(home_override: Option<String>, workspace_id: String, session_id: String) -> Result<ActionResponse, String> {
|
||||||
let home = resolve_kimi_home(home_override);
|
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");
|
let (id, _, input, output, est) = match_price("CodingPlan.site/deepseek-v4-flash");
|
||||||
assert!(!est, "resolved via models.dev, not estimated");
|
assert!(!est, "resolved via models.dev, not estimated");
|
||||||
assert_eq!(id, "deepseek/deepseek-v4-flash");
|
assert_eq!(id, "deepseek/deepseek-v4-flash");
|
||||||
assert_eq!(input, 0.14);
|
assert_eq!(input, 0.15);
|
||||||
assert_eq!(output, 0.28);
|
assert_eq!(output, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2432,3 +2708,271 @@ mod range_and_model_totals_tests {
|
|||||||
assert_eq!(total, 3 * 200);
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ use std::path::PathBuf;
|
|||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
use rusqlite::{params, Connection};
|
use rusqlite::{params, Connection};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::models::{Agent, Config, Model, Provider, ProviderType};
|
use crate::models::{Agent, Config, Model, Provider, ProviderType};
|
||||||
@@ -23,6 +24,13 @@ pub fn kimi_switch_data_dir() -> PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn db_path() -> 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")
|
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
|
/// configuration. Safe to call on every startup — it is a no-op once the new
|
||||||
/// directory exists.
|
/// directory exists.
|
||||||
fn migrate_legacy_data_dir() {
|
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();
|
let new_dir = kimi_switch_data_dir();
|
||||||
if new_dir.exists() {
|
if new_dir.exists() {
|
||||||
return;
|
return;
|
||||||
@@ -106,6 +118,24 @@ 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).
|
// 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.
|
// SQLite has no ADD COLUMN IF NOT EXISTS, so ignore duplicate-column errors.
|
||||||
for stmt in [
|
for stmt in [
|
||||||
@@ -330,6 +360,104 @@ pub fn delete_setting_pub(key: &str) -> DbResult<()> {
|
|||||||
Ok(())
|
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 {
|
fn provider_type_for_str(s: &str) -> ProviderType {
|
||||||
ProviderType::from_kimi_type(s)
|
ProviderType::from_kimi_type(s)
|
||||||
}
|
}
|
||||||
@@ -121,6 +121,7 @@ pub fn run() {
|
|||||||
dashboard::get_day_detail,
|
dashboard::get_day_detail,
|
||||||
dashboard::list_sessions,
|
dashboard::list_sessions,
|
||||||
dashboard::archive_session,
|
dashboard::archive_session,
|
||||||
|
dashboard::archive_sessions_before,
|
||||||
dashboard::unarchive_session,
|
dashboard::unarchive_session,
|
||||||
dashboard::delete_session,
|
dashboard::delete_session,
|
||||||
dashboard::delete_workspace,
|
dashboard::delete_workspace,
|
||||||
|
|||||||
@@ -290,6 +290,7 @@ pub struct UsageConfig {
|
|||||||
impl UsageConfig {
|
impl UsageConfig {
|
||||||
pub const TEMPLATE_AUTO: &'static str = "auto";
|
pub const TEMPLATE_AUTO: &'static str = "auto";
|
||||||
pub const TEMPLATE_NEWAPI: &'static str = "newapi";
|
pub const TEMPLATE_NEWAPI: &'static str = "newapi";
|
||||||
|
pub const TEMPLATE_SUB2API: &'static str = "sub2api";
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for Config {
|
impl std::fmt::Debug for Config {
|
||||||
|
|||||||
@@ -521,6 +521,159 @@ fn parse_newapi(
|
|||||||
}])
|
}])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Sub2API (Wei-Shaw/sub2api) 中转站 ────────────────────────
|
||||||
|
// GET {base_url}/v1/usage
|
||||||
|
// 头:Authorization: Bearer <api_key>(就是推理用的 sk- key,无需额外凭据)
|
||||||
|
// Response(三种 mode):
|
||||||
|
// - quota_limited:key 设了总额度/速率限制 → quota{limit,used,remaining} +
|
||||||
|
// rate_limits[{window: 5h|1d|7d, limit, used, remaining, reset_at}]
|
||||||
|
// - unrestricted + subscription:订阅组 → planName + subscription 的
|
||||||
|
// daily/weekly/monthly usage/limit(USD)
|
||||||
|
// - unrestricted + 钱包:planName="钱包余额" → balance / remaining(USD)
|
||||||
|
//
|
||||||
|
// 站点指纹(可选探测):GET /api/v1/settings/public 返回 data.affiliate_enabled
|
||||||
|
// 即为 sub2api 面板。注意 NewAPI 的 /api/user/self、/api/status 在 sub2api 上
|
||||||
|
// 全部 404 —— 两套中转站协议不互通。
|
||||||
|
|
||||||
|
/// 把供应商 base_url 归一成站点根(去掉结尾 `/` 与 `/v1`),网关路由挂在根路径。
|
||||||
|
fn sub2api_site_root(base_url: &str) -> String {
|
||||||
|
let mut base = base_url.trim_end_matches('/');
|
||||||
|
if let Some(stripped) = base.strip_suffix("/v1") {
|
||||||
|
base = stripped;
|
||||||
|
}
|
||||||
|
base.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn query_sub2api(
|
||||||
|
base_url: &str,
|
||||||
|
api_key: &str,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Result<UsageResult, String> {
|
||||||
|
let url = format!("{}/v1/usage", sub2api_site_root(base_url));
|
||||||
|
match get_json(&url, api_key, AuthStyle::Bearer, timeout).await? {
|
||||||
|
Fetched::Body(body) => Ok(parse_sub2api(&body)),
|
||||||
|
Fetched::Failed(err) => Ok(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 速率窗口名 → 本地化 tier 名(前端 planLabel 会翻译;five_hour /
|
||||||
|
/// weekly_limit 已有映射,daily_limit 新增)。
|
||||||
|
fn sub2api_window_tier(window: &str) -> &'static str {
|
||||||
|
match window {
|
||||||
|
"5h" => "five_hour",
|
||||||
|
"1d" => "daily_limit",
|
||||||
|
"7d" => "weekly_limit",
|
||||||
|
_ => "window",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_sub2api(body: &serde_json::Value) -> UsageResult {
|
||||||
|
let is_valid = body
|
||||||
|
.get("isValid")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
let mode = body.get("mode").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
if mode.is_empty() {
|
||||||
|
return UsageResult::failure(
|
||||||
|
"Missing 'mode' field in response (not a sub2api gateway?)".to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out: Vec<UsageData> = Vec::new();
|
||||||
|
|
||||||
|
// key 总额度(quota_limited 模式才有)
|
||||||
|
if let Some(quota) = body.get("quota") {
|
||||||
|
if let Some(limit) = parse_f64_field(quota, "limit").filter(|&l| l > 0.0) {
|
||||||
|
let used = parse_f64_field(quota, "used").unwrap_or(0.0);
|
||||||
|
let remaining = parse_f64_field(quota, "remaining").unwrap_or(limit - used);
|
||||||
|
out.push(UsageData {
|
||||||
|
plan_name: Some("Sub2API".to_string()),
|
||||||
|
remaining: Some(remaining),
|
||||||
|
total: Some(limit),
|
||||||
|
used: Some(used),
|
||||||
|
unit: Some("USD".to_string()),
|
||||||
|
is_valid: Some(is_valid && remaining > 0.0),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 速率窗口(5h / 1d / 7d;reset_at 为 RFC3339,窗口过期时缺省)
|
||||||
|
if let Some(rates) = body.get("rate_limits").and_then(|v| v.as_array()) {
|
||||||
|
for w in rates {
|
||||||
|
let window = w.get("window").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let Some(limit) = parse_f64_field(w, "limit").filter(|&l| l > 0.0) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let used = parse_f64_field(w, "used").unwrap_or(0.0);
|
||||||
|
let remaining = parse_f64_field(w, "remaining").unwrap_or(limit - used);
|
||||||
|
out.push(UsageData {
|
||||||
|
plan_name: Some(sub2api_window_tier(window).to_string()),
|
||||||
|
remaining: Some(remaining),
|
||||||
|
total: Some(limit),
|
||||||
|
used: Some(used),
|
||||||
|
unit: Some("USD".to_string()),
|
||||||
|
is_valid: Some(is_valid && remaining > 0.0),
|
||||||
|
resets_at: w
|
||||||
|
.get("reset_at")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(str::to_string),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// unrestricted 订阅组:daily/weekly/monthly 三窗口(usage_usd vs limit_usd)
|
||||||
|
if mode == "unrestricted" {
|
||||||
|
if let Some(sub) = body.get("subscription") {
|
||||||
|
for (prefix, tier) in [
|
||||||
|
("daily", "daily_limit"),
|
||||||
|
("weekly", "weekly_limit"),
|
||||||
|
("monthly", "monthly_limit"),
|
||||||
|
] {
|
||||||
|
let Some(usage) = parse_f64_field(sub, &format!("{prefix}_usage_usd")) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(limit) = parse_f64_field(sub, &format!("{prefix}_limit_usd"))
|
||||||
|
.filter(|&l| l > 0.0)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let remaining = limit - usage;
|
||||||
|
out.push(UsageData {
|
||||||
|
plan_name: Some(tier.to_string()),
|
||||||
|
remaining: Some(remaining),
|
||||||
|
total: Some(limit),
|
||||||
|
used: Some(usage),
|
||||||
|
unit: Some("USD".to_string()),
|
||||||
|
is_valid: Some(is_valid && remaining > 0.0),
|
||||||
|
resets_at: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 钱包余额(unrestricted 非订阅)或以上全部缺失时的兜底
|
||||||
|
if out.is_empty() {
|
||||||
|
if let Some(balance) = parse_f64_field(body, "balance")
|
||||||
|
.or_else(|| parse_f64_field(body, "remaining"))
|
||||||
|
{
|
||||||
|
out.push(UsageData {
|
||||||
|
plan_name: Some("Sub2API".to_string()),
|
||||||
|
remaining: Some(balance),
|
||||||
|
unit: Some("USD".to_string()),
|
||||||
|
is_valid: Some(is_valid),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.is_empty() {
|
||||||
|
UsageResult::failure("No quota/rate-limit/balance fields in response".to_string())
|
||||||
|
} else {
|
||||||
|
UsageResult::ok(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -688,4 +841,105 @@ mod tests {
|
|||||||
assert_eq!(data[0].remaining, Some(1.0));
|
assert_eq!(data[0].remaining, Some(1.0));
|
||||||
assert_eq!(data[0].used, Some(0.2));
|
assert_eq!(data[0].used, Some(0.2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sub2api_site_root_strips_v1_and_slash() {
|
||||||
|
assert_eq!(sub2api_site_root("https://x.com"), "https://x.com");
|
||||||
|
assert_eq!(sub2api_site_root("https://x.com/"), "https://x.com");
|
||||||
|
assert_eq!(sub2api_site_root("https://x.com/v1"), "https://x.com");
|
||||||
|
assert_eq!(sub2api_site_root("https://x.com/v1/"), "https://x.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sub2api_quota_limited_parses_quota_and_windows() {
|
||||||
|
let body = json!({
|
||||||
|
"mode": "quota_limited",
|
||||||
|
"isValid": true,
|
||||||
|
"quota": { "limit": 50.0, "used": 20.0, "remaining": 30.0, "unit": "USD" },
|
||||||
|
"remaining": 30.0,
|
||||||
|
"unit": "USD",
|
||||||
|
"rate_limits": [
|
||||||
|
{ "window": "5h", "limit": 10.0, "used": 4.0, "remaining": 6.0,
|
||||||
|
"window_start": "2026-09-13T00:00:00Z", "reset_at": "2026-09-13T05:00:00Z" },
|
||||||
|
{ "window": "1d", "limit": 30.0, "used": 10.0, "remaining": 20.0,
|
||||||
|
"window_start": "2026-09-13T00:00:00Z" }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
let result = parse_sub2api(&body);
|
||||||
|
assert!(result.success);
|
||||||
|
let data = result.data.unwrap();
|
||||||
|
assert_eq!(data.len(), 3);
|
||||||
|
assert_eq!(data[0].plan_name.as_deref(), Some("Sub2API"));
|
||||||
|
assert_eq!(data[0].remaining, Some(30.0));
|
||||||
|
assert_eq!(data[0].total, Some(50.0));
|
||||||
|
assert_eq!(data[0].used, Some(20.0));
|
||||||
|
assert_eq!(data[0].unit.as_deref(), Some("USD"));
|
||||||
|
assert_eq!(data[1].plan_name.as_deref(), Some("five_hour"));
|
||||||
|
assert_eq!(data[1].remaining, Some(6.0));
|
||||||
|
assert_eq!(data[1].resets_at.as_deref(), Some("2026-09-13T05:00:00Z"));
|
||||||
|
assert_eq!(data[2].plan_name.as_deref(), Some("daily_limit"));
|
||||||
|
assert_eq!(data[2].resets_at, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sub2api_subscription_windows() {
|
||||||
|
let body = json!({
|
||||||
|
"mode": "unrestricted",
|
||||||
|
"isValid": true,
|
||||||
|
"planName": "claude-pro",
|
||||||
|
"unit": "USD",
|
||||||
|
"subscription": {
|
||||||
|
"daily_usage_usd": 1.0, "daily_limit_usd": 10.0,
|
||||||
|
"weekly_usage_usd": 8.0, "weekly_limit_usd": 30.0,
|
||||||
|
"monthly_usage_usd": 55.0, "monthly_limit_usd": 60.0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let result = parse_sub2api(&body);
|
||||||
|
assert!(result.success);
|
||||||
|
let data = result.data.unwrap();
|
||||||
|
assert_eq!(data.len(), 3);
|
||||||
|
assert_eq!(data[0].plan_name.as_deref(), Some("daily_limit"));
|
||||||
|
assert_eq!(data[0].remaining, Some(9.0));
|
||||||
|
assert_eq!(data[1].plan_name.as_deref(), Some("weekly_limit"));
|
||||||
|
assert_eq!(data[1].remaining, Some(22.0));
|
||||||
|
assert_eq!(data[2].plan_name.as_deref(), Some("monthly_limit"));
|
||||||
|
assert_eq!(data[2].remaining, Some(5.0));
|
||||||
|
assert_eq!(data[2].is_valid, Some(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sub2api_wallet_balance() {
|
||||||
|
let body = json!({
|
||||||
|
"mode": "unrestricted",
|
||||||
|
"isValid": true,
|
||||||
|
"planName": "钱包余额",
|
||||||
|
"remaining": 12.5,
|
||||||
|
"unit": "USD",
|
||||||
|
"balance": 12.5
|
||||||
|
});
|
||||||
|
let data = parse_sub2api(&body).data.unwrap();
|
||||||
|
assert_eq!(data.len(), 1);
|
||||||
|
assert_eq!(data[0].plan_name.as_deref(), Some("Sub2API"));
|
||||||
|
assert_eq!(data[0].remaining, Some(12.5));
|
||||||
|
assert_eq!(data[0].unit.as_deref(), Some("USD"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sub2api_missing_mode_is_deterministic_failure() {
|
||||||
|
let body = json!({ "foo": 1 });
|
||||||
|
let result = parse_sub2api(&body);
|
||||||
|
assert!(!result.success);
|
||||||
|
assert!(result.error.unwrap().contains("Missing 'mode'"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sub2api_exhausted_window_is_invalid() {
|
||||||
|
let body = json!({
|
||||||
|
"mode": "quota_limited",
|
||||||
|
"isValid": true,
|
||||||
|
"quota": { "limit": 10.0, "used": 10.0, "remaining": 0.0, "unit": "USD" }
|
||||||
|
});
|
||||||
|
let data = parse_sub2api(&body).data.unwrap();
|
||||||
|
assert_eq!(data[0].is_valid, Some(false));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -23,6 +23,7 @@ pub enum UsageKind {
|
|||||||
BalanceNovita,
|
BalanceNovita,
|
||||||
BalanceKimi,
|
BalanceKimi,
|
||||||
BalanceNewapi,
|
BalanceNewapi,
|
||||||
|
BalanceSub2Api,
|
||||||
PlanKimiCoding,
|
PlanKimiCoding,
|
||||||
PlanZhipu,
|
PlanZhipu,
|
||||||
PlanMinimax,
|
PlanMinimax,
|
||||||
@@ -40,6 +41,7 @@ impl UsageKind {
|
|||||||
UsageKind::BalanceNovita => "balance:novita",
|
UsageKind::BalanceNovita => "balance:novita",
|
||||||
UsageKind::BalanceKimi => "balance:kimi",
|
UsageKind::BalanceKimi => "balance:kimi",
|
||||||
UsageKind::BalanceNewapi => "balance:newapi",
|
UsageKind::BalanceNewapi => "balance:newapi",
|
||||||
|
UsageKind::BalanceSub2Api => "balance:sub2api",
|
||||||
UsageKind::PlanKimiCoding => "plan:kimi_coding",
|
UsageKind::PlanKimiCoding => "plan:kimi_coding",
|
||||||
UsageKind::PlanZhipu => "plan:zhipu",
|
UsageKind::PlanZhipu => "plan:zhipu",
|
||||||
UsageKind::PlanMinimax => "plan:minimax",
|
UsageKind::PlanMinimax => "plan:minimax",
|
||||||
@@ -47,7 +49,7 @@ impl UsageKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const ALL: [UsageKind; 11] = [
|
pub const ALL: [UsageKind; 12] = [
|
||||||
UsageKind::BalanceDeepseek,
|
UsageKind::BalanceDeepseek,
|
||||||
UsageKind::BalanceSiliconflow,
|
UsageKind::BalanceSiliconflow,
|
||||||
UsageKind::BalanceOpenrouter,
|
UsageKind::BalanceOpenrouter,
|
||||||
@@ -55,6 +57,7 @@ impl UsageKind {
|
|||||||
UsageKind::BalanceNovita,
|
UsageKind::BalanceNovita,
|
||||||
UsageKind::BalanceKimi,
|
UsageKind::BalanceKimi,
|
||||||
UsageKind::BalanceNewapi,
|
UsageKind::BalanceNewapi,
|
||||||
|
UsageKind::BalanceSub2Api,
|
||||||
UsageKind::PlanKimiCoding,
|
UsageKind::PlanKimiCoding,
|
||||||
UsageKind::PlanZhipu,
|
UsageKind::PlanZhipu,
|
||||||
UsageKind::PlanMinimax,
|
UsageKind::PlanMinimax,
|
||||||
@@ -74,6 +77,7 @@ impl std::str::FromStr for UsageKind {
|
|||||||
"balance:novita" => UsageKind::BalanceNovita,
|
"balance:novita" => UsageKind::BalanceNovita,
|
||||||
"balance:kimi" => UsageKind::BalanceKimi,
|
"balance:kimi" => UsageKind::BalanceKimi,
|
||||||
"balance:newapi" => UsageKind::BalanceNewapi,
|
"balance:newapi" => UsageKind::BalanceNewapi,
|
||||||
|
"balance:sub2api" => UsageKind::BalanceSub2Api,
|
||||||
"plan:kimi_coding" => UsageKind::PlanKimiCoding,
|
"plan:kimi_coding" => UsageKind::PlanKimiCoding,
|
||||||
"plan:zhipu" => UsageKind::PlanZhipu,
|
"plan:zhipu" => UsageKind::PlanZhipu,
|
||||||
"plan:minimax" => UsageKind::PlanMinimax,
|
"plan:minimax" => UsageKind::PlanMinimax,
|
||||||
@@ -97,10 +101,13 @@ pub fn detect_provider(base_url: &str) -> Vec<UsageKind> {
|
|||||||
if url.contains("openrouter.ai") {
|
if url.contains("openrouter.ai") {
|
||||||
kinds.push(UsageKind::BalanceOpenrouter);
|
kinds.push(UsageKind::BalanceOpenrouter);
|
||||||
}
|
}
|
||||||
// codingplan.site 检测为 NewAPI 实例(本工具仅支持 NewAPI 查询);
|
// codingplan.site 家族:主域(含任意子域除 ai. 外)是 Sub2API 面板
|
||||||
// 家族匹配意味着 ai. 子域也自然命中该规则。
|
// (/api/v1/settings/public 指纹命中,NewAPI 端点全 404);
|
||||||
if url.contains("codingplan.site") {
|
// ai.codingplan.site 才是 NewAPI 实例(/api/status、/api/user/self 可用)。
|
||||||
|
if url.contains("ai.codingplan.site") {
|
||||||
kinds.push(UsageKind::BalanceNewapi);
|
kinds.push(UsageKind::BalanceNewapi);
|
||||||
|
} else if url.contains("codingplan.site") {
|
||||||
|
kinds.push(UsageKind::BalanceSub2Api);
|
||||||
}
|
}
|
||||||
if url.contains("api.stepfun.com") {
|
if url.contains("api.stepfun.com") {
|
||||||
kinds.push(UsageKind::BalanceStepfun);
|
kinds.push(UsageKind::BalanceStepfun);
|
||||||
@@ -186,6 +193,14 @@ pub async fn query_kind(
|
|||||||
.unwrap_or(base_url);
|
.unwrap_or(base_url);
|
||||||
balance::query_newapi(url, token, uid, timeout).await
|
balance::query_newapi(url, token, uid, timeout).await
|
||||||
}
|
}
|
||||||
|
UsageKind::BalanceSub2Api => {
|
||||||
|
// sub2api 复用推理 API Key,无需额外凭据;base_url 同样允许面板覆盖。
|
||||||
|
let url = usage_config
|
||||||
|
.and_then(|c| c.base_url.as_deref())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or(base_url);
|
||||||
|
balance::query_sub2api(url, api_key, timeout).await
|
||||||
|
}
|
||||||
UsageKind::PlanKimiCoding => {
|
UsageKind::PlanKimiCoding => {
|
||||||
coding_plan::query_kimi_coding(base_url, api_key, timeout).await
|
coding_plan::query_kimi_coding(base_url, api_key, timeout).await
|
||||||
}
|
}
|
||||||
@@ -279,15 +294,15 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn detect_provider_codingplan_site_family() {
|
fn detect_provider_codingplan_site_family() {
|
||||||
// codingplan.site 是 NewAPI 实例(本工具仅支持 NewAPI 查询):
|
// codingplan.site 家族分家:主域是 Sub2API 面板(推理 key 直查 /v1/usage),
|
||||||
// 主域与 ai. 子域都命中家族规则,且只映射到 BalanceNewapi。
|
// ai. 子域才是 NewAPI 实例。
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
detect_provider("https://codingplan.site"),
|
detect_provider("https://codingplan.site"),
|
||||||
vec![UsageKind::BalanceNewapi]
|
vec![UsageKind::BalanceSub2Api]
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
detect_provider("https://codingplan.site/v1"),
|
detect_provider("https://codingplan.site/v1"),
|
||||||
vec![UsageKind::BalanceNewapi]
|
vec![UsageKind::BalanceSub2Api]
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
detect_provider("https://ai.codingplan.site"),
|
detect_provider("https://ai.codingplan.site"),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"productName": "Kimi Switch",
|
"productName": "Kimi Switch",
|
||||||
"version": "0.7.15",
|
"version": "0.7.18",
|
||||||
"identifier": "com.kimiswitch.app",
|
"identifier": "com.kimiswitch.app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
@@ -527,6 +527,18 @@ function ModelMapping({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectAllModels = () => {
|
||||||
|
if (!discovered) return;
|
||||||
|
setSelected(new Set(discovered.map((m) => m.id)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const deselectAllModels = () => setSelected(new Set());
|
||||||
|
|
||||||
|
const invertSelection = () => {
|
||||||
|
if (!discovered) return;
|
||||||
|
setSelected((prev) => new Set(discovered.filter((m) => !prev.has(m.id)).map((m) => m.id)));
|
||||||
|
};
|
||||||
|
|
||||||
const handleAddSelected = () => {
|
const handleAddSelected = () => {
|
||||||
if (!discovered || selected.size === 0) return;
|
if (!discovered || selected.size === 0) return;
|
||||||
const existingIds = new Set(models.filter((m) => m.model).map((m) => m.model));
|
const existingIds = new Set(models.filter((m) => m.model).map((m) => m.model));
|
||||||
@@ -596,9 +608,34 @@ function ModelMapping({
|
|||||||
|
|
||||||
{discovered && (
|
{discovered && (
|
||||||
<div className="bg-panel border border-border rounded-xl p-4 space-y-3">
|
<div className="bg-panel border border-border rounded-xl p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-sm font-medium">
|
<div className="text-sm font-medium">
|
||||||
{t("discoveredModels", { count: discovered.length })}
|
{t("discoveredModels", { count: discovered.length })}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={selectAllModels}
|
||||||
|
className="px-2 py-0.5 text-xs border border-border rounded hover:bg-hover-2 text-content-primary transition-colors"
|
||||||
|
>
|
||||||
|
{t("selectAll")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={deselectAllModels}
|
||||||
|
className="px-2 py-0.5 text-xs border border-border rounded hover:bg-hover-2 text-content-primary transition-colors"
|
||||||
|
>
|
||||||
|
{t("deselectAll")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={invertSelection}
|
||||||
|
className="px-2 py-0.5 text-xs border border-border rounded hover:bg-hover-2 text-content-primary transition-colors"
|
||||||
|
>
|
||||||
|
{t("invertSelection")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<label className="flex items-center gap-2 text-sm text-content-primary cursor-pointer">
|
<label className="flex items-center gap-2 text-sm text-content-primary cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
@@ -43,21 +43,15 @@ const EFFORT_LABELS: Record<(typeof EFFORTS)[number], TranslationKey> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const FLAG_LABELS: Record<string, { name: TranslationKey; desc: TranslationKey }> = {
|
const FLAG_LABELS: Record<string, { name: TranslationKey; desc: TranslationKey }> = {
|
||||||
"secondary-model": { name: "flagSecondaryModel", desc: "flagSecondaryModelDesc" },
|
wait_for: { name: "flagWaitFor", desc: "flagWaitForDesc" },
|
||||||
"tool-select": { name: "flagToolSelect", desc: "flagToolSelectDesc" },
|
"tool-select": { name: "flagToolSelect", desc: "flagToolSelectDesc" },
|
||||||
persistence_minidb_readmodel: {
|
notify_user: { name: "flagNotifyUser", desc: "flagNotifyUserDesc" },
|
||||||
name: "flagMinidbReadmodel",
|
|
||||||
desc: "flagMinidbReadmodelDesc",
|
|
||||||
},
|
|
||||||
tower: { name: "flagTower", desc: "flagTowerDesc" },
|
tower: { name: "flagTower", desc: "flagTowerDesc" },
|
||||||
subagent_fork: { name: "flagSubagentFork", desc: "flagSubagentForkDesc" },
|
subagent_fork: { name: "flagSubagentFork", desc: "flagSubagentForkDesc" },
|
||||||
wait_for: { name: "flagWaitFor", desc: "flagWaitForDesc" },
|
|
||||||
auto_session_title: {
|
auto_session_title: {
|
||||||
name: "flagAutoSessionTitle",
|
name: "flagAutoSessionTitle",
|
||||||
desc: "flagAutoSessionTitleDesc",
|
desc: "flagAutoSessionTitleDesc",
|
||||||
},
|
},
|
||||||
"remote-control": { name: "flagRemoteControl", desc: "flagRemoteControlDesc" },
|
|
||||||
search_worker: { name: "flagSearchWorker", desc: "flagSearchWorkerDesc" },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Validation-error i18n key per engine rule, for the pre-write self-check. */
|
/** Validation-error i18n key per engine rule, for the pre-write self-check. */
|
||||||
@@ -185,21 +179,6 @@ export function SubagentSettingsPage({
|
|||||||
// off — upgrading with force would create an invalid force+pool-table combo.
|
// off — upgrading with force would create an invalid force+pool-table combo.
|
||||||
const canUpgrade = !!pool && !isPoolView && pool.model !== undefined && !pool.force;
|
const canUpgrade = !!pool && !isPoolView && pool.model !== undefined && !pool.force;
|
||||||
const masterOn = isMasterEnvOn(env);
|
const masterOn = isMasterEnvOn(env);
|
||||||
const secondaryFlag = EXPERIMENTAL_FLAGS[0]; // "secondary-model"
|
|
||||||
const otherFlags = EXPERIMENTAL_FLAGS.slice(1);
|
|
||||||
|
|
||||||
/** Effective state of the secondary-model flag. Mirrors the kimi-code
|
|
||||||
* 0.40.1 priority: single-flag env > explicit [experimental] value >
|
|
||||||
* master env (force-on only) > upstream default. The master env no
|
|
||||||
* longer locks the toggle — only the flag's own env var does. */
|
|
||||||
const secondaryEnabled = isFlagLockedByEnv(env, secondaryFlag)
|
|
||||||
? forcedEnvValue(env, secondaryFlag)
|
|
||||||
: isExperimentalFlagSet(rawOther, secondaryFlag.id)
|
|
||||||
? flags["secondary-model"] === true
|
|
||||||
: masterOn
|
|
||||||
? true
|
|
||||||
: (secondaryFlag.defaultEnabled ?? false);
|
|
||||||
const secondaryLocked = isFlagLockedByEnv(env, secondaryFlag);
|
|
||||||
|
|
||||||
const aliasList = Object.keys(models).sort();
|
const aliasList = Object.keys(models).sort();
|
||||||
|
|
||||||
@@ -573,41 +552,6 @@ export function SubagentSettingsPage({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="border-t border-border" />
|
<div className="border-t border-border" />
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 text-sm text-content-primary">
|
|
||||||
{t(FLAG_LABELS[secondaryFlag.id].name)}
|
|
||||||
{!secondaryLocked &&
|
|
||||||
!isExperimentalFlagSet(rawOther, secondaryFlag.id) &&
|
|
||||||
secondaryFlag.defaultEnabled && (
|
|
||||||
<span className="shrink-0 text-xs text-blue-600 dark:text-blue-400 border border-blue-500/30 rounded px-1">
|
|
||||||
{t("flagDefaultOn")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-content-muted">
|
|
||||||
{t(FLAG_LABELS[secondaryFlag.id].desc)}
|
|
||||||
<code className="ml-2">{secondaryFlag.envVar}</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{secondaryLocked && (
|
|
||||||
<span className="text-xs text-content-muted shrink-0">
|
|
||||||
🔒 {t("lockedByEnv")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<Toggle
|
|
||||||
checked={secondaryEnabled}
|
|
||||||
disabled={secondaryLocked}
|
|
||||||
ariaLabel={t(FLAG_LABELS[secondaryFlag.id].name)}
|
|
||||||
onChange={(checked) =>
|
|
||||||
onChange(
|
|
||||||
setExperimentalFlag(rawOther, secondaryFlag.id, checked, {
|
|
||||||
explicitFalse: masterOn || secondaryFlag.defaultEnabled === true,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{effectiveDefault !== undefined && (
|
{effectiveDefault !== undefined && (
|
||||||
<div className="rounded-lg border border-border bg-input/40 px-3 py-2.5 space-y-2">
|
<div className="rounded-lg border border-border bg-input/40 px-3 py-2.5 space-y-2">
|
||||||
@@ -636,9 +580,9 @@ export function SubagentSettingsPage({
|
|||||||
<p className="text-xs text-content-muted">{t("secondaryModelPriorityHint")}</p>
|
<p className="text-xs text-content-muted">{t("secondaryModelPriorityHint")}</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Other experimental features (not subagent-related) */}
|
{/* Experimental feature flags — one unified list */}
|
||||||
<Card title={t("otherExperimentalFlags")}>
|
<Card title={t("experimentalFlags")}>
|
||||||
{otherFlags.map(renderFlagRow)}
|
{EXPERIMENTAL_FLAGS.map(renderFlagRow)}
|
||||||
<p className="text-xs text-content-muted">{t("experimentalFlagsHint")}</p>
|
<p className="text-xs text-content-muted">{t("experimentalFlagsHint")}</p>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,12 +30,13 @@ interface UsageConfigModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TEMPLATES: ReadonlyArray<{
|
const TEMPLATES: ReadonlyArray<{
|
||||||
id: "auto" | "newapi";
|
id: "auto" | "newapi" | "sub2api";
|
||||||
labelKey: TranslationKey;
|
labelKey: TranslationKey;
|
||||||
hintKey: TranslationKey;
|
hintKey: TranslationKey;
|
||||||
}> = [
|
}> = [
|
||||||
{ id: "auto", labelKey: "usageTemplateAuto", hintKey: "usageAutoDetectHint" },
|
{ id: "auto", labelKey: "usageTemplateAuto", hintKey: "usageAutoDetectHint" },
|
||||||
{ id: "newapi", labelKey: "usageTemplateNewapi", hintKey: "usageNewapiHint" },
|
{ id: "newapi", labelKey: "usageTemplateNewapi", hintKey: "usageNewapiHint" },
|
||||||
|
{ id: "sub2api", labelKey: "usageTemplateSub2api", hintKey: "usageSub2apiHint" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function defaultConfig(provider: Provider): UsageConfig {
|
function defaultConfig(provider: Provider): UsageConfig {
|
||||||
@@ -225,6 +226,8 @@ export function UsageConfigModal({ open, agent, provider, onClose, onSave }: Usa
|
|||||||
<p className="text-xs text-content-muted">
|
<p className="text-xs text-content-muted">
|
||||||
{cfg.templateType === "newapi"
|
{cfg.templateType === "newapi"
|
||||||
? t("usageNewapiHint")
|
? t("usageNewapiHint")
|
||||||
|
: cfg.templateType === "sub2api"
|
||||||
|
? t("usageSub2apiHint")
|
||||||
: t("usageAutoDetectHint")}
|
: t("usageAutoDetectHint")}
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
@@ -237,9 +240,9 @@ export function UsageConfigModal({ open, agent, provider, onClose, onSave }: Usa
|
|||||||
<span className="text-blue-500">{"{{baseUrl}}"}</span>
|
<span className="text-blue-500">{"{{baseUrl}}"}</span>
|
||||||
<span className="text-content-muted">=</span>
|
<span className="text-content-muted">=</span>
|
||||||
<span className="text-content-primary break-all">
|
<span className="text-content-primary break-all">
|
||||||
{cfg.templateType === "newapi"
|
{cfg.templateType === "auto"
|
||||||
? cfg.baseUrl || provider.base_url || "-"
|
? provider.base_url || "-"
|
||||||
: provider.base_url || "-"}
|
: cfg.baseUrl || provider.base_url || "-"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-xs font-mono">
|
<div className="flex items-center gap-2 text-xs font-mono">
|
||||||
@@ -344,6 +347,21 @@ export function UsageConfigModal({ open, agent, provider, onClose, onSave }: Usa
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* sub2api: no extra credentials (reuses the inference key), optional gateway override */}
|
||||||
|
{!isManaged && cfg.templateType === "sub2api" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-content-muted mb-1.5">{t("usageBaseUrl")}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full 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.baseUrl ?? ""}
|
||||||
|
placeholder={provider.base_url ?? "https://your-sub2api-site.com"}
|
||||||
|
onChange={(e) => setCfg({ ...cfg, baseUrl: e.target.value || undefined })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* timeout + auto query interval */}
|
{/* timeout + auto query interval */}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function ModelTotalsChart({ rows }: ModelTotalsChartProps) {
|
|||||||
const max = Math.max(...rows.map((m) => m.totalTokens || 0), 1);
|
const max = Math.max(...rows.map((m) => m.totalTokens || 0), 1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col gap-1 overflow-y-auto">
|
<div className="flex max-h-[360px] flex-col gap-1 overflow-y-auto">
|
||||||
{rows.map((m, i) => {
|
{rows.map((m, i) => {
|
||||||
const color = i < 10 ? modelColor(i) : "#6b7280";
|
const color = i < 10 ? modelColor(i) : "#6b7280";
|
||||||
const name = m.isSecondary ? t("subagentModel") : m.modelDisplay || m.model;
|
const name = m.isSecondary ? t("subagentModel") : m.modelDisplay || m.model;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useSessions } from "../../hooks/useSessions";
|
import { useSessions } from "../../hooks/useSessions";
|
||||||
import { fmtInt, fmtTime } from "../../lib/dashboard-format";
|
import { fmtInt, fmtTime } from "../../lib/dashboard-format";
|
||||||
import { useTranslation } from "../../i18n";
|
import { useTranslation } from "../../i18n";
|
||||||
import type { SessionRow, WorkspaceRow } from "../../types/sessions";
|
import type { BulkArchiveResult, SessionRow, WorkspaceRow } from "../../types/sessions";
|
||||||
|
|
||||||
function fmtBytes(n: number): string {
|
function fmtBytes(n: number): string {
|
||||||
const v = Number(n) || 0;
|
const v = Number(n) || 0;
|
||||||
@@ -23,6 +23,44 @@ function rowKey(row: SessionRow): string {
|
|||||||
return `${row.workspaceId}/${row.id}`;
|
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 =
|
type ConfirmState =
|
||||||
| { mode: "one"; row: SessionRow }
|
| { mode: "one"; row: SessionRow }
|
||||||
| { mode: "bulk"; rows: SessionRow[] }
|
| { mode: "bulk"; rows: SessionRow[] }
|
||||||
@@ -52,6 +90,7 @@ export function SessionsPage() {
|
|||||||
refresh,
|
refresh,
|
||||||
archiveSession,
|
archiveSession,
|
||||||
unarchiveSession,
|
unarchiveSession,
|
||||||
|
archiveBefore,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
deleteWorkspace,
|
deleteWorkspace,
|
||||||
getPreview,
|
getPreview,
|
||||||
@@ -62,6 +101,7 @@ export function SessionsPage() {
|
|||||||
const [confirm, setConfirm] = useState<ConfirmState>(null);
|
const [confirm, setConfirm] = useState<ConfirmState>(null);
|
||||||
const [confirmBusy, setConfirmBusy] = useState(false);
|
const [confirmBusy, setConfirmBusy] = useState(false);
|
||||||
const [preview, setPreview] = useState<PreviewState>(null);
|
const [preview, setPreview] = useState<PreviewState>(null);
|
||||||
|
const [archiveAll, setArchiveAll] = useState<ArchiveAllState | null>(null);
|
||||||
|
|
||||||
const workspaces = data?.workspaces ?? [];
|
const workspaces = data?.workspaces ?? [];
|
||||||
const sessions = data?.sessions ?? [];
|
const sessions = data?.sessions ?? [];
|
||||||
@@ -199,6 +239,30 @@ export function SessionsPage() {
|
|||||||
0
|
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 (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden p-4 space-y-3">
|
<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">
|
<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>
|
</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
|
<select
|
||||||
value={workspace}
|
value={workspace}
|
||||||
onChange={(e) => setWorkspace(e.target.value)}
|
onChange={(e) => setWorkspace(e.target.value)}
|
||||||
@@ -548,6 +629,112 @@ export function SessionsPage() {
|
|||||||
{data?.home && <>{data.home}</>}
|
{data?.home && <>{data.home}</>}
|
||||||
</div>
|
</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 dialog */}
|
||||||
{confirm && (
|
{confirm && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type UsageKind =
|
|||||||
| "balance:novita"
|
| "balance:novita"
|
||||||
| "balance:kimi"
|
| "balance:kimi"
|
||||||
| "balance:newapi"
|
| "balance:newapi"
|
||||||
|
| "balance:sub2api"
|
||||||
| "plan:kimi_coding"
|
| "plan:kimi_coding"
|
||||||
| "plan:zhipu"
|
| "plan:zhipu"
|
||||||
| "plan:minimax"
|
| "plan:minimax"
|
||||||
@@ -32,6 +33,7 @@ export const SUPPORTED_USAGE_KINDS: ReadonlySet<string> = new Set<UsageKind>([
|
|||||||
"balance:novita",
|
"balance:novita",
|
||||||
"balance:kimi",
|
"balance:kimi",
|
||||||
"balance:newapi",
|
"balance:newapi",
|
||||||
|
"balance:sub2api",
|
||||||
"plan:kimi_coding",
|
"plan:kimi_coding",
|
||||||
"plan:zhipu",
|
"plan:zhipu",
|
||||||
"plan:minimax",
|
"plan:minimax",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import type {
|
import type {
|
||||||
ActionResponse,
|
ActionResponse,
|
||||||
|
BulkArchiveResult,
|
||||||
PreviewResult,
|
PreviewResult,
|
||||||
SessionStatusFilter,
|
SessionStatusFilter,
|
||||||
SessionsResult,
|
SessionsResult,
|
||||||
@@ -54,6 +55,17 @@ export function useSessions() {
|
|||||||
[refresh]
|
[refresh]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const archiveBefore = useCallback(
|
||||||
|
async (cutoffMs: number) => {
|
||||||
|
const result = await invoke<BulkArchiveResult>("archive_sessions_before", {
|
||||||
|
cutoffMs,
|
||||||
|
});
|
||||||
|
await refresh();
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
[refresh]
|
||||||
|
);
|
||||||
|
|
||||||
const deleteSession = useCallback(
|
const deleteSession = useCallback(
|
||||||
async (workspaceId: string, sessionId: string, sessionStatus?: string) => {
|
async (workspaceId: string, sessionId: string, sessionStatus?: string) => {
|
||||||
await invoke<ActionResponse>("delete_session", {
|
await invoke<ActionResponse>("delete_session", {
|
||||||
@@ -100,6 +112,7 @@ export function useSessions() {
|
|||||||
refresh,
|
refresh,
|
||||||
archiveSession,
|
archiveSession,
|
||||||
unarchiveSession,
|
unarchiveSession,
|
||||||
|
archiveBefore,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
deleteWorkspace,
|
deleteWorkspace,
|
||||||
getPreview,
|
getPreview,
|
||||||
|
|||||||
+19
-11
@@ -190,6 +190,7 @@ export const enTranslations: Record<TranslationKey, string> = {
|
|||||||
statusAll: "All",
|
statusAll: "All",
|
||||||
selectAll: "Select all",
|
selectAll: "Select all",
|
||||||
deselectAll: "Deselect all",
|
deselectAll: "Deselect all",
|
||||||
|
invertSelection: "Invert selection",
|
||||||
selectedCount: "{n} selected",
|
selectedCount: "{n} selected",
|
||||||
archive: "Archive",
|
archive: "Archive",
|
||||||
unarchive: "Unarchive",
|
unarchive: "Unarchive",
|
||||||
@@ -224,6 +225,19 @@ export const enTranslations: Record<TranslationKey, string> = {
|
|||||||
roleUser: "User",
|
roleUser: "User",
|
||||||
roleAssistant: "Assistant",
|
roleAssistant: "Assistant",
|
||||||
roleSystem: "System",
|
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
|
// Heatmap
|
||||||
noData: "No data",
|
noData: "No data",
|
||||||
@@ -408,8 +422,10 @@ export const enTranslations: Record<TranslationKey, string> = {
|
|||||||
usageTemplate: "Query template",
|
usageTemplate: "Query template",
|
||||||
usageTemplateAuto: "Auto-detect",
|
usageTemplateAuto: "Auto-detect",
|
||||||
usageTemplateNewapi: "NewAPI gateway",
|
usageTemplateNewapi: "NewAPI gateway",
|
||||||
|
usageTemplateSub2api: "Sub2API gateway",
|
||||||
usageAutoDetectHint: "Query balance / plan with the provider's API Key and Base URL",
|
usageAutoDetectHint: "Query balance / plan with the provider's API Key and Base URL",
|
||||||
usageNewapiHint: "Query a NewAPI / OneAPI gateway balance (requires a web-console Access Token, not the sk- inference key)",
|
usageNewapiHint: "Query a NewAPI / OneAPI gateway balance (requires a web-console Access Token, not the sk- inference key)",
|
||||||
|
usageSub2apiHint: "Query a Sub2API (Wei-Shaw/sub2api) gateway balance / quota (GET /v1/usage). Reuses the inference sk- API key — no extra credentials; the query URL is optionally overridable",
|
||||||
usageOAuthManagedHint: "Official OAuth managed: queries the subscription with your Kimi Code login credentials",
|
usageOAuthManagedHint: "Official OAuth managed: queries the subscription with your Kimi Code login credentials",
|
||||||
usageDetectedKinds: "Detected query kinds",
|
usageDetectedKinds: "Detected query kinds",
|
||||||
usageNoKinds: "Not detected (switch to the NewAPI template to configure manually)",
|
usageNoKinds: "Not detected (switch to the NewAPI template to configure manually)",
|
||||||
@@ -430,6 +446,7 @@ export const enTranslations: Record<TranslationKey, string> = {
|
|||||||
usageSwitchToNewapi: "Switch to NewAPI template",
|
usageSwitchToNewapi: "Switch to NewAPI template",
|
||||||
usageUnsupportedProvider: "This provider is not supported by auto query",
|
usageUnsupportedProvider: "This provider is not supported by auto query",
|
||||||
usageTier5h: "5-hour",
|
usageTier5h: "5-hour",
|
||||||
|
usageTierDaily: "Daily",
|
||||||
usageTierWeekly: "Weekly",
|
usageTierWeekly: "Weekly",
|
||||||
usageTierMonthly: "Monthly",
|
usageTierMonthly: "Monthly",
|
||||||
usageErrNoKey: "No API key configured",
|
usageErrNoKey: "No API key configured",
|
||||||
@@ -463,15 +480,10 @@ export const enTranslations: Record<TranslationKey, string> = {
|
|||||||
// Advanced settings page (WebUI quick open + experimental flags + secondary model)
|
// Advanced settings page (WebUI quick open + experimental flags + secondary model)
|
||||||
subagentSettings: "Advanced Settings",
|
subagentSettings: "Advanced Settings",
|
||||||
experimentalFlags: "Experimental Features",
|
experimentalFlags: "Experimental Features",
|
||||||
otherExperimentalFlags: "Other Experimental Features",
|
|
||||||
experimentalFlagsHint:
|
experimentalFlagsHint:
|
||||||
"Toggles are written to the [experimental] section of config.toml; env vars take precedence and locked toggles cannot be changed here",
|
"Toggles are written to the [experimental] section of config.toml; env vars take precedence and locked toggles cannot be changed here",
|
||||||
flagSecondaryModel: "Subagent Secondary Model",
|
|
||||||
flagSecondaryModelDesc: "Newly spawned subagents bind a second (usually cheaper) model config",
|
|
||||||
flagToolSelect: "Tool Select",
|
flagToolSelect: "Tool Select",
|
||||||
flagToolSelectDesc: "Enable the experimental tool selection feature",
|
flagToolSelectDesc: "Enable the experimental tool selection feature",
|
||||||
flagMinidbReadmodel: "MiniDB Read Model",
|
|
||||||
flagMinidbReadmodelDesc: "Enable the v2 engine MiniDB read-model backend",
|
|
||||||
flagTower: "Tower Mode",
|
flagTower: "Tower Mode",
|
||||||
flagTowerDesc: "Enable tower mode: coordinate multiple agents on a shared objective (/tower command)",
|
flagTowerDesc: "Enable tower mode: coordinate multiple agents on a shared objective (/tower command)",
|
||||||
flagSubagentFork: "Subagent Fork Context",
|
flagSubagentFork: "Subagent Fork Context",
|
||||||
@@ -480,17 +492,13 @@ export const enTranslations: Record<TranslationKey, string> = {
|
|||||||
flagWaitForDesc: "The agent can wait for background tasks to finish within the current turn",
|
flagWaitForDesc: "The agent can wait for background tasks to finish within the current turn",
|
||||||
flagAutoSessionTitle: "Auto Session Title",
|
flagAutoSessionTitle: "Auto Session Title",
|
||||||
flagAutoSessionTitleDesc: "Generate session titles automatically via the managed chat_title tool",
|
flagAutoSessionTitleDesc: "Generate session titles automatically via the managed chat_title tool",
|
||||||
flagRemoteControl: "Remote Control",
|
flagNotifyUser: "NotifyUser tool",
|
||||||
flagRemoteControlDesc: "Enable the experimental remote control feature",
|
flagNotifyUserDesc: "Experimental Updates panel: paginated progress messages from the main agent and subagents (off by default)",
|
||||||
flagSearchWorker: "Search Worker",
|
|
||||||
flagSearchWorkerDesc: "Run the kap-server global search backend in a background worker host",
|
|
||||||
flagDefaultOn: "On by default",
|
flagDefaultOn: "On by default",
|
||||||
lockedByEnv: "Locked by env var",
|
lockedByEnv: "Locked by env var",
|
||||||
masterEnvOnHint:
|
masterEnvOnHint:
|
||||||
"KIMI_CODE_EXPERIMENTAL_FLAG is set: flags without an explicit [experimental] entry are forced on; explicit entries in config.toml still win",
|
"KIMI_CODE_EXPERIMENTAL_FLAG is set: flags without an explicit [experimental] entry are forced on; explicit entries in config.toml still win",
|
||||||
secondaryModelSection: "Subagent Model (Secondary Model)",
|
secondaryModelSection: "Subagent Model (Secondary Model)",
|
||||||
secondaryModelDisabledHint:
|
|
||||||
"The experimental flag \"Subagent Secondary Model\" is off and this config will not take effect — enable the toggle above first",
|
|
||||||
secondaryModelLabel: "Secondary model",
|
secondaryModelLabel: "Secondary model",
|
||||||
secondaryModelUnset: "Not configured",
|
secondaryModelUnset: "Not configured",
|
||||||
secondaryModelInheritedContext: "Inherited context",
|
secondaryModelInheritedContext: "Inherited context",
|
||||||
|
|||||||
+18
-11
@@ -187,6 +187,7 @@ export const zhTranslations = {
|
|||||||
statusAll: "全部",
|
statusAll: "全部",
|
||||||
selectAll: "全选",
|
selectAll: "全选",
|
||||||
deselectAll: "取消全选",
|
deselectAll: "取消全选",
|
||||||
|
invertSelection: "反选",
|
||||||
selectedCount: "已选 {n} 项",
|
selectedCount: "已选 {n} 项",
|
||||||
archive: "归档",
|
archive: "归档",
|
||||||
unarchive: "取消归档",
|
unarchive: "取消归档",
|
||||||
@@ -219,6 +220,18 @@ export const zhTranslations = {
|
|||||||
roleUser: "用户",
|
roleUser: "用户",
|
||||||
roleAssistant: "助手",
|
roleAssistant: "助手",
|
||||||
roleSystem: "系统",
|
roleSystem: "系统",
|
||||||
|
sessionsArchiveAll: "一键归档",
|
||||||
|
archiveAllTitle: "一键归档会话",
|
||||||
|
archiveAllDesc: "将归档最后活跃时间早于所选日期的全部活跃会话;文件保留,统计不受影响。",
|
||||||
|
archiveOptionMonth: "1 个月前",
|
||||||
|
archiveOptionHalfMonth: "半个月前",
|
||||||
|
archiveOptionWeek: "1 星期前",
|
||||||
|
archiveOptionCustom: "指定日期",
|
||||||
|
archiveAllConfirm: "将归档最后活跃时间早于 {date} 的全部活跃会话。确认?",
|
||||||
|
archiveAllRunning: "正在归档…",
|
||||||
|
archiveAllDone: "已归档 {archived} 个会话,跳过 {skipped} 个",
|
||||||
|
archiveAllFailed: "归档失败",
|
||||||
|
archiveAllEmpty: "没有符合条件的会话",
|
||||||
|
|
||||||
// Heatmap
|
// Heatmap
|
||||||
noData: "暂无数据",
|
noData: "暂无数据",
|
||||||
@@ -403,8 +416,10 @@ export const zhTranslations = {
|
|||||||
usageTemplate: "查询模板",
|
usageTemplate: "查询模板",
|
||||||
usageTemplateAuto: "自动检测",
|
usageTemplateAuto: "自动检测",
|
||||||
usageTemplateNewapi: "NewAPI 中转站",
|
usageTemplateNewapi: "NewAPI 中转站",
|
||||||
|
usageTemplateSub2api: "Sub2API 中转站",
|
||||||
usageAutoDetectHint: "自动使用供应商的 API Key 和 Base URL 查询余额 / 套餐",
|
usageAutoDetectHint: "自动使用供应商的 API Key 和 Base URL 查询余额 / 套餐",
|
||||||
usageNewapiHint: "查询 NewAPI / OneAPI 中转站的账号余额(需网页后台 Access Token,不是 sk- 推理 Key)",
|
usageNewapiHint: "查询 NewAPI / OneAPI 中转站的账号余额(需网页后台 Access Token,不是 sk- 推理 Key)",
|
||||||
|
usageSub2apiHint: "查询 Sub2API (Wei-Shaw/sub2api) 面板的余额 / 额度(GET /v1/usage),自动复用推理 sk- API Key,无需额外凭据;可选覆盖查询地址",
|
||||||
usageOAuthManagedHint: "官方 OAuth 托管:自动使用 Kimi Code 登录凭据查询订阅用量",
|
usageOAuthManagedHint: "官方 OAuth 托管:自动使用 Kimi Code 登录凭据查询订阅用量",
|
||||||
usageDetectedKinds: "检测到的查询类型",
|
usageDetectedKinds: "检测到的查询类型",
|
||||||
usageNoKinds: "未识别(可切换到 NewAPI 模板手动配置)",
|
usageNoKinds: "未识别(可切换到 NewAPI 模板手动配置)",
|
||||||
@@ -425,6 +440,7 @@ export const zhTranslations = {
|
|||||||
usageSwitchToNewapi: "切换到 NewAPI 模板",
|
usageSwitchToNewapi: "切换到 NewAPI 模板",
|
||||||
usageUnsupportedProvider: "此供应商暂不支持自动查询",
|
usageUnsupportedProvider: "此供应商暂不支持自动查询",
|
||||||
usageTier5h: "5小时",
|
usageTier5h: "5小时",
|
||||||
|
usageTierDaily: "每日",
|
||||||
usageTierWeekly: "7天",
|
usageTierWeekly: "7天",
|
||||||
usageTierMonthly: "30天",
|
usageTierMonthly: "30天",
|
||||||
usageErrNoKey: "未配置 API Key",
|
usageErrNoKey: "未配置 API Key",
|
||||||
@@ -458,15 +474,10 @@ export const zhTranslations = {
|
|||||||
// Advanced settings page (WebUI quick open + experimental flags + secondary model)
|
// Advanced settings page (WebUI quick open + experimental flags + secondary model)
|
||||||
subagentSettings: "高级设置",
|
subagentSettings: "高级设置",
|
||||||
experimentalFlags: "实验功能开关",
|
experimentalFlags: "实验功能开关",
|
||||||
otherExperimentalFlags: "其他实验功能",
|
|
||||||
experimentalFlagsHint:
|
experimentalFlagsHint:
|
||||||
"开关写入 config.toml 的 [experimental] 节;环境变量优先级更高,被锁定的开关无法在此修改",
|
"开关写入 config.toml 的 [experimental] 节;环境变量优先级更高,被锁定的开关无法在此修改",
|
||||||
flagSecondaryModel: "子代理次主力模型",
|
|
||||||
flagSecondaryModelDesc: "子代理派发时绑定第二份(通常更便宜的)模型配置",
|
|
||||||
flagToolSelect: "工具选择",
|
flagToolSelect: "工具选择",
|
||||||
flagToolSelectDesc: "启用实验性工具选择功能",
|
flagToolSelectDesc: "启用实验性工具选择功能",
|
||||||
flagMinidbReadmodel: "MiniDB 读模型",
|
|
||||||
flagMinidbReadmodelDesc: "启用 v2 引擎 MiniDB 读模型后端",
|
|
||||||
flagTower: "Tower 模式",
|
flagTower: "Tower 模式",
|
||||||
flagTowerDesc: "启用 Tower 多智能体协同模式(/tower 命令切换)",
|
flagTowerDesc: "启用 Tower 多智能体协同模式(/tower 命令切换)",
|
||||||
flagSubagentFork: "子代理 Fork 上下文",
|
flagSubagentFork: "子代理 Fork 上下文",
|
||||||
@@ -475,17 +486,13 @@ export const zhTranslations = {
|
|||||||
flagWaitForDesc: "代理可在当前回合内等待后台任务完成,而无需结束回合再被唤醒",
|
flagWaitForDesc: "代理可在当前回合内等待后台任务完成,而无需结束回合再被唤醒",
|
||||||
flagAutoSessionTitle: "自动会话标题",
|
flagAutoSessionTitle: "自动会话标题",
|
||||||
flagAutoSessionTitleDesc: "通过托管 chat_title 工具自动生成会话标题",
|
flagAutoSessionTitleDesc: "通过托管 chat_title 工具自动生成会话标题",
|
||||||
flagRemoteControl: "远程控制",
|
flagNotifyUser: "NotifyUser 工具",
|
||||||
flagRemoteControlDesc: "启用实验性远程控制功能",
|
flagNotifyUserDesc: "实验性 Updates 面板:分页展示主代理与子代理的进度消息(默认关闭)",
|
||||||
flagSearchWorker: "搜索 Worker",
|
|
||||||
flagSearchWorkerDesc: "kap-server 全局搜索的后台 worker 宿主服务",
|
|
||||||
flagDefaultOn: "默认开启",
|
flagDefaultOn: "默认开启",
|
||||||
lockedByEnv: "被环境变量锁定",
|
lockedByEnv: "被环境变量锁定",
|
||||||
masterEnvOnHint:
|
masterEnvOnHint:
|
||||||
"KIMI_CODE_EXPERIMENTAL_FLAG 已设置:未显式配置的实验项将被强制开启;config.toml 中的显式配置优先生效",
|
"KIMI_CODE_EXPERIMENTAL_FLAG 已设置:未显式配置的实验项将被强制开启;config.toml 中的显式配置优先生效",
|
||||||
secondaryModelSection: "子代理模型(次主力模型)",
|
secondaryModelSection: "子代理模型(次主力模型)",
|
||||||
secondaryModelDisabledHint:
|
|
||||||
"实验开关「子代理次主力模型」未启用,本配置不会生效——请先在上方打开对应开关",
|
|
||||||
secondaryModelLabel: "次主力模型",
|
secondaryModelLabel: "次主力模型",
|
||||||
secondaryModelUnset: "未配置",
|
secondaryModelUnset: "未配置",
|
||||||
secondaryModelInheritedContext: "继承上下文长度",
|
secondaryModelInheritedContext: "继承上下文长度",
|
||||||
|
|||||||
+3661
-827
File diff suppressed because it is too large.
Load diff
+3098
-681
File diff suppressed because it is too large.
Load diff
@@ -1,10 +1,12 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
EXPERIMENTAL_FLAGS,
|
EXPERIMENTAL_FLAGS,
|
||||||
|
forcedEnvValue,
|
||||||
getExperimentalFlags,
|
getExperimentalFlags,
|
||||||
getSecondaryModel,
|
getSecondaryModel,
|
||||||
getSubagentModelPool,
|
getSubagentModelPool,
|
||||||
isExperimentalFlagSet,
|
isExperimentalFlagSet,
|
||||||
|
isFlagLockedByEnv,
|
||||||
removeSubagentPoolEntry,
|
removeSubagentPoolEntry,
|
||||||
setExperimentalFlag,
|
setExperimentalFlag,
|
||||||
setSecondaryModelOnly,
|
setSecondaryModelOnly,
|
||||||
@@ -163,13 +165,13 @@ describe("setSecondaryModelOnly", () => {
|
|||||||
|
|
||||||
it("preserves other top-level raw_other sections", () => {
|
it("preserves other top-level raw_other sections", () => {
|
||||||
const raw = {
|
const raw = {
|
||||||
experimental: { "secondary-model": true },
|
experimental: { wait_for: true },
|
||||||
secondary_model: { model: "kimi-k1" },
|
secondary_model: { model: "kimi-k1" },
|
||||||
};
|
};
|
||||||
const next = setSecondaryModelOnly(raw, "kimi-k2") as {
|
const next = setSecondaryModelOnly(raw, "kimi-k2") as {
|
||||||
experimental?: unknown;
|
experimental?: unknown;
|
||||||
};
|
};
|
||||||
expect(next.experimental).toEqual({ "secondary-model": true });
|
expect(next.experimental).toEqual({ wait_for: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -455,31 +457,49 @@ describe("validateSubagentPool", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// [experimental] — flag registry (kimi-code 0.41.0) + write semantics
|
// [experimental] — flag registry (kimi-code 0.42.0) + write semantics
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe("experimental flag registry", () => {
|
describe("experimental flag registry", () => {
|
||||||
it("mirrors the 0.41.0 v2 registry (9 flags, file_history removed)", () => {
|
it("mirrors the 0.42.0 v2 registry (6 flags)", () => {
|
||||||
expect(EXPERIMENTAL_FLAGS.map((f) => f.id)).toEqual([
|
expect(EXPERIMENTAL_FLAGS.map((f) => f.id)).toEqual([
|
||||||
"secondary-model",
|
"wait_for",
|
||||||
"tool-select",
|
"tool-select",
|
||||||
"persistence_minidb_readmodel",
|
"notify_user",
|
||||||
"tower",
|
"tower",
|
||||||
"subagent_fork",
|
"subagent_fork",
|
||||||
"wait_for",
|
|
||||||
"auto_session_title",
|
"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.
|
||||||
|
for (const id of [
|
||||||
|
"file_history",
|
||||||
|
"secondary-model",
|
||||||
|
"persistence_minidb_readmodel",
|
||||||
"remote-control",
|
"remote-control",
|
||||||
"search_worker",
|
"search_worker",
|
||||||
]);
|
]) {
|
||||||
// Upstream removed the flag in 0.41.0 (turn-level file history is
|
expect(EXPERIMENTAL_FLAGS.some((f) => f.id === id)).toBe(false);
|
||||||
// always on), so the adapter must not mirror it either.
|
}
|
||||||
expect(EXPERIMENTAL_FLAGS.some((f) => f.id === "file_history")).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("secondary-model is on by default since 0.40.1", () => {
|
it("notify_user is opt-in: no upstream default, own env var", () => {
|
||||||
expect(
|
const flag = EXPERIMENTAL_FLAGS.find((f) => f.id === "notify_user");
|
||||||
EXPERIMENTAL_FLAGS.find((f) => f.id === "secondary-model")?.defaultEnabled
|
expect(flag?.envVar).toBe("KIMI_CODE_EXPERIMENTAL_NOTIFY_USER");
|
||||||
).toBe(true);
|
expect(flag?.defaultEnabled).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notify_user is locked by its env var and forced to the env value", () => {
|
||||||
|
const flag = EXPERIMENTAL_FLAGS.find((f) => f.id === "notify_user")!;
|
||||||
|
expect(isFlagLockedByEnv({}, flag)).toBe(false);
|
||||||
|
// A set env var locks the toggle even when it is falsy — it can force
|
||||||
|
// the flag on *or* off.
|
||||||
|
expect(isFlagLockedByEnv({ [flag.envVar]: "0" }, flag)).toBe(true);
|
||||||
|
expect(forcedEnvValue({ [flag.envVar]: "0" }, flag)).toBe(false);
|
||||||
|
expect(forcedEnvValue({ [flag.envVar]: "true" }, flag)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -514,13 +534,13 @@ describe("setExperimentalFlag", () => {
|
|||||||
|
|
||||||
it("an explicit false coexists with other flags and keeps the section", () => {
|
it("an explicit false coexists with other flags and keeps the section", () => {
|
||||||
const next = setExperimentalFlag(
|
const next = setExperimentalFlag(
|
||||||
rawExp({ "secondary-model": false }),
|
rawExp({ wait_for: false }),
|
||||||
"tower",
|
"tower",
|
||||||
true,
|
true,
|
||||||
{ explicitFalse: true }
|
{ explicitFalse: true }
|
||||||
) as { experimental: Record<string, unknown> };
|
) as { experimental: Record<string, unknown> };
|
||||||
expect(next.experimental).toEqual({
|
expect(next.experimental).toEqual({
|
||||||
"secondary-model": false,
|
wait_for: false,
|
||||||
tower: true,
|
tower: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ function isTruthyEnv(value: string | undefined): boolean {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface ExperimentalFlagDef {
|
export interface ExperimentalFlagDef {
|
||||||
/** Flag id as stored in config.toml `[experimental]` (e.g. "secondary-model"). */
|
/** Flag id as stored in config.toml `[experimental]` (e.g. "wait_for"). */
|
||||||
id: string;
|
id: string;
|
||||||
/** Single-feature env var that can force the flag (locks the UI toggle). */
|
/** Single-feature env var that can force the flag (locks the UI toggle). */
|
||||||
envVar: string;
|
envVar: string;
|
||||||
@@ -33,43 +33,30 @@ export interface ExperimentalFlagDef {
|
|||||||
defaultEnabled?: boolean;
|
defaultEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Known experimental flags — mirrors the kimi-code v2 flag registry
|
/** Known experimental flags — mirrors the kimi-code 0.42.0 v2 flag registry
|
||||||
* (the per-feature flag.ts files under packages/agent-core-v2/src:
|
* (the per-feature flag.ts files under packages/agent-core-v2/src):
|
||||||
* secondary-model, tool-select, persistence_minidb_readmodel, tower,
|
* wait_for, tool-select, notify_user, tower, subagent_fork,
|
||||||
* subagent_fork, wait_for, auto_session_title, remote-control,
|
* auto_session_title. Removed in 0.42.0: secondary-model, remote-control
|
||||||
* search_worker). `acp-v2` was removed upstream and is dropped here;
|
* and search_worker (promoted or dropped upstream — no flag left), and
|
||||||
* `file_history` was removed in kimi-code 0.41.0 (turn-level file
|
* persistence_minidb_readmodel (now controlled by the `[database]` config
|
||||||
* history is always on, no flag). Keep the env-var list in sync with
|
* section). `acp-v2` was removed upstream earlier; `file_history` was
|
||||||
* `get_experimental_env_status` in src-tauri/src/commands.rs. */
|
* removed in kimi-code 0.41.0 (turn-level file history is always on, no
|
||||||
|
* flag). Keep the env-var list in sync with `get_experimental_env_status`
|
||||||
|
* in src-tauri/src/commands.rs. */
|
||||||
export const EXPERIMENTAL_FLAGS: ExperimentalFlagDef[] = [
|
export const EXPERIMENTAL_FLAGS: ExperimentalFlagDef[] = [
|
||||||
{
|
|
||||||
id: "secondary-model",
|
|
||||||
envVar: "KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL",
|
|
||||||
defaultEnabled: true, // on by default since kimi-code 0.40.1
|
|
||||||
},
|
|
||||||
{ id: "tool-select", envVar: "KIMI_CODE_EXPERIMENTAL_TOOL_SELECT" },
|
|
||||||
{
|
|
||||||
id: "persistence_minidb_readmodel",
|
|
||||||
envVar: "KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL",
|
|
||||||
defaultEnabled: true,
|
|
||||||
},
|
|
||||||
{ id: "tower", envVar: "KIMI_CODE_EXPERIMENTAL_TOWER" },
|
|
||||||
{ id: "subagent_fork", envVar: "KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK" },
|
|
||||||
{
|
{
|
||||||
id: "wait_for",
|
id: "wait_for",
|
||||||
envVar: "KIMI_CODE_EXPERIMENTAL_WAIT_FOR",
|
envVar: "KIMI_CODE_EXPERIMENTAL_WAIT_FOR",
|
||||||
defaultEnabled: true,
|
defaultEnabled: true,
|
||||||
},
|
},
|
||||||
|
{ id: "tool-select", envVar: "KIMI_CODE_EXPERIMENTAL_TOOL_SELECT" },
|
||||||
|
{ 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",
|
id: "auto_session_title",
|
||||||
envVar: "KIMI_CODE_EXPERIMENTAL_AUTO_SESSION_TITLE",
|
envVar: "KIMI_CODE_EXPERIMENTAL_AUTO_SESSION_TITLE",
|
||||||
},
|
},
|
||||||
{ id: "remote-control", envVar: "KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL" },
|
|
||||||
{
|
|
||||||
id: "search_worker",
|
|
||||||
envVar: "KIMI_CODE_EXPERIMENTAL_SEARCH_WORKER",
|
|
||||||
defaultEnabled: true,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const EXPERIMENTAL_MASTER_ENV = "KIMI_CODE_EXPERIMENTAL_FLAG";
|
export const EXPERIMENTAL_MASTER_ENV = "KIMI_CODE_EXPERIMENTAL_FLAG";
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export function planLabel(name: string, t: TranslateFn): string {
|
|||||||
switch (name) {
|
switch (name) {
|
||||||
case "five_hour":
|
case "five_hour":
|
||||||
return t("usageTier5h");
|
return t("usageTier5h");
|
||||||
|
case "daily_limit":
|
||||||
|
return t("usageTierDaily");
|
||||||
case "weekly_limit":
|
case "weekly_limit":
|
||||||
return t("usageTierWeekly");
|
return t("usageTierWeekly");
|
||||||
case "monthly_limit":
|
case "monthly_limit":
|
||||||
|
|||||||
+3
-2
@@ -75,11 +75,12 @@ export interface Config {
|
|||||||
/**
|
/**
|
||||||
* Usage query configuration (mirrors the Rust `UsageConfig`, camelCase).
|
* Usage query configuration (mirrors the Rust `UsageConfig`, camelCase).
|
||||||
* "auto" = query kinds from usageKinds/host detect; "newapi" = query a
|
* "auto" = query kinds from usageKinds/host detect; "newapi" = query a
|
||||||
* NewAPI/OneAPI gateway with accessToken + userId.
|
* NewAPI/OneAPI gateway with accessToken + userId; "sub2api" = query a
|
||||||
|
* Sub2API (Wei-Shaw/sub2api) gateway, reusing the inference API key.
|
||||||
*/
|
*/
|
||||||
export interface UsageConfig {
|
export interface UsageConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
templateType: "auto" | "newapi";
|
templateType: "auto" | "newapi" | "sub2api";
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
/** NewAPI web-console access token (NOT the sk- inference key). */
|
/** NewAPI web-console access token (NOT the sk- inference key). */
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ export interface ActionResponse {
|
|||||||
deleted?: boolean | null;
|
deleted?: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BulkArchiveResult {
|
||||||
|
archived: number;
|
||||||
|
skipped: number;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface PreviewMessage {
|
export interface PreviewMessage {
|
||||||
role: string;
|
role: string;
|
||||||
time: number | null;
|
time: number | null;
|
||||||
|
|||||||
Reference in new issue
Block a user