Compare commits

...
4 Commits
Author SHA1 Message Date
KimiSwitch Dev 8aed04898c feat(subagent): 实验功能设置新增子代理模型配置
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
- 子代理设置页支持选择次主力模型,仅保存模型引用(不再写补丁字段),
  子代理直接继承所选模型的上下文长度与思考模式
- 界面只读展示继承的上下文长度与思考模式,移除补丁编辑项
- 仪表盘统计识别子代理请求(__secondary__):归为「子代理模型(估算)」
  并缓存次主力模型定价;自定义网关模型价格按官方同名模型跨 provider 匹配
- bump v0.7.1
2026-08-05 23:38:05 +08:00
KimiSwitch Dev 8b0daabd95 fix(tray): 托盘图标悬停气泡显示程序名(Kimi Switch) 2026-08-05 11:19:30 +08:00
KimiSwitch Dev 6e38bed092 docs(website): v0.7.0 更新日志补入 Kimi Eyes 插件站链接
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
2026-08-02 18:18:34 +08:00
KimiSwitch Dev 6df9095ee8 feat(settings): 关于页新增 Kimi Eyes 插件站链接(billowliu2.github.io/kimi-eyes) 2026-08-02 18:12:29 +08:00
24 changed files with 16665 additions and 8882 deletions

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "kimiswitch", "name": "kimiswitch",
"private": true, "private": true,
"version": "0.7.0", "version": "0.7.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -1
View File
@@ -1958,7 +1958,7 @@ dependencies = [
[[package]] [[package]]
name = "kimiswitch" name = "kimiswitch"
version = "0.7.0" version = "0.7.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "kimiswitch" name = "kimiswitch"
version = "0.7.0" version = "0.7.1"
description = "Kimi Switch - model config manager" description = "Kimi Switch - model config manager"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
+31
View File
@@ -1078,3 +1078,34 @@ pub async fn kimi_oauth_poll(
) -> Result<crate::oauth::DevicePollStatus, String> { ) -> Result<crate::oauth::DevicePollStatus, String> {
crate::oauth::poll_device_token(&device_code, interval).await crate::oauth::poll_device_token(&device_code, interval).await
} }
// ---------------------------------------------------------------------------
// Experimental feature env-var probe (Kimi Code secondary model etc.)
// ---------------------------------------------------------------------------
/// Read the Kimi Code experimental-feature environment variables that are
/// currently set (non-empty) in this process's environment. The frontend
/// uses the result to mark config.toml `[experimental]` toggles as locked:
/// env vars outrank the config file in Kimi Code's flag resolution.
///
/// Truthy values per the CLI: `1` / `true` / `yes` / `on` (case-insensitive);
/// the raw values are returned and the truthy check happens on the frontend.
#[tauri::command]
pub fn get_experimental_env_status() -> HashMap<String, String> {
const VARS: [&str; 5] = [
"KIMI_CODE_EXPERIMENTAL_FLAG",
"KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL",
"KIMI_CODE_EXPERIMENTAL_TOOL_SELECT",
"KIMI_CODE_EXPERIMENTAL_ACP_V2",
"KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL",
];
let mut out = HashMap::new();
for name in VARS {
if let Ok(value) = std::env::var(name) {
if !value.trim().is_empty() {
out.insert(name.to_string(), value);
}
}
}
out
}
+100 -4
View File
@@ -149,6 +149,9 @@ pub struct ModelRow {
pub model_resolved: String, pub model_resolved: String,
pub price_id: String, pub price_id: String,
pub cost_estimated: bool, pub cost_estimated: bool,
/// True when any aggregated record came from a subagent request (Kimi Code
/// `__secondary__` marker, resolved to the configured secondary model).
pub is_secondary: bool,
pub requests: usize, pub requests: usize,
pub input_other: u64, pub input_other: u64,
pub output: u64, pub output: u64,
@@ -175,6 +178,9 @@ pub struct RecentRow {
pub cost_estimated: bool, pub cost_estimated: bool,
pub price_id: String, pub price_id: String,
pub from_env: bool, pub from_env: bool,
/// True when the record is a subagent request bound to the configured
/// secondary model (Kimi Code `__secondary__` marker).
pub is_secondary: bool,
} }
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
@@ -330,6 +336,9 @@ pub struct UsageRecord {
pub model_display: String, pub model_display: String,
pub provider: Option<String>, pub provider: Option<String>,
pub from_env: bool, pub from_env: bool,
/// True when the record is a subagent request bound to the configured
/// secondary model (Kimi Code `__secondary__` marker).
pub is_secondary: bool,
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -400,6 +409,26 @@ fn build_alias_provider_map(home: &Path) -> HashMap<String, String> {
map map
} }
/// Read `[secondary_model] model` from the Kimi Code config.toml — the alias
/// subagents bind to when the experimental secondary-model feature is on.
/// Kimi Code emits usage records with the internal marker `__secondary__` for
/// those requests; the marker is resolved to this alias so prices and display
/// line up with the actual model.
fn read_secondary_model_alias(home: &Path) -> Option<String> {
let path = home.join("config.toml");
if !path.is_file() {
return None;
}
let content = std::fs::read_to_string(&path).ok()?;
let parsed: toml::Value = toml::from_str(&content).ok()?;
parsed
.get("secondary_model")
.and_then(|s| s.get("model"))
.and_then(|m| m.as_str())
.map(|s| s.to_string())
.filter(|s| !s.is_empty())
}
/// Resolve the provider for a usage record's raw model key: prefer the `provider/` /// Resolve the provider for a usage record's raw model key: prefer the `provider/`
/// prefix, then fall back to the legacy alias→provider map (config + backups). /// prefix, then fall back to the legacy alias→provider map (config + backups).
fn resolve_provider(model_raw: &str, map: &HashMap<String, String>) -> Option<String> { fn resolve_provider(model_raw: &str, map: &HashMap<String, String>) -> Option<String> {
@@ -726,6 +755,15 @@ fn match_price_inner(model_name: &str) -> (String, f64, f64, f64, bool) {
return (id, c.cache_read, c.input, c.output, false); return (id, c.cache_read, c.input, c.output, false);
} }
} }
// 1c) still no exact match: fall back to a cross-provider bare-name
// suffix lookup in the models.dev snapshot. Custom-gateway aliases (e.g.
// "CodingPlan.site/deepseek-v4-flash", not catalogued in models.dev)
// then price against the official listing of the same model id.
if let Some((id, c)) = priced_suffix_lookup(model_name) {
if c.input > 0.0 || c.output > 0.0 {
return (id, c.cache_read, c.input, c.output, false);
}
}
// 2) legacy Kimi table (kept as a fallback for names not in models.dev). // 2) legacy Kimi table (kept as a fallback for names not in models.dev).
let bare_l = bare.to_ascii_lowercase(); let bare_l = bare.to_ascii_lowercase();
for p in &list_prices() { for p in &list_prices() {
@@ -758,9 +796,30 @@ fn cost_for_usage(input_other: u64, output: u64, cache_read: u64, cache_create:
// Scanner // Scanner
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Settings key caching the last configured secondary-model alias. Historical
/// `__secondary__` usage records carry no model identity, so billing follows
/// the currently configured secondary model; this cache keeps a stable basis
/// even after the secondary config is removed or changed.
const SECONDARY_MODEL_CACHE_KEY: &str = "dashboard.secondary_model_cache";
fn scan_usage(home: &Path) -> (Vec<UsageRecord>, ScanMeta) { fn scan_usage(home: &Path) -> (Vec<UsageRecord>, ScanMeta) {
let root = sessions_root(home); let root = sessions_root(home);
let alias2prov = build_alias_provider_map(home); let alias2prov = build_alias_provider_map(home);
// Resolve the `__secondary__` marker (subagent requests bound to the
// configured secondary model) to the real model alias once per scan.
// Prefer the current config; fall back to the persisted cache so records
// keep a stable billing basis after the secondary config is removed or
// changed. When the config still declares a secondary model, refresh the
// cache with it.
let current_secondary = read_secondary_model_alias(home);
let cached_secondary = crate::db::get_setting_pub(SECONDARY_MODEL_CACHE_KEY)
.ok()
.flatten()
.filter(|s| !s.is_empty());
let secondary_alias = current_secondary.clone().or(cached_secondary);
if let Some(alias) = &current_secondary {
let _ = crate::db::set_setting_pub(SECONDARY_MODEL_CACHE_KEY, alias);
}
let mut records = Vec::new(); let mut records = Vec::new();
let mut files_scanned = 0; let mut files_scanned = 0;
let mut lines_seen = 0; let mut lines_seen = 0;
@@ -800,6 +859,21 @@ fn scan_usage(home: &Path) -> (Vec<UsageRecord>, ScanMeta) {
if scope != "turn" { continue; } if scope != "turn" { continue; }
let model_raw = obj.get("model").and_then(|v| v.as_str()).unwrap_or("unknown").to_string(); let model_raw = obj.get("model").and_then(|v| v.as_str()).unwrap_or("unknown").to_string();
// Kimi Code writes the internal marker `__secondary__` as the model
// key for usage records emitted by subagents bound to the
// configured secondary model. The record stays keyed on that
// stable marker so historical records keep their semantics if the
// secondary config changes later; cost billing follows the
// currently configured secondary model, and the record is flagged
// is_secondary so the UI shows it as its own "subagent model"
// entry.
let is_secondary = model_raw == "__secondary__";
let from_env = model_raw == "__kimi_env_model__";
let price_model = if is_secondary {
secondary_alias.clone().unwrap_or_else(|| model_raw.clone())
} else {
model_raw.clone()
};
let time = obj.get("time").and_then(|v| v.as_u64()).unwrap_or(0); let time = obj.get("time").and_then(|v| v.as_u64()).unwrap_or(0);
let usage = &obj["usage"]; let usage = &obj["usage"];
let input_other = usage.get("inputOther").and_then(|v| v.as_u64()).unwrap_or(0); let input_other = usage.get("inputOther").and_then(|v| v.as_u64()).unwrap_or(0);
@@ -809,16 +883,22 @@ fn scan_usage(home: &Path) -> (Vec<UsageRecord>, ScanMeta) {
// Resolve model display // Resolve model display
let bare = model_raw.rsplit_once('/').map(|x| x.1).unwrap_or(&model_raw); let bare = model_raw.rsplit_once('/').map(|x| x.1).unwrap_or(&model_raw);
let (cost, est) = cost_for_usage(input_other, output, cache_read, cache_create, &model_raw); let (cost, est) = cost_for_usage(input_other, output, cache_read, cache_create, &price_model);
let (pid, _ch, _ip, _op, _) = match_price(&model_raw); // Subagent records are inherently estimates: the `__secondary__`
// marker carries no model identity, so the cost is flagged as
// estimated even when the current secondary config resolves to a
// priced listing.
let est = est || is_secondary;
let (pid, _ch, _ip, _op, _) = match_price(&price_model);
records.push(UsageRecord { records.push(UsageRecord {
time, time,
model: model_raw.clone(), model: model_raw.clone(),
model_resolved: bare.to_string(), model_resolved: bare.to_string(),
model_display: model_raw.clone(), model_display: model_raw.clone(),
provider: resolve_provider(&model_raw, &alias2prov), provider: resolve_provider(&price_model, &alias2prov),
from_env: model_raw == "__kimi_env_model__", from_env,
is_secondary,
input_other, input_other,
output, output,
input_cache_read: cache_read, input_cache_read: cache_read,
@@ -925,6 +1005,7 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats {
model_resolved: r.model_resolved.clone(), model_resolved: r.model_resolved.clone(),
price_id: r.price_id.clone(), price_id: r.price_id.clone(),
cost_estimated: r.cost_estimated, cost_estimated: r.cost_estimated,
is_secondary: r.is_secondary,
requests: 0, input_other: 0, output: 0, requests: 0, input_other: 0, output: 0,
input_cache_read: 0, input_cache_creation: 0, input_cache_read: 0, input_cache_creation: 0,
cost_usd: 0.0, total_tokens: 0, cache_hit_rate: 0.0, cost_usd: 0.0, total_tokens: 0, cache_hit_rate: 0.0,
@@ -933,6 +1014,7 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats {
}); });
entry.1.add(r); entry.1.add(r);
entry.0.cost_estimated = entry.0.cost_estimated || r.cost_estimated; entry.0.cost_estimated = entry.0.cost_estimated || r.cost_estimated;
entry.0.is_secondary = entry.0.is_secondary || r.is_secondary;
} }
let total_input = totals.input_other + totals.input_cache_read + totals.input_cache_creation; let total_input = totals.input_other + totals.input_cache_read + totals.input_cache_creation;
@@ -979,6 +1061,7 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats {
total_tokens: r.input_other + r.output + r.input_cache_read + r.input_cache_creation, total_tokens: r.input_other + r.output + r.input_cache_read + r.input_cache_creation,
cost_usd: r.cost_usd, cost_estimated: r.cost_estimated, cost_usd: r.cost_usd, cost_estimated: r.cost_estimated,
price_id: r.price_id.clone(), from_env: r.from_env, price_id: r.price_id.clone(), from_env: r.from_env,
is_secondary: r.is_secondary,
}).collect(); }).collect();
RangeStats { RangeStats {
@@ -1760,6 +1843,19 @@ mod pricing_tests {
assert!(est, "unknown models must be flagged as estimated"); assert!(est, "unknown models must be flagged as estimated");
} }
#[test]
fn custom_gateway_alias_bills_at_official_listing() {
// Custom-gateway aliases not catalogued in models.dev (e.g. the user's
// aggregated gateway "CodingPlan.site/deepseek-v4-flash") must resolve
// via the cross-provider bare-name lookup to a priced official entry
// instead of falling into the last-resort estimate.
let (id, _, input, output, est) = match_price("CodingPlan.site/deepseek-v4-flash");
assert!(!est, "resolved via models.dev, not estimated");
assert_eq!(id, "deepseek/deepseek-v4-flash");
assert_eq!(input, 0.14);
assert_eq!(output, 0.28);
}
#[test] #[test]
fn ambiguous_ids_prefer_official_provider() { fn ambiguous_ids_prefer_official_provider() {
// glm-4.6 ships under resellers too (e.g. 302ai); the official zhipuai // glm-4.6 ships under resellers too (e.g. 302ai); the official zhipuai
+103
View File
@@ -343,6 +343,39 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
} }
root.insert("models".to_string(), TomlValue::Table(models_table)); root.insert("models".to_string(), TomlValue::Table(models_table));
// Sync UI-managed top-level sections (thinking, experimental,
// secondary_model, ...) from config.raw_other back into the root.
// At import time every top-level key except default_model/providers/
// models was collected into raw_other, so raw_other is the authoritative
// snapshot of those sections. This both persists UI edits to those
// sections and drops sections the user explicitly removed (absent from
// raw_other but still present in `existing`).
let mut managed_keys = std::collections::HashSet::new();
if let TomlValue::Table(extra) =
json_to_toml(&config.raw_other).unwrap_or(TomlValue::Table(Table::new()))
{
for (k, v) in extra {
if k == "default_model" || k == "providers" || k == "models" {
continue;
}
managed_keys.insert(k.clone());
root.insert(k, v);
}
}
let stale_keys: Vec<String> = root
.keys()
.filter(|k| {
*k != "default_model"
&& *k != "providers"
&& *k != "models"
&& !managed_keys.contains(*k)
})
.cloned()
.collect();
for k in stale_keys {
root.remove(&k);
}
TomlValue::Table(root) TomlValue::Table(root)
} }
@@ -552,6 +585,76 @@ api_key = ""
assert!(caps.iter().any(|v| v.as_str() == Some("thinking"))); assert!(caps.iter().any(|v| v.as_str() == Some("thinking")));
} }
#[test]
fn kimi_code_export_syncs_raw_other_top_level_sections() {
// Regression: UI-managed top-level sections ([thinking],
// [experimental], [secondary_model], ...) collected into raw_other at
// import time must be written back on export; sections removed from
// raw_other must be dropped from the output.
let toml_str = r#"
default_model = "glm-5.2"
default_thinking = true
[providers."glmzhongzhuan"]
type = "anthropic"
api_key = "sk-test"
[models."glm-5.2"]
provider = "glmzhongzhuan"
model = "glm-5.2"
max_context_size = 900000
[thinking]
enabled = true
[experimental]
secondary-model = true
[secondary_model]
model = "glm-5.2"
default_effort = "low"
"#;
let value: TomlValue = toml_str.parse().unwrap();
let mut config = kimi_code_to_config(&value);
// raw_other collected the unknown top-level sections
let raw = config.raw_other.as_object().unwrap();
assert!(raw.contains_key("thinking"));
assert!(raw.contains_key("experimental"));
assert!(raw.contains_key("secondary_model"));
// UI edit: flip the experimental flag off and drop secondary_model
let mut raw = config.raw_other.as_object().unwrap().clone();
raw.insert(
"experimental".to_string(),
serde_json::json!({"secondary-model": false}),
);
raw.remove("secondary_model");
config.raw_other = serde_json::Value::Object(raw);
let exported = config_to_kimi_code(&config, Some(&value));
let root = exported.as_table().unwrap();
// Updated section is written back
let exp = root.get("experimental").unwrap().as_table().unwrap();
assert_eq!(
exp.get("secondary-model").and_then(|v| v.as_bool()),
Some(false)
);
// Removed section is dropped
assert!(root.get("secondary_model").is_none());
// Untouched sections are preserved
let thinking = root.get("thinking").unwrap().as_table().unwrap();
assert_eq!(
thinking.get("enabled").and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
root.get("default_thinking").and_then(|v| v.as_bool()),
Some(true)
);
}
#[test] #[test]
fn kimi_code_export_does_not_inject_thinking_from_supports_1m() { fn kimi_code_export_does_not_inject_thinking_from_supports_1m() {
let mut providers = IndexMap::new(); let mut providers = IndexMap::new();
+2
View File
@@ -44,6 +44,7 @@ pub fn run() {
let icon = app.default_window_icon().unwrap().clone(); let icon = app.default_window_icon().unwrap().clone();
TrayIconBuilder::new() TrayIconBuilder::new()
.icon(icon) .icon(icon)
.tooltip("Kimi Switch")
.menu(&menu) .menu(&menu)
.show_menu_on_left_click(false) .show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id.as_ref() { .on_menu_event(|app, event| match event.id.as_ref() {
@@ -110,6 +111,7 @@ pub fn run() {
commands::open_external_url, commands::open_external_url,
commands::kimi_oauth_start, commands::kimi_oauth_start,
commands::kimi_oauth_poll, commands::kimi_oauth_poll,
commands::get_experimental_env_status,
dashboard::get_paths, dashboard::get_paths,
dashboard::get_prices, dashboard::get_prices,
dashboard::get_summary, dashboard::get_summary,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"productName": "Kimi Switch", "productName": "Kimi Switch",
"version": "0.7.0", "version": "0.7.1",
"identifier": "com.kimiswitch.app", "identifier": "com.kimiswitch.app",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
+21 -2
View File
@@ -5,6 +5,7 @@ import { useConfig } from "./hooks/useConfig";
import { useUpdateCheck } from "./hooks/useUpdateCheck"; import { useUpdateCheck } from "./hooks/useUpdateCheck";
import { ProviderList } from "./components/ProviderList"; import { ProviderList } from "./components/ProviderList";
import { ProviderEdit } from "./components/ProviderEdit"; import { ProviderEdit } from "./components/ProviderEdit";
import { SubagentSettingsPage } from "./components/SubagentSettingsPage";
import { DashboardPage } from "./components/dashboard/DashboardPage"; import { DashboardPage } from "./components/dashboard/DashboardPage";
import { SessionsPage } from "./components/sessions/SessionsPage"; import { SessionsPage } from "./components/sessions/SessionsPage";
import { SettingsModal } from "./components/SettingsModal"; import { SettingsModal } from "./components/SettingsModal";
@@ -70,7 +71,7 @@ export default function App() {
updateConfig, updateConfig,
} = useConfig(agent); } = useConfig(agent);
const [view, setView] = useState<"list" | "edit" | "dashboard" | "sessions">("list"); const [view, setView] = useState<"list" | "edit" | "subagent" | "dashboard" | "sessions">("list");
const [editingProvider, setEditingProvider] = useState<string>(""); const [editingProvider, setEditingProvider] = useState<string>("");
const [loadTimeout, setLoadTimeout] = useState(false); const [loadTimeout, setLoadTimeout] = useState(false);
const [switchMessage, setSwitchMessage] = useState<string | null>(null); const [switchMessage, setSwitchMessage] = useState<string | null>(null);
@@ -573,7 +574,7 @@ export default function App() {
type="button" type="button"
onClick={() => setView("list")} onClick={() => setView("list")}
className={`px-3 py-1 text-sm rounded transition-colors ${ className={`px-3 py-1 text-sm rounded transition-colors ${
view === "list" || view === "edit" view === "list" || view === "edit" || view === "subagent"
? "bg-blue-600 text-white" ? "bg-blue-600 text-white"
: "text-content-muted hover:text-content-primary" : "text-content-muted hover:text-content-primary"
}`} }`}
@@ -649,6 +650,15 @@ export default function App() {
<DashboardPage /> <DashboardPage />
) : view === "sessions" ? ( ) : view === "sessions" ? (
<SessionsPage /> <SessionsPage />
) : view === "subagent" ? (
<SubagentSettingsPage
rawOther={config.raw_other}
models={config.models}
onChange={(nextRawOther) =>
updateConfig((cfg) => ({ ...cfg, raw_other: nextRawOther }))
}
onBack={() => setView("list")}
/>
) : view === "list" ? ( ) : view === "list" ? (
<ProviderList <ProviderList
providers={providers} providers={providers}
@@ -813,6 +823,15 @@ export default function App() {
> >
{t("openConfigDir")} {t("openConfigDir")}
</button> </button>
{view === "list" && (
<button
type="button"
onClick={() => setView("subagent")}
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
{t("subagentSettings")}
</button>
)}
</div> </div>
</footer> </footer>
)} )}
+1 -100
View File
@@ -1,8 +1,8 @@
import type { ReactNode } from "react";
import { useTranslation } from "../i18n"; import { useTranslation } from "../i18n";
import type { TranslationKey } from "../i18n/zh"; import type { TranslationKey } from "../i18n/zh";
import { getAgentSettings, setAgentSettings } from "../lib/agent-settings"; import { getAgentSettings, setAgentSettings } from "../lib/agent-settings";
import type { AgentSettings, Hook, PermissionRule } from "../types"; import type { AgentSettings, Hook, PermissionRule } from "../types";
import { Card, Checkbox, NumberField, Segmented } from "./ui/controls";
interface AgentSettingsPanelProps { interface AgentSettingsPanelProps {
rawOther: unknown; rawOther: unknown;
@@ -275,102 +275,3 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
</div> </div>
); );
} }
function Card({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="bg-panel border border-border rounded-xl p-4 space-y-3">
<h4 className="text-content-muted text-sm font-medium">{title}</h4>
{children}
</div>
);
}
function Checkbox({
label,
checked,
disabled,
onChange,
}: {
label: string;
checked: boolean;
disabled?: boolean;
onChange: (checked: boolean) => void;
}) {
return (
<label
className={`flex items-center gap-2 text-sm ${
disabled ? "text-content-muted" : "text-content-primary"
}`}
>
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
className="rounded border-border bg-input text-blue-600 focus:ring-blue-500"
/>
{label}
</label>
);
}
function NumberField({
label,
value,
onChange,
}: {
label: string;
value?: number;
onChange: (value: number | undefined) => void;
}) {
return (
<label className="flex items-center gap-3 text-sm text-content-primary">
<span className="text-content-muted">{label}</span>
<input
type="number"
value={value ?? ""}
onChange={(e) => {
const raw = e.target.value;
onChange(raw === "" ? undefined : Number(raw));
}}
className="w-32 bg-input border border-border rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</label>
);
}
function Segmented({
options,
value,
onChange,
disabled,
}: {
options: { key: string; label: string }[];
value: string;
onChange: (value: string) => void;
disabled?: boolean;
}) {
return (
<div
className={`flex items-center rounded overflow-hidden border border-border ${
disabled ? "opacity-50" : ""
}`}
>
{options.map((opt) => (
<button
key={opt.key}
type="button"
disabled={disabled}
onClick={() => onChange(opt.key)}
className={`px-3 py-1 text-sm ${
value === opt.key
? "bg-blue-600 text-white"
: "bg-input text-content-muted hover:bg-hover-2"
}`}
>
{opt.label}
</button>
))}
</div>
);
}
+12
View File
@@ -384,6 +384,18 @@ export function SettingsModal({
billowliu2.github.io/KimiSwitch billowliu2.github.io/KimiSwitch
</button> </button>
</div> </div>
<div>
{t("kimiEyesSite")}:{" "}
<button
type="button"
onClick={() =>
openExternal("https://billowliu2.github.io/kimi-eyes")
}
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer"
>
billowliu2.github.io/kimi-eyes
</button>
</div>
<div> <div>
{t("attributionPorted")}{" "} {t("attributionPorted")}{" "}
<button <button
+239
View File
@@ -0,0 +1,239 @@
import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useTranslation } from "../i18n";
import type { TranslationKey } from "../i18n/zh";
import type { ExperimentalEnvStatus, Model } from "../types";
import {
EXPERIMENTAL_FLAGS,
clearSecondaryModel,
forcedEnvValue,
getExperimentalFlags,
getSecondaryModel,
isFlagLockedByEnv,
isMasterEnvOn,
setExperimentalFlag,
setSecondaryModelOnly,
type ExperimentalFlagDef,
} from "../lib/subagent-settings";
import { Card, Checkbox } from "./ui/controls";
interface SubagentSettingsPageProps {
/** config.raw_other — hosts the `[experimental]` and `[secondary_model]` sections. */
rawOther: unknown;
/** All configured models (alias → entry) for the secondary-model picker. */
models: Record<string, Model>;
onChange: (nextRawOther: unknown) => void;
onBack: () => void;
}
const EFFORTS = ["low", "medium", "high", "max"] as const;
const EFFORT_LABELS: Record<(typeof EFFORTS)[number], TranslationKey> = {
low: "thinkingLow",
medium: "thinkingMedium",
high: "thinkingHigh",
max: "thinkingMax",
};
const FLAG_LABELS: Record<string, { name: TranslationKey; desc: TranslationKey }> = {
"secondary-model": { name: "flagSecondaryModel", desc: "flagSecondaryModelDesc" },
"tool-select": { name: "flagToolSelect", desc: "flagToolSelectDesc" },
"acp-v2": { name: "flagAcpV2", desc: "flagAcpV2Desc" },
persistence_minidb_readmodel: {
name: "flagMinidbReadmodel",
desc: "flagMinidbReadmodelDesc",
},
};
export function SubagentSettingsPage({
rawOther,
models,
onChange,
onBack,
}: SubagentSettingsPageProps) {
const { t } = useTranslation();
const [env, setEnv] = useState<ExperimentalEnvStatus | null>(null);
useEffect(() => {
invoke<ExperimentalEnvStatus>("get_experimental_env_status")
.then(setEnv)
.catch(() => setEnv({}));
}, []);
const flags = getExperimentalFlags(rawOther);
const secondary = getSecondaryModel(rawOther);
const masterOn = isMasterEnvOn(env);
const secondaryFlag = EXPERIMENTAL_FLAGS[0]; // "secondary-model"
const otherFlags = EXPERIMENTAL_FLAGS.slice(1);
/** Effective state of the secondary-model flag (env overrides config). */
const secondaryEnabled = masterOn
? true
: isFlagLockedByEnv(env, secondaryFlag)
? forcedEnvValue(env, secondaryFlag)
: flags["secondary-model"] === true;
const aliasList = Object.keys(models).sort();
// Inherited settings shown for the picked model: subagents bind the pointed
// entry directly (no patch fields), so context size and effort come from the
// model entry / global [thinking], not from a `[secondary_model]` patch.
const selectedEntry = secondary.model ? models[secondary.model] : undefined;
const inheritedContext = selectedEntry?.max_context_size;
const modelEffort =
selectedEntry &&
typeof (selectedEntry.raw_other as Record<string, unknown> | undefined)
?.default_effort === "string"
? ((selectedEntry.raw_other as Record<string, unknown>).default_effort as string)
: undefined;
const thinkingEffort = (() => {
const ro = rawOther as Record<string, unknown> | undefined;
const thinking = ro?.thinking as Record<string, unknown> | undefined;
return thinking && typeof thinking.effort === "string" && thinking.effort
? thinking.effort
: undefined;
})();
const inheritedEffort = modelEffort ?? thinkingEffort;
const fmtContext = (n: number): string =>
n >= 1_000_000
? `${(n / 1_000_000).toFixed(1)}M`
: n >= 1000
? `${Math.round(n / 1000)}K`
: String(n);
const effortLabel = (e: string): string => {
const key = EFFORT_LABELS[e as (typeof EFFORTS)[number]];
return key ? t(key) : e;
};
const renderFlagRow = (flag: ExperimentalFlagDef) => {
const labels = FLAG_LABELS[flag.id];
const locked = masterOn || isFlagLockedByEnv(env, flag);
const on = masterOn
? true
: isFlagLockedByEnv(env, flag)
? forcedEnvValue(env, flag)
: flags[flag.id] === true;
return (
<div key={flag.id} className="flex items-center gap-3">
<div className="flex-1 min-w-0">
<div className="text-sm text-content-primary">{t(labels.name)}</div>
<div className="text-xs text-content-muted">
{t(labels.desc)}
<code className="ml-2">{flag.envVar}</code>
</div>
</div>
{locked && (
<span className="text-xs text-content-muted shrink-0">
🔒 {t("lockedByEnv")}
</span>
)}
<Checkbox
label=""
checked={on}
disabled={locked}
onChange={(checked) =>
onChange(setExperimentalFlag(rawOther, flag.id, checked))
}
/>
</div>
);
};
return (
<div className="h-full flex flex-col bg-app">
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-border bg-panel">
<button
type="button"
onClick={onBack}
className="p-2 rounded-lg hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
← {t("back")}
</button>
<h2 className="font-semibold text-lg">{t("subagentSettings")}</h2>
</div>
<div className="flex-1 overflow-auto p-6 space-y-4">
{masterOn && (
<div className="bg-green-100 dark:bg-green-900/20 border border-green-300 dark:border-green-500/30 rounded-lg px-4 py-2 text-sm text-green-700 dark:text-green-400">
{t("masterEnvOnHint")}
</div>
)}
{/* Subagent: experimental flag + secondary model in one card */}
<Card title={t("secondaryModelSection")}>
{renderFlagRow(secondaryFlag)}
<div className="border-t border-border" />
{!secondaryEnabled && (
<div className="bg-amber-100 dark:bg-amber-900/20 border border-amber-300 dark:border-amber-500/30 rounded-lg px-3 py-2 text-sm text-amber-800 dark:text-amber-400">
{t("secondaryModelDisabledHint")}
</div>
)}
<label className="flex items-center gap-3 text-sm text-content-primary">
<span className="text-content-muted">{t("secondaryModelLabel")}</span>
<select
className="flex-1 max-w-md bg-input border border-border rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={secondary.model ?? ""}
onChange={(e) => {
const alias = e.target.value;
if (alias === "") {
onChange(clearSecondaryModel(rawOther));
} else {
// Only the model reference is persisted — no patch fields.
// Subagents bind the pointed entry directly and inherit its
// settings; usage records stay keyed on the real alias
// instead of Kimi Code's synthesized `__secondary__` entry.
onChange(setSecondaryModelOnly(rawOther, alias));
}
}}
>
<option value="">{t("secondaryModelUnset")}</option>
{aliasList.map((alias) => (
<option key={alias} value={alias}>
{models[alias]?.display_name
? `${alias}(${models[alias].display_name})`
: alias}
</option>
))}
</select>
</label>
{secondary.model !== undefined && (
<div className="rounded-lg border border-border bg-input/40 px-3 py-2 space-y-1.5">
<div className="flex items-center gap-3 text-sm">
<span className="text-content-muted w-24 shrink-0">
{t("secondaryModelInheritedContext")}
</span>
<span className="text-content-primary">
{inheritedContext ? fmtContext(inheritedContext) : "—"}
</span>
</div>
<div className="flex items-center gap-3 text-sm">
<span className="text-content-muted w-24 shrink-0">
{t("secondaryModelInheritedEffort")}
</span>
<span className="text-content-primary">
{inheritedEffort ? effortLabel(inheritedEffort) : "—"}
</span>
</div>
<p className="text-xs text-content-muted">
{t("secondaryModelInheritedHint")}
</p>
</div>
)}
<p className="text-xs text-content-muted">{t("secondaryModelHint")}</p>
<p className="text-xs text-content-muted">{t("secondaryModelPriorityHint")}</p>
</Card>
{/* Other experimental features (not subagent-related) */}
<Card title={t("otherExperimentalFlags")}>
{otherFlags.map(renderFlagRow)}
<p className="text-xs text-content-muted">{t("experimentalFlagsHint")}</p>
</Card>
</div>
</div>
);
}
+4
View File
@@ -59,6 +59,10 @@ export function DailyBars({ daily, dimension, names, unknownProviderLabel }: Dai
if (dimension === "provider") { if (dimension === "provider") {
return key === "unknown" ? unknownProviderLabel : key; return key === "unknown" ? unknownProviderLabel : key;
} }
// Subagent records (Kimi Code `__secondary__` marker) show their own label.
if (key === "__secondary__") {
return t("subagentModel");
}
return shortModel(key); return shortModel(key);
}; };
@@ -25,6 +25,10 @@ export function DailyDetailModal({ day, dimension, colorMap, unknownProviderLabe
const { t } = useTranslation(); const { t } = useTranslation();
const { money } = useCurrency(); const { money } = useCurrency();
/** Subagent records (Kimi Code `__secondary__` marker) show their own label. */
const modelName = (model: string) =>
model === "__secondary__" ? t("subagentModel") : shortModel(model);
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose(); if (e.key === "Escape") onClose();
@@ -143,7 +147,7 @@ export function DailyDetailModal({ day, dimension, colorMap, unknownProviderLabe
<div className="mt-1 ml-5 space-y-0.5"> <div className="mt-1 ml-5 space-y-0.5">
{p.models.map((m) => ( {p.models.map((m) => (
<div key={m.model} className="flex items-baseline justify-between gap-2 text-xs"> <div key={m.model} className="flex items-baseline justify-between gap-2 text-xs">
<span className="truncate text-content-muted" title={m.model}>{shortModel(m.model)}</span> <span className="truncate text-content-muted" title={m.model}>{modelName(m.model)}</span>
<span className="shrink-0 text-content-muted tabular-nums">{fmtTokens(m.tokens)}</span> <span className="shrink-0 text-content-muted tabular-nums">{fmtTokens(m.tokens)}</span>
</div> </div>
))} ))}
@@ -167,7 +171,7 @@ export function DailyDetailModal({ day, dimension, colorMap, unknownProviderLabe
<span className="inline-block h-2.5 w-2.5 rounded-sm shrink-0" style={{ backgroundColor: modelColor(i) }} /> <span className="inline-block h-2.5 w-2.5 rounded-sm shrink-0" style={{ backgroundColor: modelColor(i) }} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2"> <div className="flex items-baseline justify-between gap-2">
<span className="truncate text-content-primary" title={s.model}>{shortModel(s.model)}</span> <span className="truncate text-content-primary" title={s.model}>{modelName(s.model)}</span>
<span className="shrink-0 text-xs text-content-muted tabular-nums"> <span className="shrink-0 text-xs text-content-muted tabular-nums">
{fmtTokens(s.tokens)} <span className="text-content-muted">· {s.pct.toFixed(1)}%</span> {fmtTokens(s.tokens)} <span className="text-content-muted">· {s.pct.toFixed(1)}%</span>
</span> </span>
+10 -2
View File
@@ -310,7 +310,9 @@ export function DashboardPage() {
}} }}
/> />
<div className="flex items-center gap-1.5 min-w-0"> <div className="flex items-center gap-1.5 min-w-0">
<span className="text-content-primary whitespace-nowrap truncate">{m.modelDisplay || m.model}</span> <span className="text-content-primary whitespace-nowrap truncate">
{m.isSecondary ? t("subagentModel") : m.modelDisplay || m.model}
</span>
{m.costEstimated && ( {m.costEstimated && (
<span className="text-[10px] text-yellow-600 shrink-0">({t("estimate")})</span> <span className="text-[10px] text-yellow-600 shrink-0">({t("estimate")})</span>
)} )}
@@ -371,7 +373,13 @@ export function DashboardPage() {
<td className="py-1.5 pr-4 text-content-muted whitespace-nowrap"> <td className="py-1.5 pr-4 text-content-muted whitespace-nowrap">
{fmtTime(r.time)} {fmtTime(r.time)}
</td> </td>
<td className="py-1.5 pr-4 text-content-primary">{r.modelDisplay || r.model}</td> <td className="py-1.5 pr-4 text-content-primary">
<span className="flex items-center gap-1.5 min-w-0">
<span className="truncate">
{r.isSecondary ? t("subagentModel") : r.modelDisplay || r.model}
</span>
</span>
</td>
<td className="py-1.5 pr-4 text-right text-content-muted">{fmtTokens(r.inputOther)}</td> <td className="py-1.5 pr-4 text-right text-content-muted">{fmtTokens(r.inputOther)}</td>
<td className="py-1.5 pr-4 text-right text-content-muted">{fmtTokens(r.output)}</td> <td className="py-1.5 pr-4 text-right text-content-muted">{fmtTokens(r.output)}</td>
<td className="py-1.5 pr-4 text-right text-content-muted">{fmtTokens(r.inputCacheRead)}</td> <td className="py-1.5 pr-4 text-right text-content-muted">{fmtTokens(r.inputCacheRead)}</td>
+104
View File
@@ -0,0 +1,104 @@
import type { ReactNode } from "react";
/** Shared small form controls used by settings panels (Agent settings,
* Subagent settings, ...). Extracted from AgentSettingsPanel.tsx so new
* panels can reuse the same look without duplicating code. */
export function Card({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="bg-panel border border-border rounded-xl p-4 space-y-3">
<h4 className="text-content-muted text-sm font-medium">{title}</h4>
{children}
</div>
);
}
export function Checkbox({
label,
checked,
disabled,
onChange,
}: {
label: string;
checked: boolean;
disabled?: boolean;
onChange: (checked: boolean) => void;
}) {
return (
<label
className={`flex items-center gap-2 text-sm ${
disabled ? "text-content-muted" : "text-content-primary"
}`}
>
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
className="rounded border-border bg-input text-blue-600 focus:ring-blue-500"
/>
{label}
</label>
);
}
export function NumberField({
label,
value,
onChange,
}: {
label: string;
value?: number;
onChange: (value: number | undefined) => void;
}) {
return (
<label className="flex items-center gap-3 text-sm text-content-primary">
<span className="text-content-muted">{label}</span>
<input
type="number"
value={value ?? ""}
onChange={(e) => {
const raw = e.target.value;
onChange(raw === "" ? undefined : Number(raw));
}}
className="w-32 bg-input border border-border rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</label>
);
}
export function Segmented({
options,
value,
onChange,
disabled,
}: {
options: { key: string; label: string }[];
value: string;
onChange: (value: string) => void;
disabled?: boolean;
}) {
return (
<div
className={`flex items-center rounded overflow-hidden border border-border ${
disabled ? "opacity-50" : ""
}`}
>
{options.map((opt) => (
<button
key={opt.key}
type="button"
disabled={disabled}
onClick={() => onChange(opt.key)}
className={`px-3 py-1 text-sm ${
value === opt.key
? "bg-blue-600 text-white"
: "bg-input text-content-muted hover:bg-hover-2"
}`}
>
{opt.label}
</button>
))}
</div>
);
}
+32
View File
@@ -263,6 +263,7 @@ export const enTranslations: Record<TranslationKey, string> = {
lastChecked: "Last checked", lastChecked: "Last checked",
appDescription: "Windows desktop LLM provider config manager", appDescription: "Windows desktop LLM provider config manager",
settingsWebsite: "Official website", settingsWebsite: "Official website",
kimiEyesSite: "Kimi Eyes plugin site",
referenceProject: "Reference project", referenceProject: "Reference project",
// Validation errors // Validation errors
@@ -319,6 +320,7 @@ export const enTranslations: Record<TranslationKey, string> = {
colCacheRead: "Cache read", colCacheRead: "Cache read",
unitTimes: "requests", unitTimes: "requests",
estimate: "est.", estimate: "est.",
subagentModel: "Subagent model",
collapse: "Collapse", collapse: "Collapse",
expandAll: "Expand all ({n})", expandAll: "Expand all ({n})",
@@ -446,4 +448,34 @@ export const enTranslations: Record<TranslationKey, string> = {
trendLineShowRequests: "Show requests", trendLineShowRequests: "Show requests",
trendLineTooltip: "{date}\nTokens: {tokens}\nCache hit: {cache}\nRequests: {req}", trendLineTooltip: "{date}\nTokens: {tokens}\nCache hit: {cache}\nRequests: {req}",
trendLineNoData: "No data", trendLineNoData: "No data",
// Subagent settings page (experimental flags + secondary model)
subagentSettings: "Subagent Settings",
experimentalFlags: "Experimental Features",
otherExperimentalFlags: "Other Experimental Features",
experimentalFlagsHint:
"Toggles are written to the [experimental] section of config.toml; env vars take precedence and locked toggles cannot be changed here",
flagSecondaryModel: "Subagent Secondary Model",
flagSecondaryModelDesc: "Newly spawned subagents bind a second (usually cheaper) model config",
flagToolSelect: "Tool Select",
flagToolSelectDesc: "Enable the experimental tool selection feature",
flagAcpV2: "ACP v2 Protocol",
flagAcpV2Desc: "Enable Agent Communication Protocol v2",
flagMinidbReadmodel: "MiniDB Read Model",
flagMinidbReadmodelDesc: "Enable the v2 engine MiniDB read-model backend",
lockedByEnv: "Locked by env var",
masterEnvOnHint: "KIMI_CODE_EXPERIMENTAL_FLAG is set: all experimental features are forced on by the env var",
secondaryModelSection: "Subagent Model (Secondary Model)",
secondaryModelDisabledHint:
"The experimental flag \"Subagent Secondary Model\" is off and this config will not take effect — enable the toggle above first",
secondaryModelLabel: "Secondary model",
secondaryModelUnset: "Not configured",
secondaryModelInheritedContext: "Inherited context",
secondaryModelInheritedEffort: "Inherited effort",
secondaryModelInheritedHint:
"Subagents bind the selected model directly and inherit its context size and effort — no patch needed",
secondaryModelHint:
"Second model config bound to subagents by default; when unset, subagents inherit the primary model. Only the model reference is stored; subagents inherit all of that model's settings",
secondaryModelPriorityHint:
"Priority: explicit tool model param > profile model_preference > this config > inherit primary model",
}; };
+32
View File
@@ -258,6 +258,7 @@ export const zhTranslations = {
lastChecked: "上次检查", lastChecked: "上次检查",
appDescription: "Windows 桌面端 LLM 供应商配置管理器", appDescription: "Windows 桌面端 LLM 供应商配置管理器",
settingsWebsite: "官方网站", settingsWebsite: "官方网站",
kimiEyesSite: "Kimi Eyes 插件站",
referenceProject: "引用项目", referenceProject: "引用项目",
// Validation errors // Validation errors
@@ -314,6 +315,7 @@ export const zhTranslations = {
colCacheRead: "缓存读", colCacheRead: "缓存读",
unitTimes: "次", unitTimes: "次",
estimate: "估算", estimate: "估算",
subagentModel: "子代理模型",
collapse: "收起", collapse: "收起",
expandAll: "展开全部 ({n})", expandAll: "展开全部 ({n})",
@@ -441,6 +443,36 @@ export const zhTranslations = {
trendLineShowRequests: "显示请求数", trendLineShowRequests: "显示请求数",
trendLineTooltip: "{date}\nToken: {tokens}\n命中率: {cache}\n请求: {req}", trendLineTooltip: "{date}\nToken: {tokens}\n命中率: {cache}\n请求: {req}",
trendLineNoData: "暂无数据", trendLineNoData: "暂无数据",
// Subagent settings page (experimental flags + secondary model)
subagentSettings: "子代理设置",
experimentalFlags: "实验功能开关",
otherExperimentalFlags: "其他实验功能",
experimentalFlagsHint:
"开关写入 config.toml 的 [experimental] 节;环境变量优先级更高,被锁定的开关无法在此修改",
flagSecondaryModel: "子代理次主力模型",
flagSecondaryModelDesc: "子代理派发时绑定第二份(通常更便宜的)模型配置",
flagToolSelect: "工具选择",
flagToolSelectDesc: "启用实验性工具选择功能",
flagAcpV2: "ACP v2 协议",
flagAcpV2Desc: "启用 Agent Communication Protocol v2",
flagMinidbReadmodel: "MiniDB 读模型",
flagMinidbReadmodelDesc: "启用 v2 引擎 MiniDB 读模型后端",
lockedByEnv: "被环境变量锁定",
masterEnvOnHint: "KIMI_CODE_EXPERIMENTAL_FLAG 已设置:全部实验功能被环境变量强制开启",
secondaryModelSection: "子代理模型(次主力模型)",
secondaryModelDisabledHint:
"实验开关「子代理次主力模型」未启用,本配置不会生效——请先在上方打开对应开关",
secondaryModelLabel: "次主力模型",
secondaryModelUnset: "未配置",
secondaryModelInheritedContext: "继承上下文长度",
secondaryModelInheritedEffort: "继承思考模式",
secondaryModelInheritedHint:
"子代理直接绑定所选模型,继承其上下文长度与思考设置,无需单独配置补丁",
secondaryModelHint:
"子代理默认绑定的第二份模型配置;未配置时子代理继承主模型;仅保存模型引用,子代理继承该模型的全部设置",
secondaryModelPriorityHint:
"优先级:工具显式 model 参数 > 子代理 profile 的 model_preference > 本配置 > 继承主模型",
} as const; } as const;
export type TranslationKey = keyof typeof zhTranslations; export type TranslationKey = keyof typeof zhTranslations;
+8635 -4926
View File
File diff suppressed because it is too large. Load diff
+7152 -3842
View File
File diff suppressed because it is too large. Load diff
+155
View File
@@ -0,0 +1,155 @@
import type { ExperimentalEnvStatus, SecondaryModelConfig } from "../types";
/**
* Helpers for the `[experimental]` and `[secondary_model]` sections of the
* Kimi Code config.toml. Both live in `config.raw_other` (the pass-through
* bucket for unknown top-level TOML sections), so these helpers read/write
* that JSON object directly.
*/
function asRecord(value: unknown): Record<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return {};
}
function isTruthyEnv(value: string | undefined): boolean {
if (!value) return false;
return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
}
// ---------------------------------------------------------------------------
// [experimental] — experimental feature flags
// ---------------------------------------------------------------------------
export interface ExperimentalFlagDef {
/** Flag id as stored in config.toml `[experimental]` (e.g. "secondary-model"). */
id: string;
/** Single-feature env var that can force the flag (locks the UI toggle). */
envVar: string;
}
/** Known experimental flags (Kimi Code v1 + v2 registries, merged). */
export const EXPERIMENTAL_FLAGS: ExperimentalFlagDef[] = [
{ id: "secondary-model", envVar: "KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL" },
{ id: "tool-select", envVar: "KIMI_CODE_EXPERIMENTAL_TOOL_SELECT" },
{ id: "acp-v2", envVar: "KIMI_CODE_EXPERIMENTAL_ACP_V2" },
{
id: "persistence_minidb_readmodel",
envVar: "KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL",
},
];
export const EXPERIMENTAL_MASTER_ENV = "KIMI_CODE_EXPERIMENTAL_FLAG";
/** Whether the master env var is set to a truthy value (locks every flag on). */
export function isMasterEnvOn(env: ExperimentalEnvStatus | null): boolean {
return isTruthyEnv(env?.[EXPERIMENTAL_MASTER_ENV]);
}
/** Whether a flag's value is overridden by its env var (set & non-empty),
* locking the config toggle. Kimi Code lets the env var force the flag on
* *or* off, so a set env var locks regardless of its truthiness. */
export function isFlagLockedByEnv(
env: ExperimentalEnvStatus | null,
flag: ExperimentalFlagDef
): boolean {
return env !== null && flag.envVar in env;
}
/** The value a locked flag is forced to by its env var. */
export function forcedEnvValue(
env: ExperimentalEnvStatus | null,
flag: ExperimentalFlagDef
): boolean {
return isTruthyEnv(env?.[flag.envVar]);
}
/** Read `[experimental]` from raw_other: `{ flagId: boolean }`. */
export function getExperimentalFlags(rawOther: unknown): Record<string, boolean> {
const section = asRecord(rawOther)["experimental"];
const flags = asRecord(section);
const out: Record<string, boolean> = {};
for (const [k, v] of Object.entries(flags)) {
out[k] = v === true;
}
return out;
}
/** Set one flag in `[experimental]`. Removing all flags drops the section. */
export function setExperimentalFlag(
rawOther: unknown,
id: string,
enabled: boolean
): unknown {
const root = { ...asRecord(rawOther) };
const flags = { ...getExperimentalFlags(rawOther) };
if (enabled) {
flags[id] = true;
} else {
delete flags[id];
}
if (Object.keys(flags).length > 0) {
root.experimental = flags;
} else {
delete root.experimental;
}
return root;
}
// ---------------------------------------------------------------------------
// [secondary_model] — subagent secondary model
// ---------------------------------------------------------------------------
/** Read `[secondary_model]` from raw_other; `{}` when absent. */
export function getSecondaryModel(rawOther: unknown): SecondaryModelConfig {
const section = asRecord(rawOther)["secondary_model"];
const cfg = asRecord(section);
const out: SecondaryModelConfig = {};
if (typeof cfg.model === "string" && cfg.model !== "") out.model = cfg.model;
if (typeof cfg.default_effort === "string" && cfg.default_effort !== "")
out.default_effort = cfg.default_effort;
if (typeof cfg.max_output_size === "number" && cfg.max_output_size > 0)
out.max_output_size = cfg.max_output_size;
if (typeof cfg.max_context_size === "number" && cfg.max_context_size > 0)
out.max_context_size = cfg.max_context_size;
return out;
}
/**
* Write `[secondary_model]` with only the `model` field — no patch fields.
* Subagents then bind the pointed model entry directly and inherit its
* settings (context size, capabilities, effort fallback), which also keeps
* usage records keyed on the real model alias instead of the synthesized
* `__secondary__` derived entry. Unknown fields already present in the
* section are preserved; the known patch fields (`default_effort`,
* `max_output_size`, `max_context_size`) are dropped so stale patches are
* cleaned up when the model is re-picked.
*/
export function setSecondaryModelOnly(
rawOther: unknown,
alias: string
): unknown {
const root = { ...asRecord(rawOther) };
const section = asRecord(asRecord(rawOther)["secondary_model"]);
const next: Record<string, unknown> = { model: alias };
const knownFields: ReadonlySet<string> = new Set([
"model",
"default_effort",
"max_output_size",
"max_context_size",
]);
for (const [k, v] of Object.entries(section)) {
if (!knownFields.has(k)) next[k] = v;
}
root.secondary_model = next;
return root;
}
/** Remove `[secondary_model]` entirely (the "not configured" option). */
export function clearSecondaryModel(rawOther: unknown): unknown {
const root = { ...asRecord(rawOther) };
delete root.secondary_model;
return root;
}
+4
View File
@@ -24,6 +24,8 @@ export interface ModelRow extends TotalsRow {
modelResolved: string; modelResolved: string;
priceId: string; priceId: string;
costEstimated: boolean; costEstimated: boolean;
/** True when any record in this row came from a subagent (secondary-model) request. */
isSecondary: boolean;
} }
export interface RecentRow { export interface RecentRow {
@@ -40,6 +42,8 @@ export interface RecentRow {
costEstimated: boolean; costEstimated: boolean;
priceId: string; priceId: string;
fromEnv: boolean; fromEnv: boolean;
/** True when this request came from a subagent (secondary-model) call. */
isSecondary: boolean;
} }
export interface RangeStats { export interface RangeStats {
+16
View File
@@ -127,3 +127,19 @@ export interface AgentSettings {
permission?: { rules?: PermissionRule[] }; permission?: { rules?: PermissionRule[] };
hooks?: Hook[]; hooks?: Hook[];
} }
/**
* Kimi Code `[secondary_model]` section of config.toml: the secondary model
* bound to newly spawned subagents (experimental feature).
* `model` points at an alias defined in `[models]`; the remaining fields are
* a patch applied on top of the referenced entry (subagents only).
*/
export interface SecondaryModelConfig {
model?: string;
default_effort?: string;
max_output_size?: number;
max_context_size?: number;
}
/** Values of experimental env vars that are currently set (non-empty). */
export type ExperimentalEnvStatus = Record<string, string>;
+2
View File
@@ -152,6 +152,7 @@ const zh = {
"kimi usages 响应异常时透出原始返回,便于对照接口排查", "kimi usages 响应异常时透出原始返回,便于对照接口排查",
"models.dev 价格快照同步", "models.dev 价格快照同步",
"关于页新增官方网站链接", "关于页新增官方网站链接",
"设置-关于页新增 Kimi Eyes 识图插件站链接",
], ],
}, },
{ {
@@ -463,6 +464,7 @@ const en: Dict = {
"kimi usages failure now exposes the raw response for easier debugging", "kimi usages failure now exposes the raw response for easier debugging",
"models.dev price snapshot sync", "models.dev price snapshot sync",
"About page gains an official website link", "About page gains an official website link",
"Settings-About page gains a Kimi Eyes plugin site link",
], ],
}, },
{ {