feat(settings): 设置弹窗改为全局设置页面(全局/高级/关于三 Tab)+ 体检账单缺口与更新下载修复 — bump to v0.8.5
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s

This commit is contained in:
KimiSwitch Dev committed 2026-10-16 08:50:45 +08:00
1 parent e00f6b6591
commit 38dbb5527a
50 files changed
+3904 -2571

No files matched your search

+39 -26
View File
@@ -9,7 +9,7 @@ import { SubagentSettingsPage } from "./components/SubagentSettingsPage";
import { DashboardPage } from "./components/dashboard/DashboardPage";
import { SessionsPage } from "./components/sessions/SessionsPage";
import { PluginMarketplacePage } from "./components/plugins/PluginMarketplacePage";
import { SettingsModal } from "./components/SettingsModal";
import { SettingsPage } from "./components/settings/SettingsPage";
import { UsageConfigModal } from "./components/UsageConfigModal";
import { PresetPickerModal } from "./components/PresetPickerModal";
import { useTranslation } from "./i18n";
@@ -81,12 +81,14 @@ export default function App() {
} = useConfig(agent);
const [view, setView] = useState<
"list" | "edit" | "subagent" | "dashboard" | "sessions" | "plugins"
"list" | "edit" | "subagent" | "dashboard" | "sessions" | "plugins" | "settings"
>("list");
const [editingProvider, setEditingProvider] = useState<string>("");
const [loadTimeout, setLoadTimeout] = useState(false);
const [switchMessage, setSwitchMessage] = useState<string | null>(null);
const [showSettings, setShowSettings] = useState(false);
const [settingsTab, setSettingsTab] = useState<"general" | "global" | "advanced">(
"general",
);
const [showPresetPicker, setShowPresetPicker] = useState(false);
/** Provider whose usage-config panel is open; null = closed. */
const [usageConfigProvider, setUsageConfigProvider] = useState<string | null>(null);
@@ -628,6 +630,20 @@ export default function App() {
>
{t("sessions")}
</button>
<button
type="button"
onClick={() => {
setSettingsTab("advanced");
setView("settings");
}}
className={`px-3 py-1 text-sm rounded transition-colors ${
view === "settings" && settingsTab === "advanced"
? "bg-blue-600 text-white"
: "text-content-muted hover:text-content-primary"
}`}
>
{t("subagentSettings")}
</button>
{/* 插件市场功能尚未成熟,导航入口暂时隐藏;页面代码保留,
恢复时把下面的按钮加回即可(view/渲染分支均未移除)。
<button
@@ -651,7 +667,10 @@ export default function App() {
{/* Settings gear */}
<button
type="button"
onClick={() => setShowSettings(true)}
onClick={() => {
setSettingsTab("global");
setView("settings");
}}
title={t("settings")}
className="relative p-1.5 rounded border border-border hover:bg-hover-2 transition-colors"
>
@@ -685,10 +704,26 @@ export default function App() {
<SessionsPage />
) : view === "plugins" ? (
<PluginMarketplacePage />
) : view === "settings" ? (
<SettingsPage
activeTab={settingsTab}
onTabChange={setSettingsTab}
updateInfo={updateInfo}
checking={checking}
onCheckUpdate={checkNow}
lastChecked={lastChecked}
rawOther={config.raw_other}
models={config.models}
defaultModel={config.default_model}
onRawOtherChange={(nextRawOther) =>
updateConfig((cfg) => ({ ...cfg, raw_other: nextRawOther }))
}
/>
) : view === "subagent" ? (
<SubagentSettingsPage
rawOther={config.raw_other}
models={config.models}
defaultModel={config.default_model}
onChange={(nextRawOther) =>
updateConfig((cfg) => ({ ...cfg, raw_other: nextRawOther }))
}
@@ -718,10 +753,6 @@ export default function App() {
(m) => m.provider === currentProvider.name
)}
defaultModel={config.default_model}
rawOther={config.raw_other}
onRawOtherChange={(nextRawOther) =>
updateConfig((cfg) => ({ ...cfg, raw_other: nextRawOther }))
}
onBack={handleEditBack}
onChange={handleUpdateProvider}
onDelete={() => handleDeleteProvider(currentProvider.name)}
@@ -903,28 +934,10 @@ export default function App() {
>
{t("openConfigDir")}
</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>
</footer>
)}
<SettingsModal
open={showSettings}
onClose={() => setShowSettings(false)}
updateInfo={updateInfo}
checking={checking}
onCheckUpdate={checkNow}
lastChecked={lastChecked}
/>
<PresetPickerModal
open={showPresetPicker}
onClose={() => setShowPresetPicker(false)}
+1 -21
View File
@@ -1,4 +1,4 @@
import { useEffect, useId, useMemo, useReducer, useRef, useState } from "react";
import { useEffect, useId, useReducer, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { invoke } from "@tauri-apps/api/core";
import { useTranslation } from "../i18n";
@@ -13,7 +13,6 @@ import {
} from "../lib/model-max-output";
import { capabilitiesFromRef, getModelRef, modelsDevReady } from "../lib/models-dev";
import { getIconMetadata } from "../icons/extracted/metadata";
import { AgentSettingsPanel } from "./AgentSettingsPanel";
import { KimiOAuthDialog } from "./KimiOAuthDialog";
import { ProviderIcon } from "./ProviderIcon";
import { IconPicker } from "./IconPicker";
@@ -90,8 +89,6 @@ interface ProviderEditProps {
provider: Provider;
models: Model[];
defaultModel: string | null;
rawOther: unknown;
onRawOtherChange: (nextRawOther: unknown) => void;
onBack: () => void;
onChange: (provider: Provider) => void;
onDelete: () => void;
@@ -109,8 +106,6 @@ export function ProviderEdit({
provider,
models,
defaultModel,
rawOther,
onRawOtherChange,
onBack,
onChange,
onDelete,
@@ -151,13 +146,6 @@ export function ProviderEdit({
const apiKeyEnvSupported = agent === "kimi_code";
const apiKeyEnvMode = apiKeyEnvSupported && provider.api_key_env != null;
// Alias → entry lookup for panels that resolve a model by alias
// (AgentSettingsPanel reads the default model's support_efforts).
const modelsByAlias = useMemo(
() => Object.fromEntries(models.map((m) => [m.alias, m])),
[models]
);
useEffect(() => {
const def = defaultBaseUrl(agent, provider.provider_type);
if (!def) return;
@@ -474,14 +462,6 @@ export function ProviderEdit({
onBulkAdd={onBulkAdd}
onSetDefault={onSetDefault}
/>
{agent === "kimi_code" && (
<AgentSettingsPanel
rawOther={rawOther}
onChange={onRawOtherChange}
models={modelsByAlias}
defaultModel={defaultModel}
/>
)}
</>
)}
-563
View File
@@ -1,563 +0,0 @@
import { useEffect, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useTranslation, type Language } from "../i18n";
import { useTheme, type Theme } from "../hooks/useTheme";
import type { UpdateInfo } from "../hooks/useUpdateCheck";
import {
useKimiCodeVersion,
isNewerVersion,
type KimiCodeStatus,
} from "../hooks/useKimiCodeVersion";
function formatVersion(version: string): string {
const v = version.trim();
return v.startsWith("v") || v.startsWith("V") ? v : `v${v}`;
}
/** 上游 kimi-code releases 页面。 */
const KIMI_CODE_RELEASES_URL = "https://github.com/MoonshotAI/kimi-code/releases";
interface SettingsModalProps {
open: boolean;
onClose: () => void;
updateInfo: UpdateInfo | null;
checking: boolean;
onCheckUpdate: () => void;
lastChecked: number | null;
}
export function SettingsModal({
open,
onClose,
updateInfo,
checking,
onCheckUpdate,
lastChecked,
}: SettingsModalProps) {
const { t, lang, setLang } = useTranslation();
const { theme, setTheme } = useTheme();
const {
info: kimiCodeInfo,
checking: kimiCodeChecking,
checkNow: checkKimiCode,
} = useKimiCodeVersion();
const [appVersion, setAppVersion] = useState("");
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
const [downloadedPath, setDownloadedPath] = useState<string | null>(null);
const [downloadError, setDownloadError] = useState<string | null>(null);
useEffect(() => {
if (open) {
invoke<string>("get_app_version").then(setAppVersion).catch(() => {});
}
}, [open]);
// Listen to download progress events
useEffect(() => {
const unlisteners: (() => void)[] = [];
const setupProgress = async () => {
const un1 = await listen<{ downloaded: number; total: number; progress: number }>(
"download-progress",
(event) => {
setDownloadProgress(event.payload.progress);
}
);
unlisteners.push(() => un1());
const un2 = await listen<{ path: string }>(
"download-complete",
(event) => {
setDownloadedPath(event.payload.path);
setDownloadProgress(100);
}
);
unlisteners.push(() => un2());
};
setupProgress();
return () => unlisteners.forEach((fn) => fn());
}, []);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open]);
const handleDownload = async () => {
if (!updateInfo?.downloadUrl) return;
setDownloadProgress(0);
setDownloadError(null);
setDownloadedPath(null);
try {
const path = await invoke<string>("download_update", {
url: updateInfo.downloadUrl,
});
setDownloadedPath(path);
setDownloadProgress(100);
} catch (e) {
setDownloadError(e instanceof Error ? e.message : String(e));
setDownloadProgress(null);
}
};
const handleInstall = () => {
if (!downloadedPath) return;
invoke("open_installer", { path: downloadedPath }).catch(() => {});
};
// Open an external link via the Rust-side opener command (bypasses the JS
// plugin scope); fall back to a new tab when even that fails.
const openExternal = (url: string) => {
invoke("open_external_url", { url }).catch(() => {
const w = window.open(url, "_blank");
if (!w) console.error(`failed to open ${url}`);
});
};
if (!open) return null;
const themeOptions: { value: Theme; icon: ReactNode; label: string }[] = [
{
value: "light",
icon: (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
</svg>
),
label: t("themeLight"),
},
{
value: "dark",
icon: (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
),
label: t("themeDark"),
},
{
value: "auto",
icon: (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="12" rx="2" />
<path d="M8 21h8M12 16v5" />
</svg>
),
label: t("themeAuto"),
},
];
const fmtLastChecked = (ts: number | null) => {
if (!ts) return "—";
const d = new Date(ts);
return d.toLocaleString(lang === "zh" ? "zh-CN" : "en-US");
};
// 状态徽标:绿=已适配 / 黄=建议升级 KimiSwitch / 橙=部分兼容 / 灰=未知或未安装
const kimiCodeBadge = (status: KimiCodeStatus) => {
switch (status) {
case "current":
return {
label: t("kimiCodeStatusCurrent"),
className:
"bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 border-green-300 dark:border-green-500/30",
};
case "outdated":
return {
label: t("kimiCodeStatusOutdated"),
className:
"bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 border-yellow-300 dark:border-yellow-500/30",
};
case "partial":
return {
label: t("kimiCodeStatusPartial"),
className:
"bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 border-orange-300 dark:border-orange-500/30",
};
case "unknown":
return {
label: t("kimiCodeStatusUnknown"),
className: "bg-hover-2 text-content-muted border-border",
};
default:
return {
label: t("kimiCodeStatusNotInstalled"),
className: "bg-hover-2 text-content-muted border-border",
};
}
};
const kimiCodeUpdateAvailable =
!!kimiCodeInfo?.installed &&
!!kimiCodeInfo?.latest &&
isNewerVersion(kimiCodeInfo.latest, kimiCodeInfo.installed);
const kimiCodeBadgeInfo = kimiCodeInfo ? kimiCodeBadge(kimiCodeInfo.status) : null;
return createPortal(
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
onClick={onClose}
role="dialog"
aria-modal="true"
>
<div
className="w-full max-w-lg rounded-xl border border-border bg-panel shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-5 py-3.5">
<h2 className="text-lg font-semibold text-content-primary">
{t("settingsTitle")}
</h2>
<button
type="button"
onClick={onClose}
className="rounded-md p-1 text-content-muted hover:bg-hover hover:text-content-primary transition-colors"
aria-label={t("close")}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<div className="max-h-[70vh] overflow-y-auto p-5 space-y-5">
{/* Appearance */}
<section>
<h3 className="mb-3 text-[11px] uppercase tracking-wider text-content-muted">
{t("settingsAppearance")}
</h3>
<div className="space-y-3">
{/* Theme */}
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-content-muted">
{t("themeLight")} / {t("themeDark")}
</span>
<div className="flex h-8 items-center gap-1 bg-input rounded border border-border p-0.5">
{themeOptions.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setTheme(opt.value)}
title={opt.label}
className={`flex h-7 items-center gap-1 px-2 text-sm rounded transition-colors ${
theme === opt.value
? "bg-blue-600 text-white"
: "text-content-muted hover:text-content-primary"
}`}
>
{opt.icon}
<span className="hidden sm:inline">{opt.label}</span>
</button>
))}
</div>
</div>
{/* Language */}
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-content-muted">{t("language")}</span>
<select
value={lang}
onChange={(e) => setLang(e.target.value as Language)}
className="h-8 bg-input border border-border rounded px-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
<option value="zh">{t("zh")}</option>
<option value="en">{t("en")}</option>
</select>
</div>
</div>
</section>
{/* Updates */}
<section>
<h3 className="mb-3 text-[11px] uppercase tracking-wider text-content-muted">
{t("settingsUpdates")}
</h3>
<div className="space-y-2 rounded-lg border border-border bg-input p-3">
<div className="flex items-center justify-between text-sm">
<span className="text-content-muted">{t("currentVersion")}</span>
<span className="font-medium text-content-primary tabular-nums">
v{appVersion || "—"}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-content-muted">{t("latestVersion")}</span>
<span className="font-medium text-content-primary tabular-nums">
{updateInfo ? formatVersion(updateInfo.latest) : "—"}
</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-content-muted">{t("lastChecked")}</span>
<span className="text-content-muted tabular-nums">
{fmtLastChecked(lastChecked)}
</span>
</div>
<div className="pt-2 flex items-center gap-2">
<button
type="button"
onClick={onCheckUpdate}
disabled={checking}
className="px-3 py-1.5 text-sm rounded border border-border hover:bg-hover-2 disabled:opacity-50 transition-colors flex items-center gap-1.5"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={checking ? "animate-spin" : ""}
>
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
<polyline points="21 3 21 9 15 9" />
</svg>
{checking ? t("checking") : t("checkUpdate")}
</button>
{updateInfo?.updateAvailable && downloadProgress === null && !downloadedPath && (
<button
type="button"
onClick={handleDownload}
className="px-3 py-1.5 text-sm rounded bg-green-600 text-white hover:bg-green-500 transition-colors flex items-center gap-1.5"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
{t("downloadUpdate")}
</button>
)}
{downloadedPath && (
<button
type="button"
onClick={handleInstall}
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-500 transition-colors flex items-center gap-1.5"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polygon points="6 3 20 12 6 21 6 3" />
</svg>
{t("install")}
</button>
)}
</div>
{/* Download progress bar */}
{downloadProgress !== null && downloadProgress < 100 && (
<div className="pt-1">
<div className="flex items-center justify-between text-xs text-content-muted mb-1">
<span>{t("downloading")}</span>
<span className="tabular-nums">{downloadProgress}%</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-hover">
<div
className="h-full rounded-full bg-blue-500 transition-all duration-150"
style={{ width: `${downloadProgress}%` }}
/>
</div>
</div>
)}
{downloadedPath && (
<div className="pt-1 text-sm text-green-600 dark:text-green-500">
✓ {t("downloadComplete")}
</div>
)}
{downloadError && (
<div className="pt-1 text-sm text-red-500">
{t("updateCheckFailed")}: {downloadError}
</div>
)}
{updateInfo?.updateAvailable && (
<div className="pt-1 text-sm text-green-600 dark:text-green-500">
✦ {t("updateAvailable")}: {formatVersion(updateInfo.latest)}
</div>
)}
{updateInfo && !updateInfo.updateAvailable && (
<div className="pt-1 text-sm text-content-muted">
✓ {t("upToDate")}
</div>
)}
</div>
</section>
{/* Kimi Code CLI version & compatibility */}
<section>
<h3 className="mb-3 text-[11px] uppercase tracking-wider text-content-muted">
{t("kimiCodeSection")}
</h3>
<div className="space-y-2 rounded-lg border border-border bg-input p-3">
<div className="flex items-center justify-between text-sm">
<span className="text-content-muted">{t("kimiCodeInstalled")}</span>
<span className="font-medium text-content-primary tabular-nums">
{kimiCodeInfo?.installed ? formatVersion(kimiCodeInfo.installed) : "—"}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-content-muted">{t("kimiCodeLatest")}</span>
<span className="font-medium text-content-primary tabular-nums">
{kimiCodeInfo?.latest ? formatVersion(kimiCodeInfo.latest) : "—"}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-content-muted">{t("kimiCodeStatusLabel")}</span>
{kimiCodeBadgeInfo ? (
<span
className={`text-xs px-2 py-0.5 rounded-full border ${kimiCodeBadgeInfo.className}`}
>
{kimiCodeBadgeInfo.label}
</span>
) : (
<span className="text-xs text-content-muted">—</span>
)}
</div>
<div className="pt-2 flex items-center gap-2">
<button
type="button"
onClick={checkKimiCode}
disabled={kimiCodeChecking}
className="px-3 py-1.5 text-sm rounded border border-border hover:bg-hover-2 disabled:opacity-50 transition-colors flex items-center gap-1.5"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={kimiCodeChecking ? "animate-spin" : ""}
>
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
<polyline points="21 3 21 9 15 9" />
</svg>
{kimiCodeChecking ? t("checking") : t("kimiCodeCheck")}
</button>
{kimiCodeUpdateAvailable && (
<button
type="button"
onClick={() => openExternal(KIMI_CODE_RELEASES_URL)}
className="px-3 py-1.5 text-sm rounded border border-border hover:bg-hover-2 transition-colors"
>
{t("kimiCodeOpenReleases")}
</button>
)}
</div>
</div>
</section>
{/* About */}
<section>
<h3 className="mb-3 text-[11px] uppercase tracking-wider text-content-muted">
{t("settingsAbout")}
</h3>
<div className="space-y-1.5 text-sm text-content-muted">
<div className="font-medium text-content-primary">Kimi Switch</div>
<div>{t("appDescription")}</div>
<div className="text-xs text-content-muted">v{appVersion} · MIT License</div>
<div className="pt-2 space-y-1">
<div>
{t("settingsWebsite")}:{" "}
<button
type="button"
onClick={() =>
openExternal("https://billowliu2.github.io/KimiSwitch")
}
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer"
>
billowliu2.github.io/KimiSwitch
</button>
</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>
{t("attributionPorted")}{" "}
<button
type="button"
onClick={() =>
openExternal("https://github.com/JochenYang/kimicode-dashboard")
}
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer font-semibold"
>
kimicode-dashboard
</button>
<span className="text-content-muted"> {t("attributionSuffix")}</span>
</div>
<div className="pt-1">
{t("modelDataSourcePrefix")}{" "}
<button
type="button"
onClick={() => openExternal("https://models.dev/")}
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer"
>
models.dev
</button>
</div>
<div className="pt-1">
<span className="text-content-muted">{t("referenceProject")}: </span>
<button
type="button"
onClick={() =>
openExternal("https://github.com/farion1231/cc-switch")
}
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer"
>
cc-switch
</button>
<span className="text-content-muted"> (MIT, © Jason Young)</span>
</div>
</div>
</div>
</section>
</div>
</div>
</div>,
document.body
);
}
+20 -670
View File
@@ -1,403 +1,32 @@
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,
getSubagentModelPool,
isExperimentalFlagSet,
isFlagLockedByEnv,
isMasterEnvOn,
removeSubagentPoolEntry,
setExperimentalFlag,
setSecondaryModelOnly,
setSubagentDefault,
setSubagentForce,
upsertSubagentPoolEntry,
validateSubagentPool,
type ExperimentalFlagDef,
type PoolValidationError,
type ValidationErrorKey,
} from "../lib/subagent-settings";
import { Card, Toggle } from "./ui/controls";
import { applyModelsDevSnapshot, reloadModelsDev } from "../lib/models-dev";
/** Mirror of models_dev::ModelsDevStatus (camelCase over IPC). */
interface ModelsDevStatus {
source: "synced" | "builtin";
lastUpdated: string;
modelCount: number;
providerCount: number;
}
import type { Model } from "../types";
import { SettingsAdvancedTab } from "./settings/SettingsAdvancedTab";
import { SettingsGlobalTab } from "./settings/SettingsGlobalTab";
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>;
/** Alias of the model the effort tier actually applies to. */
defaultModel?: string | null;
onChange: (nextRawOther: unknown) => void;
onBack: () => void;
}
// Effort tiers the CLI accepts; the value is forwarded verbatim upstream, and
// per-model support comes from `[models."<alias>"] support_efforts`.
const EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
const EFFORT_LABELS: Record<(typeof EFFORTS)[number], TranslationKey> = {
low: "thinkingLow",
medium: "thinkingMedium",
high: "thinkingHigh",
max: "thinkingMax",
xhigh: "thinkingXHigh",
};
const FLAG_LABELS: Record<string, { name: TranslationKey; desc: TranslationKey }> = {
wait_for: { name: "flagWaitFor", desc: "flagWaitForDesc" },
"tool-select": { name: "flagToolSelect", desc: "flagToolSelectDesc" },
notify_user: { name: "flagNotifyUser", desc: "flagNotifyUserDesc" },
tower: { name: "flagTower", desc: "flagTowerDesc" },
subagent_fork: { name: "flagSubagentFork", desc: "flagSubagentForkDesc" },
};
/** Validation-error i18n key per engine rule, for the pre-write self-check. */
const POOL_ERROR_KEYS: Record<ValidationErrorKey, TranslationKey> = {
primaryReserved: "secondaryPoolErrorPrimaryReserved",
defaultRequired: "secondaryPoolErrorDefaultRequired",
defaultNotInPool: "secondaryPoolErrorDefaultNotInPool",
aliasNotInModels: "secondaryPoolErrorAliasNotInModels",
forceRequiresDefault: "secondaryPoolErrorForceRequiresDefault",
forceExcludesModels: "secondaryPoolErrorForceExcludesModels",
};
/**
* One row of the subagent model pool: default radio + alias + editable
* description (saved on blur/Enter via the parent's write chain) + remove.
* The description input keeps a local draft so typing is not persisted per
* keystroke; everything else is derived from `rawOther` on each render.
* Standalone full-page shell around the global + advanced settings tabs; kept
* for the legacy route into the subagent settings. The settings page renders
* both tabs separately instead.
*/
function PoolEntryRow({
alias,
displayName,
description,
isDefault,
canRemove,
onSetDefault,
onSave,
onRemove,
}: {
alias: string;
displayName: string | null;
description: string;
isDefault: boolean;
canRemove: boolean;
onSetDefault: (alias: string) => void;
onSave: (alias: string, description: string) => void;
onRemove: (alias: string) => void;
}) {
const { t } = useTranslation();
const [draft, setDraft] = useState(description);
const commit = () => {
if (draft === description) return;
onSave(alias, draft);
};
return (
<div className="flex items-center gap-2 text-sm">
<input
type="radio"
checked={isDefault}
aria-label={t("setDefault")}
onChange={() => onSetDefault(alias)}
className="shrink-0 accent-blue-600 focus:ring-blue-500"
/>
<span className="shrink-0 font-medium text-content-primary">{alias}</span>
{displayName && (
<span className="shrink-0 text-xs text-content-muted">{displayName}</span>
)}
{isDefault && (
<span className="shrink-0 text-xs text-blue-600 dark:text-blue-400 border border-blue-500/30 rounded px-1">
{t("default")}
</span>
)}
<input
type="text"
value={draft}
placeholder={t("secondaryPoolDescPlaceholder")}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
(e.currentTarget as HTMLInputElement).blur();
} else if (e.key === "Escape") {
setDraft(description);
(e.currentTarget as HTMLInputElement).blur();
}
}}
className="flex-1 min-w-0 bg-input border border-border rounded px-2 py-1 text-xs focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
{canRemove && (
<button
type="button"
onClick={() => onRemove(alias)}
className="shrink-0 px-2 py-1 text-xs border border-border rounded hover:bg-hover-2 text-content-muted focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
{t("secondaryPoolRemove")}
</button>
)}
</div>
);
}
export function SubagentSettingsPage({
rawOther,
models,
defaultModel,
onChange,
onBack,
}: SubagentSettingsPageProps) {
const { t } = useTranslation();
const [env, setEnv] = useState<ExperimentalEnvStatus | null>(null);
/** Pre-write validation failures (fallback guard; normal flows stay clear). */
const [poolErrors, setPoolErrors] = useState<PoolValidationError[]>([]);
/** Local draft for the "add pool entry" select (reset after each pick). */
const [addSelection, setAddSelection] = useState("");
/** WebUI-open button in flight; disables both buttons while non-null. */
const [webuiBusy, setWebuiBusy] = useState<"embedded" | "browser" | null>(null);
/** models.dev snapshot provenance (synced copy vs bundled asset). */
const [modelsDevStatus, setModelsDevStatus] = useState<ModelsDevStatus | null>(null);
/** Sync/restore button in flight; blocks the card's buttons. */
const [modelsDevBusy, setModelsDevBusy] = useState(false);
/** Last sync error (inline) and success flag (auto-cleared on next action). */
const [syncError, setSyncError] = useState<string | null>(null);
const [syncOk, setSyncOk] = useState(false);
useEffect(() => {
invoke<ExperimentalEnvStatus>("get_experimental_env_status")
.then(setEnv)
.catch(() => setEnv({}));
invoke<ModelsDevStatus>("get_models_dev_status")
.then(setModelsDevStatus)
.catch(() => setModelsDevStatus(null));
}, []);
const refreshModelsDevStatus = () => {
invoke<ModelsDevStatus>("get_models_dev_status")
.then(setModelsDevStatus)
.catch(() => {});
};
const handleModelsDevSync = async () => {
setModelsDevBusy(true);
setSyncError(null);
setSyncOk(false);
try {
const st = await invoke<ModelsDevStatus>("sync_models_dev");
setModelsDevStatus(st);
// Hot-swap the frontend index; the dashboard price index rebuilds on
// its next query (mtime-triggered on the Rust side).
const raw = await invoke<string | null>("get_models_dev_snapshot");
if (raw) applyModelsDevSnapshot(JSON.parse(raw) as Record<string, unknown>);
setSyncOk(true);
} catch (err) {
setSyncError(err instanceof Error ? err.message : String(err));
} finally {
setModelsDevBusy(false);
}
};
const handleModelsDevRestore = async () => {
if (!confirm(t("modelsDataRestoreConfirm"))) return;
setModelsDevBusy(true);
setSyncError(null);
setSyncOk(false);
try {
const st = await invoke<ModelsDevStatus>("reset_models_dev");
setModelsDevStatus(st);
await reloadModelsDev();
} catch (err) {
setSyncError(err instanceof Error ? err.message : String(err));
refreshModelsDevStatus();
} finally {
setModelsDevBusy(false);
}
};
const flags = getExperimentalFlags(rawOther);
const pool = getSubagentModelPool(rawOther);
const poolKeys = pool ? Object.keys(pool.models) : [];
// View selection: no/empty section or a bare legacy `model` → simple view;
// a `models` table or `default_model` → pool management view.
const isPoolView =
!!pool && (poolKeys.length > 0 || pool.defaultModel !== undefined);
// Effective default: v2 pool key first, legacy v1 recipe key as fallback.
const effectiveDefault = pool ? pool.defaultModel ?? pool.model : undefined;
// "Upgrade" only makes sense for the bare legacy form and when `force` is
// off — upgrading with force would create an invalid force+pool-table combo.
const canUpgrade = !!pool && !isPoolView && pool.model !== undefined && !pool.force;
const masterOn = isMasterEnvOn(env);
const aliasList = Object.keys(models).sort();
const aliasLabel = (alias: string): string =>
models[alias]?.display_name
? `${alias}(${models[alias].display_name})`
: alias;
// Rows of the pool view: every `models` table key plus the effective default
// when it is not already in the table (the implicit single-entry form).
const implicitDefault =
pool && effectiveDefault && !(effectiveDefault in pool.models)
? [effectiveDefault]
: [];
const poolEntries = [...poolKeys, ...implicitDefault];
// Aliases that can still be added to the pool (not already in the table).
const addableAliases = aliasList.filter((a) => !(a in (pool?.models ?? {})));
// 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 = effectiveDefault ? models[effectiveDefault] : 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 => {
// Unknown values (hand-written config) are shown verbatim.
const key = EFFORT_LABELS[e as (typeof EFFORTS)[number]];
return key ? t(key) : e;
};
/**
* Fallback guard before any pool-related write: validate the *target* state
* against the engine rules (mirrored by `validateSubagentPool`) and block the
* write when it would produce an invalid config. Normal UI flows never trip
* this — it protects against stale/divergent configs.
*/
const validateWrite = (nextRaw: unknown): boolean => {
const nextPool = getSubagentModelPool(nextRaw);
const errors = nextPool ? validateSubagentPool(nextPool, aliasList) : [];
setPoolErrors(errors);
return errors.length === 0;
};
const handleUpgrade = () => {
if (!effectiveDefault) return;
const nextRaw = setSubagentDefault(rawOther, effectiveDefault);
if (validateWrite(nextRaw)) onChange(nextRaw);
};
const handleSetDefault = (alias: string) => {
if (alias === effectiveDefault) return;
const nextRaw = setSubagentDefault(rawOther, alias);
if (validateWrite(nextRaw)) onChange(nextRaw);
};
const handleSaveDescription = (alias: string, description: string) => {
const nextRaw = upsertSubagentPoolEntry(rawOther, alias, description);
if (validateWrite(nextRaw)) onChange(nextRaw);
};
const handleRemoveEntry = (alias: string) => {
const nextRaw = removeSubagentPoolEntry(rawOther, alias);
if (validateWrite(nextRaw)) onChange(nextRaw);
};
const handleAddEntry = (alias: string) => {
const nextRaw = upsertSubagentPoolEntry(rawOther, alias, "");
if (validateWrite(nextRaw)) onChange(nextRaw);
};
const handleForceChange = (next: boolean) => {
if (!pool) return;
// Turning force on drops the `models` table — confirm when it is non-empty.
if (next && Object.keys(pool.models).length > 0) {
if (!confirm(t("secondaryPoolForceConfirm"))) return;
}
const nextRaw = setSubagentForce(rawOther, next);
if (validateWrite(nextRaw)) onChange(nextRaw);
};
const handleClear = () => {
setPoolErrors([]);
onChange(clearSecondaryModel(rawOther));
};
const renderFlagRow = (flag: ExperimentalFlagDef) => {
const labels = FLAG_LABELS[flag.id];
// Only the flag's own env var locks the toggle. The master env force-ons
// undefined flags but an explicit config entry outranks it, so the user
// can still flip values here while it is set.
const locked = isFlagLockedByEnv(env, flag);
// Resolution order mirrors kimi-code 0.40.1 flagService:
// single-flag env > explicit [experimental] value (true *or* false) >
// master env (on-only) > upstream default (`defaultEnabled`).
const explicitlySet = isExperimentalFlagSet(rawOther, flag.id);
const on = locked
? forcedEnvValue(env, flag)
: explicitlySet
? flags[flag.id] === true
: masterOn
? true
: (flag.defaultEnabled ?? false);
// Turning the flag off only sticks via an explicit `false` when the
// fallback (master env or the upstream default) would otherwise keep it on.
const explicitFalse = masterOn || flag.defaultEnabled === true;
return (
<div key={flag.id} 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(labels.name)}
{!locked && !explicitlySet && flag.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(labels.desc)}
<code className="ml-2">{flag.envVar}</code>
</div>
</div>
{locked && (
<span className="text-xs text-content-muted shrink-0">
🔒 {t("lockedByEnv")}
</span>
)}
<Toggle
checked={on}
disabled={locked}
ariaLabel={t(labels.name)}
onChange={(checked) =>
onChange(
setExperimentalFlag(rawOther, flag.id, checked, { explicitFalse })
)
}
/>
</div>
);
};
return (
<div className="h-full flex flex-col bg-app">
@@ -414,296 +43,17 @@ export function SubagentSettingsPage({
</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>
)}
{/* Kimi Code WebUI quick open */}
<Card title={t("webuiSection")}>
<p className="text-xs text-content-muted">{t("webuiDesc")}</p>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
disabled={webuiBusy !== null}
onClick={async () => {
setWebuiBusy("embedded");
try {
await invoke("open_kimi_web_embedded");
} catch (err) {
alert(err instanceof Error ? err.message : String(err));
} finally {
setWebuiBusy(null);
}
}}
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
>
{webuiBusy === "embedded" ? t("webuiOpening") : t("openWebUIEmbedded")}
</button>
<button
type="button"
disabled={webuiBusy !== null}
onClick={async () => {
setWebuiBusy("browser");
try {
await invoke("open_kimi_web");
} catch (err) {
alert(err instanceof Error ? err.message : String(err));
} finally {
setWebuiBusy(null);
}
}}
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
>
{webuiBusy === "browser" ? t("webuiOpening") : t("openWebUIBrowser")}
</button>
</div>
</Card>
{/* models.dev reference data: online sync / restore bundled */}
<Card title={t("modelsDataSection")}>
<p className="text-xs text-content-muted">{t("modelsDataDesc")}</p>
{modelsDevStatus && (
<div className="flex flex-wrap items-center gap-2 text-sm">
<span
className={`shrink-0 rounded border px-1.5 py-0.5 text-xs ${
modelsDevStatus.source === "synced"
? "border-blue-500/30 text-blue-600 dark:text-blue-400"
: "border-border text-content-muted"
}`}
>
{modelsDevStatus.source === "synced"
? t("modelsDataSourceSynced")
: t("modelsDataSourceBuiltin")}
</span>
<span className="text-xs text-content-muted">
{t("modelsDataStats", {
date: modelsDevStatus.lastUpdated,
models: modelsDevStatus.modelCount.toLocaleString(),
providers: modelsDevStatus.providerCount.toLocaleString(),
})}
</span>
</div>
)}
{syncOk && (
<div className="bg-green-100 dark:bg-green-900/20 border border-green-300 dark:border-green-500/30 rounded-lg px-3 py-2 text-xs text-green-700 dark:text-green-400">
{t("modelsDataSyncOk")}
</div>
)}
{syncError && (
<div
role="alert"
className="bg-red-100 dark:bg-red-900/20 border border-red-300 dark:border-red-500/30 rounded-lg px-3 py-2 text-xs text-red-700 dark:text-red-400"
>
{t("modelsDataSyncFailed")}: {syncError}
</div>
)}
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={modelsDevBusy}
onClick={handleModelsDevSync}
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
>
{modelsDevBusy ? t("modelsDataSyncing") : t("modelsDataSyncNow")}
</button>
{modelsDevStatus?.source === "synced" && (
<button
type="button"
disabled={modelsDevBusy}
onClick={handleModelsDevRestore}
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
>
{t("modelsDataRestore")}
</button>
)}
</div>
</Card>
{/* Subagent: secondary model picker / pool + inherited settings */}
<Card title={t("secondaryModelSection")}>
{isPoolView && pool ? (
<>
{pool.force ? (
/* Force on: the `models` table was dropped — only the pinned
default is shown, entries and "add" are hidden. */
<div className="rounded-lg border border-border bg-input/40 px-3 py-2.5">
<div className="text-sm font-medium text-content-primary">
🔒{" "}
{t("secondaryPoolForceOn", {
alias: aliasLabel(effectiveDefault ?? ""),
})}
</div>
</div>
) : (
<>
<div className="space-y-2">
{poolEntries.map((alias) => (
<PoolEntryRow
key={alias}
alias={alias}
displayName={models[alias]?.display_name ?? null}
description={pool.models[alias] ?? ""}
isDefault={alias === effectiveDefault}
canRemove={alias in pool.models}
onSetDefault={handleSetDefault}
onSave={handleSaveDescription}
onRemove={handleRemoveEntry}
/>
))}
</div>
<p className="text-xs text-content-muted">
{t("secondaryPoolDescHint")}
</p>
<label className="flex items-center gap-3 text-sm text-content-primary">
<span className="shrink-0 text-content-muted">
{t("secondaryPoolAddLabel")}
</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={addSelection}
onChange={(e) => {
const alias = e.target.value;
setAddSelection("");
if (alias) handleAddEntry(alias);
}}
>
<option value="">{t("secondaryPoolAddPlaceholder")}</option>
{addableAliases.map((alias) => (
<option key={alias} value={alias}>
{aliasLabel(alias)}
</option>
))}
</select>
</label>
</>
)}
<div className="flex items-center gap-3">
<div className="flex-1 min-w-0">
<div className="text-sm text-content-primary">
{t("secondaryPoolForce")}
</div>
<div className="text-xs text-content-muted">
{t("secondaryPoolForceDesc")}
</div>
</div>
<Toggle
checked={pool.force}
ariaLabel={t("secondaryPoolForce")}
onChange={handleForceChange}
/>
</div>
<button
type="button"
onClick={handleClear}
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("secondaryModelClear")}
</button>
</>
) : (
<>
<label className="flex items-center gap-3 text-sm text-content-primary">
<span className="shrink-0 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={effectiveDefault ?? ""}
onChange={(e) => {
const alias = e.target.value;
if (alias === "") {
setPoolErrors([]);
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 the synthesized `__secondary__` entry.
const nextRaw = setSecondaryModelOnly(rawOther, alias);
if (validateWrite(nextRaw)) onChange(nextRaw);
}
}}
>
<option value="">{t("secondaryModelUnset")}</option>
{aliasList.map((alias) => (
<option key={alias} value={alias}>
{aliasLabel(alias)}
</option>
))}
</select>
{pool?.force && (
<span className="shrink-0 text-xs text-content-muted">
🔒 {t("secondaryModelForceBadge")}
</span>
)}
</label>
{canUpgrade && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleUpgrade}
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("secondaryPoolUpgrade")}
</button>
<span className="text-xs text-content-muted">
{t("secondaryPoolUpgradeDesc")}
</span>
</div>
)}
</>
)}
{poolErrors.length > 0 && (
<div
role="alert"
className="bg-red-100 dark:bg-red-900/20 border border-red-300 dark:border-red-500/30 rounded-lg px-3 py-2 text-sm text-red-700 dark:text-red-400 space-y-1"
>
{poolErrors.map((err, i) => (
<div key={i}>{t(POOL_ERROR_KEYS[err.key], err.params)}</div>
))}
</div>
)}
<div className="border-t border-border" />
{effectiveDefault !== undefined && (
<div className="rounded-lg border border-border bg-input/40 px-3 py-2.5 space-y-2">
<div className="flex items-baseline gap-2 text-sm">
<span className="shrink-0 text-content-muted">
{t("secondaryModelInheritedContext")}
</span>
<span className="font-medium text-content-primary">
{inheritedContext ? fmtContext(inheritedContext) : "—"}
</span>
</div>
<div className="flex items-baseline gap-2 text-sm">
<span className="shrink-0 text-content-muted">
{t("secondaryModelInheritedEffort")}
</span>
<span className="font-medium 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("secondaryModelPriorityHint")}</p>
</Card>
{/* Experimental feature flags — one unified list */}
<Card title={t("experimentalFlags")}>
{EXPERIMENTAL_FLAGS.map(renderFlagRow)}
<p className="text-xs text-content-muted">{t("experimentalFlagsHint")}</p>
</Card>
<SettingsGlobalTab
rawOther={rawOther}
models={models}
defaultModel={defaultModel ?? null}
onChange={onChange}
/>
<SettingsAdvancedTab
rawOther={rawOther}
models={models}
onChange={onChange}
/>
</div>
</div>
);
@@ -0,0 +1,694 @@
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,
getSubagentModelPool,
isExperimentalFlagSet,
isFlagLockedByEnv,
isMasterEnvOn,
removeSubagentPoolEntry,
setExperimentalFlag,
setSecondaryModelOnly,
setSubagentDefault,
setSubagentForce,
upsertSubagentPoolEntry,
validateSubagentPool,
type ExperimentalFlagDef,
type PoolValidationError,
type ValidationErrorKey,
} from "../../lib/subagent-settings";
import { Card, Toggle } from "../ui/controls";
import { applyModelsDevSnapshot, reloadModelsDev } from "../../lib/models-dev";
/** Mirror of models_dev::ModelsDevStatus (camelCase over IPC). */
interface ModelsDevStatus {
source: "synced" | "builtin";
lastUpdated: string;
modelCount: number;
providerCount: number;
}
interface SettingsAdvancedTabProps {
/** 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;
}
// Effort tiers the CLI accepts; the value is forwarded verbatim upstream, and
// per-model support comes from `[models."<alias>"] support_efforts`.
const EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
const EFFORT_LABELS: Record<(typeof EFFORTS)[number], TranslationKey> = {
low: "thinkingLow",
medium: "thinkingMedium",
high: "thinkingHigh",
max: "thinkingMax",
xhigh: "thinkingXHigh",
};
const FLAG_LABELS: Record<string, { name: TranslationKey; desc: TranslationKey }> = {
wait_for: { name: "flagWaitFor", desc: "flagWaitForDesc" },
"tool-select": { name: "flagToolSelect", desc: "flagToolSelectDesc" },
notify_user: { name: "flagNotifyUser", desc: "flagNotifyUserDesc" },
tower: { name: "flagTower", desc: "flagTowerDesc" },
subagent_fork: { name: "flagSubagentFork", desc: "flagSubagentForkDesc" },
};
/** Validation-error i18n key per engine rule, for the pre-write self-check. */
const POOL_ERROR_KEYS: Record<ValidationErrorKey, TranslationKey> = {
primaryReserved: "secondaryPoolErrorPrimaryReserved",
defaultRequired: "secondaryPoolErrorDefaultRequired",
defaultNotInPool: "secondaryPoolErrorDefaultNotInPool",
aliasNotInModels: "secondaryPoolErrorAliasNotInModels",
forceRequiresDefault: "secondaryPoolErrorForceRequiresDefault",
forceExcludesModels: "secondaryPoolErrorForceExcludesModels",
};
/**
* One row of the subagent model pool: default radio + alias + editable
* description (saved on blur/Enter via the parent's write chain) + remove.
* The description input keeps a local draft so typing is not persisted per
* keystroke; everything else is derived from `rawOther` on each render.
*/
function PoolEntryRow({
alias,
displayName,
description,
isDefault,
canRemove,
onSetDefault,
onSave,
onRemove,
}: {
alias: string;
displayName: string | null;
description: string;
isDefault: boolean;
canRemove: boolean;
onSetDefault: (alias: string) => void;
onSave: (alias: string, description: string) => void;
onRemove: (alias: string) => void;
}) {
const { t } = useTranslation();
const [draft, setDraft] = useState(description);
const commit = () => {
if (draft === description) return;
onSave(alias, draft);
};
return (
<div className="flex items-center gap-2 text-sm">
<input
type="radio"
checked={isDefault}
aria-label={t("setDefault")}
onChange={() => onSetDefault(alias)}
className="shrink-0 accent-blue-600 focus:ring-blue-500"
/>
<span className="shrink-0 font-medium text-content-primary">{alias}</span>
{displayName && (
<span className="shrink-0 text-xs text-content-muted">{displayName}</span>
)}
{isDefault && (
<span className="shrink-0 text-xs text-blue-600 dark:text-blue-400 border border-blue-500/30 rounded px-1">
{t("default")}
</span>
)}
<input
type="text"
value={draft}
placeholder={t("secondaryPoolDescPlaceholder")}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
(e.currentTarget as HTMLInputElement).blur();
} else if (e.key === "Escape") {
setDraft(description);
(e.currentTarget as HTMLInputElement).blur();
}
}}
className="flex-1 min-w-0 bg-input border border-border rounded px-2 py-1 text-xs focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
{canRemove && (
<button
type="button"
onClick={() => onRemove(alias)}
className="shrink-0 px-2 py-1 text-xs border border-border rounded hover:bg-hover-2 text-content-muted focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
{t("secondaryPoolRemove")}
</button>
)}
</div>
);
}
export function SettingsAdvancedTab({
rawOther,
models,
onChange,
}: SettingsAdvancedTabProps) {
const { t } = useTranslation();
const [env, setEnv] = useState<ExperimentalEnvStatus | null>(null);
/** Pre-write validation failures (fallback guard; normal flows stay clear). */
const [poolErrors, setPoolErrors] = useState<PoolValidationError[]>([]);
/** Local draft for the "add pool entry" select (reset after each pick). */
const [addSelection, setAddSelection] = useState("");
/** WebUI-open button in flight; disables both buttons while non-null. */
const [webuiBusy, setWebuiBusy] = useState<"embedded" | "browser" | null>(null);
/** models.dev snapshot provenance (synced copy vs bundled asset). */
const [modelsDevStatus, setModelsDevStatus] = useState<ModelsDevStatus | null>(null);
/** Sync/restore button in flight; blocks the card's buttons. */
const [modelsDevBusy, setModelsDevBusy] = useState(false);
/** Last sync error (inline) and success flag (auto-cleared on next action). */
const [syncError, setSyncError] = useState<string | null>(null);
const [syncOk, setSyncOk] = useState(false);
useEffect(() => {
invoke<ExperimentalEnvStatus>("get_experimental_env_status")
.then(setEnv)
.catch(() => setEnv({}));
invoke<ModelsDevStatus>("get_models_dev_status")
.then(setModelsDevStatus)
.catch(() => setModelsDevStatus(null));
}, []);
const refreshModelsDevStatus = () => {
invoke<ModelsDevStatus>("get_models_dev_status")
.then(setModelsDevStatus)
.catch(() => {});
};
const handleModelsDevSync = async () => {
setModelsDevBusy(true);
setSyncError(null);
setSyncOk(false);
try {
const st = await invoke<ModelsDevStatus>("sync_models_dev");
setModelsDevStatus(st);
// Hot-swap the frontend index; the dashboard price index rebuilds on
// its next query (mtime-triggered on the Rust side).
const raw = await invoke<string | null>("get_models_dev_snapshot");
if (raw) applyModelsDevSnapshot(JSON.parse(raw) as Record<string, unknown>);
setSyncOk(true);
} catch (err) {
setSyncError(err instanceof Error ? err.message : String(err));
} finally {
setModelsDevBusy(false);
}
};
const handleModelsDevRestore = async () => {
if (!confirm(t("modelsDataRestoreConfirm"))) return;
setModelsDevBusy(true);
setSyncError(null);
setSyncOk(false);
try {
const st = await invoke<ModelsDevStatus>("reset_models_dev");
setModelsDevStatus(st);
await reloadModelsDev();
} catch (err) {
setSyncError(err instanceof Error ? err.message : String(err));
refreshModelsDevStatus();
} finally {
setModelsDevBusy(false);
}
};
const flags = getExperimentalFlags(rawOther);
const pool = getSubagentModelPool(rawOther);
const poolKeys = pool ? Object.keys(pool.models) : [];
// View selection: no/empty section or a bare legacy `model` → simple view;
// a `models` table or `default_model` → pool management view.
const isPoolView =
!!pool && (poolKeys.length > 0 || pool.defaultModel !== undefined);
// Effective default: v2 pool key first, legacy v1 recipe key as fallback.
const effectiveDefault = pool ? pool.defaultModel ?? pool.model : undefined;
// "Upgrade" only makes sense for the bare legacy form and when `force` is
// off — upgrading with force would create an invalid force+pool-table combo.
const canUpgrade = !!pool && !isPoolView && pool.model !== undefined && !pool.force;
const masterOn = isMasterEnvOn(env);
const aliasList = Object.keys(models).sort();
const aliasLabel = (alias: string): string =>
models[alias]?.display_name
? `${alias}(${models[alias].display_name})`
: alias;
// Rows of the pool view: every `models` table key plus the effective default
// when it is not already in the table (the implicit single-entry form).
const implicitDefault =
pool && effectiveDefault && !(effectiveDefault in pool.models)
? [effectiveDefault]
: [];
const poolEntries = [...poolKeys, ...implicitDefault];
// Aliases that can still be added to the pool (not already in the table).
const addableAliases = aliasList.filter((a) => !(a in (pool?.models ?? {})));
// 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 = effectiveDefault ? models[effectiveDefault] : 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 => {
// Unknown values (hand-written config) are shown verbatim.
const key = EFFORT_LABELS[e as (typeof EFFORTS)[number]];
return key ? t(key) : e;
};
/**
* Fallback guard before any pool-related write: validate the *target* state
* against the engine rules (mirrored by `validateSubagentPool`) and block the
* write when it would produce an invalid config. Normal UI flows never trip
* this — it protects against stale/divergent configs.
*/
const validateWrite = (nextRaw: unknown): boolean => {
const nextPool = getSubagentModelPool(nextRaw);
const errors = nextPool ? validateSubagentPool(nextPool, aliasList) : [];
setPoolErrors(errors);
return errors.length === 0;
};
const handleUpgrade = () => {
if (!effectiveDefault) return;
const nextRaw = setSubagentDefault(rawOther, effectiveDefault);
if (validateWrite(nextRaw)) onChange(nextRaw);
};
const handleSetDefault = (alias: string) => {
if (alias === effectiveDefault) return;
const nextRaw = setSubagentDefault(rawOther, alias);
if (validateWrite(nextRaw)) onChange(nextRaw);
};
const handleSaveDescription = (alias: string, description: string) => {
const nextRaw = upsertSubagentPoolEntry(rawOther, alias, description);
if (validateWrite(nextRaw)) onChange(nextRaw);
};
const handleRemoveEntry = (alias: string) => {
const nextRaw = removeSubagentPoolEntry(rawOther, alias);
if (validateWrite(nextRaw)) onChange(nextRaw);
};
const handleAddEntry = (alias: string) => {
const nextRaw = upsertSubagentPoolEntry(rawOther, alias, "");
if (validateWrite(nextRaw)) onChange(nextRaw);
};
const handleForceChange = (next: boolean) => {
if (!pool) return;
// Turning force on drops the `models` table — confirm when it is non-empty.
if (next && Object.keys(pool.models).length > 0) {
if (!confirm(t("secondaryPoolForceConfirm"))) return;
}
const nextRaw = setSubagentForce(rawOther, next);
if (validateWrite(nextRaw)) onChange(nextRaw);
};
const handleClear = () => {
setPoolErrors([]);
onChange(clearSecondaryModel(rawOther));
};
const renderFlagRow = (flag: ExperimentalFlagDef) => {
const labels = FLAG_LABELS[flag.id];
// Only the flag's own env var locks the toggle. The master env force-ons
// undefined flags but an explicit config entry outranks it, so the user
// can still flip values here while it is set.
const locked = isFlagLockedByEnv(env, flag);
// Resolution order mirrors kimi-code 0.40.1 flagService:
// single-flag env > explicit [experimental] value (true *or* false) >
// master env (on-only) > upstream default (`defaultEnabled`).
const explicitlySet = isExperimentalFlagSet(rawOther, flag.id);
const on = locked
? forcedEnvValue(env, flag)
: explicitlySet
? flags[flag.id] === true
: masterOn
? true
: (flag.defaultEnabled ?? false);
// Turning the flag off only sticks via an explicit `false` when the
// fallback (master env or the upstream default) would otherwise keep it on.
const explicitFalse = masterOn || flag.defaultEnabled === true;
return (
<div key={flag.id} 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(labels.name)}
{!locked && !explicitlySet && flag.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(labels.desc)}
<code className="ml-2">{flag.envVar}</code>
</div>
</div>
{locked && (
<span className="text-xs text-content-muted shrink-0">
🔒 {t("lockedByEnv")}
</span>
)}
<Toggle
checked={on}
disabled={locked}
ariaLabel={t(labels.name)}
onChange={(checked) =>
onChange(
setExperimentalFlag(rawOther, flag.id, checked, { explicitFalse })
)
}
/>
</div>
);
};
return (
<div className="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>
)}
{/* Kimi Code WebUI quick open */}
<Card title={t("webuiSection")}>
<p className="text-xs text-content-muted">{t("webuiDesc")}</p>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
disabled={webuiBusy !== null}
onClick={async () => {
setWebuiBusy("embedded");
try {
await invoke("open_kimi_web_embedded");
} catch (err) {
alert(err instanceof Error ? err.message : String(err));
} finally {
setWebuiBusy(null);
}
}}
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
>
{webuiBusy === "embedded" ? t("webuiOpening") : t("openWebUIEmbedded")}
</button>
<button
type="button"
disabled={webuiBusy !== null}
onClick={async () => {
setWebuiBusy("browser");
try {
await invoke("open_kimi_web");
} catch (err) {
alert(err instanceof Error ? err.message : String(err));
} finally {
setWebuiBusy(null);
}
}}
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
>
{webuiBusy === "browser" ? t("webuiOpening") : t("openWebUIBrowser")}
</button>
</div>
</Card>
{/* models.dev reference data: online sync / restore bundled */}
<Card title={t("modelsDataSection")}>
<p className="text-xs text-content-muted">{t("modelsDataDesc")}</p>
{modelsDevStatus && (
<div className="flex flex-wrap items-center gap-2 text-sm">
<span
className={`shrink-0 rounded border px-1.5 py-0.5 text-xs ${
modelsDevStatus.source === "synced"
? "border-blue-500/30 text-blue-600 dark:text-blue-400"
: "border-border text-content-muted"
}`}
>
{modelsDevStatus.source === "synced"
? t("modelsDataSourceSynced")
: t("modelsDataSourceBuiltin")}
</span>
<span className="text-xs text-content-muted">
{t("modelsDataStats", {
date: modelsDevStatus.lastUpdated,
models: modelsDevStatus.modelCount.toLocaleString(),
providers: modelsDevStatus.providerCount.toLocaleString(),
})}
</span>
</div>
)}
{syncOk && (
<div className="bg-green-100 dark:bg-green-900/20 border border-green-300 dark:border-green-500/30 rounded-lg px-3 py-2 text-xs text-green-700 dark:text-green-400">
{t("modelsDataSyncOk")}
</div>
)}
{syncError && (
<div
role="alert"
className="bg-red-100 dark:bg-red-900/20 border border-red-300 dark:border-red-500/30 rounded-lg px-3 py-2 text-xs text-red-700 dark:text-red-400"
>
{t("modelsDataSyncFailed")}: {syncError}
</div>
)}
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={modelsDevBusy}
onClick={handleModelsDevSync}
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
>
{modelsDevBusy ? t("modelsDataSyncing") : t("modelsDataSyncNow")}
</button>
{modelsDevStatus?.source === "synced" && (
<button
type="button"
disabled={modelsDevBusy}
onClick={handleModelsDevRestore}
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
>
{t("modelsDataRestore")}
</button>
)}
</div>
</Card>
{/* Subagent: secondary model picker / pool + inherited settings */}
<Card title={t("secondaryModelSection")}>
{isPoolView && pool ? (
<>
{pool.force ? (
/* Force on: the `models` table was dropped — only the pinned
default is shown, entries and "add" are hidden. */
<div className="rounded-lg border border-border bg-input/40 px-3 py-2.5">
<div className="text-sm font-medium text-content-primary">
🔒{" "}
{t("secondaryPoolForceOn", {
alias: aliasLabel(effectiveDefault ?? ""),
})}
</div>
</div>
) : (
<>
<div className="space-y-2">
{poolEntries.map((alias) => (
<PoolEntryRow
key={alias}
alias={alias}
displayName={models[alias]?.display_name ?? null}
description={pool.models[alias] ?? ""}
isDefault={alias === effectiveDefault}
canRemove={alias in pool.models}
onSetDefault={handleSetDefault}
onSave={handleSaveDescription}
onRemove={handleRemoveEntry}
/>
))}
</div>
<p className="text-xs text-content-muted">
{t("secondaryPoolDescHint")}
</p>
<label className="flex items-center gap-3 text-sm text-content-primary">
<span className="shrink-0 text-content-muted">
{t("secondaryPoolAddLabel")}
</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={addSelection}
onChange={(e) => {
const alias = e.target.value;
setAddSelection("");
if (alias) handleAddEntry(alias);
}}
>
<option value="">{t("secondaryPoolAddPlaceholder")}</option>
{addableAliases.map((alias) => (
<option key={alias} value={alias}>
{aliasLabel(alias)}
</option>
))}
</select>
</label>
</>
)}
<div className="flex items-center gap-3">
<div className="flex-1 min-w-0">
<div className="text-sm text-content-primary">
{t("secondaryPoolForce")}
</div>
<div className="text-xs text-content-muted">
{t("secondaryPoolForceDesc")}
</div>
</div>
<Toggle
checked={pool.force}
ariaLabel={t("secondaryPoolForce")}
onChange={handleForceChange}
/>
</div>
<button
type="button"
onClick={handleClear}
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("secondaryModelClear")}
</button>
</>
) : (
<>
<label className="flex items-center gap-3 text-sm text-content-primary">
<span className="shrink-0 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={effectiveDefault ?? ""}
onChange={(e) => {
const alias = e.target.value;
if (alias === "") {
setPoolErrors([]);
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 the synthesized `__secondary__` entry.
const nextRaw = setSecondaryModelOnly(rawOther, alias);
if (validateWrite(nextRaw)) onChange(nextRaw);
}
}}
>
<option value="">{t("secondaryModelUnset")}</option>
{aliasList.map((alias) => (
<option key={alias} value={alias}>
{aliasLabel(alias)}
</option>
))}
</select>
{pool?.force && (
<span className="shrink-0 text-xs text-content-muted">
🔒 {t("secondaryModelForceBadge")}
</span>
)}
</label>
{canUpgrade && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleUpgrade}
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("secondaryPoolUpgrade")}
</button>
<span className="text-xs text-content-muted">
{t("secondaryPoolUpgradeDesc")}
</span>
</div>
)}
</>
)}
{poolErrors.length > 0 && (
<div
role="alert"
className="bg-red-100 dark:bg-red-900/20 border border-red-300 dark:border-red-500/30 rounded-lg px-3 py-2 text-sm text-red-700 dark:text-red-400 space-y-1"
>
{poolErrors.map((err, i) => (
<div key={i}>{t(POOL_ERROR_KEYS[err.key], err.params)}</div>
))}
</div>
)}
<div className="border-t border-border" />
{effectiveDefault !== undefined && (
<div className="rounded-lg border border-border bg-input/40 px-3 py-2.5 space-y-2">
<div className="flex items-baseline gap-2 text-sm">
<span className="shrink-0 text-content-muted">
{t("secondaryModelInheritedContext")}
</span>
<span className="font-medium text-content-primary">
{inheritedContext ? fmtContext(inheritedContext) : "—"}
</span>
</div>
<div className="flex items-baseline gap-2 text-sm">
<span className="shrink-0 text-content-muted">
{t("secondaryModelInheritedEffort")}
</span>
<span className="font-medium 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("secondaryModelPriorityHint")}</p>
</Card>
{/* Experimental feature flags — one unified list */}
<Card title={t("experimentalFlags")}>
{EXPERIMENTAL_FLAGS.map(renderFlagRow)}
<p className="text-xs text-content-muted">{t("experimentalFlagsHint")}</p>
</Card>
</div>
);
}
@@ -0,0 +1,532 @@
import { useEffect, useState, type ReactNode } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useTranslation, type Language } from "../../i18n";
import { useTheme, type Theme } from "../../hooks/useTheme";
import type { UpdateInfo } from "../../hooks/useUpdateCheck";
import {
useKimiCodeVersion,
isNewerVersion,
type KimiCodeStatus,
} from "../../hooks/useKimiCodeVersion";
function formatVersion(version: string): string {
const v = version.trim();
return v.startsWith("v") || v.startsWith("V") ? v : `v${v}`;
}
/** 上游 kimi-code releases 页面。 */
const KIMI_CODE_RELEASES_URL = "https://github.com/MoonshotAI/kimi-code/releases";
interface SettingsGeneralTabProps {
updateInfo: UpdateInfo | null;
checking: boolean;
onCheckUpdate: () => void;
lastChecked: number | null;
}
export function SettingsGeneralTab({
updateInfo,
checking,
onCheckUpdate,
lastChecked,
}: SettingsGeneralTabProps) {
const { t, lang, setLang } = useTranslation();
const { theme, setTheme } = useTheme();
const {
info: kimiCodeInfo,
checking: kimiCodeChecking,
checkNow: checkKimiCode,
} = useKimiCodeVersion();
const [appVersion, setAppVersion] = useState("");
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
const [downloadedPath, setDownloadedPath] = useState<string | null>(null);
const [downloadError, setDownloadError] = useState<string | null>(null);
useEffect(() => {
invoke<string>("get_app_version").then(setAppVersion).catch(() => {});
}, []);
// Listen to download progress events
useEffect(() => {
const unlisteners: (() => void)[] = [];
const setupProgress = async () => {
const un1 = await listen<{ downloaded: number; total: number; progress: number }>(
"download-progress",
(event) => {
setDownloadProgress(event.payload.progress);
}
);
unlisteners.push(() => un1());
const un2 = await listen<{ path: string }>(
"download-complete",
(event) => {
setDownloadedPath(event.payload.path);
setDownloadProgress(100);
}
);
unlisteners.push(() => un2());
};
setupProgress();
return () => unlisteners.forEach((fn) => fn());
}, []);
const handleDownload = async () => {
// No installer asset for this OS in the release (e.g. a release published
// without a Windows package, or a renamed asset): silently returning would
// look like a dead button. Fall back to the release page instead, and say
// why when even that URL is missing.
if (!updateInfo?.downloadUrl) {
if (updateInfo?.releaseUrl) {
openExternal(updateInfo.releaseUrl);
setDownloadError(null);
} else {
setDownloadError(t("updateNoDownload"));
}
return;
}
setDownloadProgress(0);
setDownloadError(null);
setDownloadedPath(null);
try {
const path = await invoke<string>("download_update", {
url: updateInfo.downloadUrl,
});
setDownloadedPath(path);
setDownloadProgress(100);
} catch (e) {
setDownloadError(e instanceof Error ? e.message : String(e));
setDownloadProgress(null);
}
};
const handleInstall = () => {
if (!downloadedPath) return;
invoke("open_installer", { path: downloadedPath }).catch(() => {});
};
// Open an external link via the Rust-side opener command (bypasses the JS
// plugin scope); fall back to a new tab when even that fails.
const openExternal = (url: string) => {
invoke("open_external_url", { url }).catch(() => {
const w = window.open(url, "_blank");
if (!w) console.error(`failed to open ${url}`);
});
};
const themeOptions: { value: Theme; icon: ReactNode; label: string }[] = [
{
value: "light",
icon: (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
</svg>
),
label: t("themeLight"),
},
{
value: "dark",
icon: (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
),
label: t("themeDark"),
},
{
value: "auto",
icon: (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="12" rx="2" />
<path d="M8 21h8M12 16v5" />
</svg>
),
label: t("themeAuto"),
},
];
const fmtLastChecked = (ts: number | null) => {
if (!ts) return "—";
const d = new Date(ts);
return d.toLocaleString(lang === "zh" ? "zh-CN" : "en-US");
};
// 状态徽标:绿=已适配 / 黄=建议升级 KimiSwitch / 橙=部分兼容 / 灰=未知或未安装
const kimiCodeBadge = (status: KimiCodeStatus) => {
switch (status) {
case "current":
return {
label: t("kimiCodeStatusCurrent"),
className:
"bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 border-green-300 dark:border-green-500/30",
};
case "outdated":
return {
label: t("kimiCodeStatusOutdated"),
className:
"bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 border-yellow-300 dark:border-yellow-500/30",
};
case "partial":
return {
label: t("kimiCodeStatusPartial"),
className:
"bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 border-orange-300 dark:border-orange-500/30",
};
case "unknown":
return {
label: t("kimiCodeStatusUnknown"),
className: "bg-hover-2 text-content-muted border-border",
};
default:
return {
label: t("kimiCodeStatusNotInstalled"),
className: "bg-hover-2 text-content-muted border-border",
};
}
};
const kimiCodeUpdateAvailable =
!!kimiCodeInfo?.installed &&
!!kimiCodeInfo?.latest &&
isNewerVersion(kimiCodeInfo.latest, kimiCodeInfo.installed);
const kimiCodeBadgeInfo = kimiCodeInfo ? kimiCodeBadge(kimiCodeInfo.status) : null;
return (
<div className="space-y-5">
{/* Appearance */}
<section>
<h3 className="mb-3 text-[11px] uppercase tracking-wider text-content-muted">
{t("settingsAppearance")}
</h3>
<div className="space-y-3">
{/* Theme */}
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-content-muted">
{t("themeLight")} / {t("themeDark")}
</span>
<div className="flex h-8 items-center gap-1 bg-input rounded border border-border p-0.5">
{themeOptions.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setTheme(opt.value)}
title={opt.label}
className={`flex h-7 items-center gap-1 px-2 text-sm rounded transition-colors ${
theme === opt.value
? "bg-blue-600 text-white"
: "text-content-muted hover:text-content-primary"
}`}
>
{opt.icon}
<span className="hidden sm:inline">{opt.label}</span>
</button>
))}
</div>
</div>
{/* Language */}
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-content-muted">{t("language")}</span>
<select
value={lang}
onChange={(e) => setLang(e.target.value as Language)}
className="h-8 bg-input border border-border rounded px-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
<option value="zh">{t("zh")}</option>
<option value="en">{t("en")}</option>
</select>
</div>
</div>
</section>
{/* Updates */}
<section>
<h3 className="mb-3 text-[11px] uppercase tracking-wider text-content-muted">
{t("settingsUpdates")}
</h3>
<div className="space-y-2 rounded-lg border border-border bg-input p-3">
<div className="flex items-center justify-between text-sm">
<span className="text-content-muted">{t("currentVersion")}</span>
<span className="font-medium text-content-primary tabular-nums">
v{appVersion || "—"}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-content-muted">{t("latestVersion")}</span>
<span className="font-medium text-content-primary tabular-nums">
{updateInfo ? formatVersion(updateInfo.latest) : "—"}
</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-content-muted">{t("lastChecked")}</span>
<span className="text-content-muted tabular-nums">
{fmtLastChecked(lastChecked)}
</span>
</div>
<div className="pt-2 flex items-center gap-2">
<button
type="button"
onClick={onCheckUpdate}
disabled={checking}
className="px-3 py-1.5 text-sm rounded border border-border hover:bg-hover-2 disabled:opacity-50 transition-colors flex items-center gap-1.5"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={checking ? "animate-spin" : ""}
>
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
<polyline points="21 3 21 9 15 9" />
</svg>
{checking ? t("checking") : t("checkUpdate")}
</button>
{updateInfo?.updateAvailable && downloadProgress === null && !downloadedPath && (
<button
type="button"
onClick={handleDownload}
className="px-3 py-1.5 text-sm rounded bg-green-600 text-white hover:bg-green-500 transition-colors flex items-center gap-1.5"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
{updateInfo?.downloadUrl
? t("downloadUpdate")
: t("openReleasePage")}
</button>
)}
{downloadedPath && (
<button
type="button"
onClick={handleInstall}
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-500 transition-colors flex items-center gap-1.5"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polygon points="6 3 20 12 6 21 6 3" />
</svg>
{t("install")}
</button>
)}
</div>
{/* Download progress bar */}
{downloadProgress !== null && downloadProgress < 100 && (
<div className="pt-1">
<div className="flex items-center justify-between text-xs text-content-muted mb-1">
<span>{t("downloading")}</span>
<span className="tabular-nums">{downloadProgress}%</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-hover">
<div
className="h-full rounded-full bg-blue-500 transition-all duration-150"
style={{ width: `${downloadProgress}%` }}
/>
</div>
</div>
)}
{downloadedPath && (
<div className="pt-1 text-sm text-green-600 dark:text-green-500">
✓ {t("downloadComplete")}
</div>
)}
{downloadError && (
<div className="pt-1 text-sm text-red-500">
{t("downloadFailed")}: {downloadError}
</div>
)}
{updateInfo?.updateAvailable && (
<div className="pt-1 text-sm text-green-600 dark:text-green-500">
✦ {t("updateAvailable")}: {formatVersion(updateInfo.latest)}
</div>
)}
{updateInfo && !updateInfo.updateAvailable && (
<div className="pt-1 text-sm text-content-muted">
✓ {t("upToDate")}
</div>
)}
</div>
</section>
{/* Kimi Code CLI version & compatibility */}
<section>
<h3 className="mb-3 text-[11px] uppercase tracking-wider text-content-muted">
{t("kimiCodeSection")}
</h3>
<div className="space-y-2 rounded-lg border border-border bg-input p-3">
<div className="flex items-center justify-between text-sm">
<span className="text-content-muted">{t("kimiCodeInstalled")}</span>
<span className="font-medium text-content-primary tabular-nums">
{kimiCodeInfo?.installed ? formatVersion(kimiCodeInfo.installed) : "—"}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-content-muted">{t("kimiCodeLatest")}</span>
<span className="font-medium text-content-primary tabular-nums">
{kimiCodeInfo?.latest ? formatVersion(kimiCodeInfo.latest) : "—"}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-content-muted">{t("kimiCodeStatusLabel")}</span>
{kimiCodeBadgeInfo ? (
<span
className={`text-xs px-2 py-0.5 rounded-full border ${kimiCodeBadgeInfo.className}`}
>
{kimiCodeBadgeInfo.label}
</span>
) : (
<span className="text-xs text-content-muted">—</span>
)}
</div>
<div className="pt-2 flex items-center gap-2">
<button
type="button"
onClick={checkKimiCode}
disabled={kimiCodeChecking}
className="px-3 py-1.5 text-sm rounded border border-border hover:bg-hover-2 disabled:opacity-50 transition-colors flex items-center gap-1.5"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={kimiCodeChecking ? "animate-spin" : ""}
>
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
<polyline points="21 3 21 9 15 9" />
</svg>
{kimiCodeChecking ? t("checking") : t("kimiCodeCheck")}
</button>
{kimiCodeUpdateAvailable && (
<button
type="button"
onClick={() => openExternal(KIMI_CODE_RELEASES_URL)}
className="px-3 py-1.5 text-sm rounded border border-border hover:bg-hover-2 transition-colors"
>
{t("kimiCodeOpenReleases")}
</button>
)}
</div>
</div>
</section>
{/* About */}
<section>
<h3 className="mb-3 text-[11px] uppercase tracking-wider text-content-muted">
{t("settingsAbout")}
</h3>
<div className="space-y-1.5 text-sm text-content-muted">
<div className="font-medium text-content-primary">Kimi Switch</div>
<div>{t("appDescription")}</div>
<div className="text-xs text-content-muted">v{appVersion} · MIT License</div>
<div className="pt-2 space-y-1">
<div>
{t("settingsWebsite")}:{" "}
<button
type="button"
onClick={() =>
openExternal("https://billowliu2.github.io/KimiSwitch")
}
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer"
>
billowliu2.github.io/KimiSwitch
</button>
</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>
{t("openTerminalSite")}:{" "}
<button
type="button"
onClick={() =>
openExternal("https://billowliu2.github.io/OpenTerminal/")
}
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer"
>
billowliu2.github.io/OpenTerminal
</button>
</div>
<div>
{t("attributionPorted")}{" "}
<button
type="button"
onClick={() =>
openExternal("https://github.com/JochenYang/kimicode-dashboard")
}
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer font-semibold"
>
kimicode-dashboard
</button>
<span className="text-content-muted"> {t("attributionSuffix")}</span>
</div>
<div className="pt-1">
{t("modelDataSourcePrefix")}{" "}
<button
type="button"
onClick={() => openExternal("https://models.dev/")}
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer"
>
models.dev
</button>
</div>
<div className="pt-1">
<span className="text-content-muted">{t("referenceProject")}: </span>
<button
type="button"
onClick={() =>
openExternal("https://github.com/farion1231/cc-switch")
}
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer"
>
cc-switch
</button>
<span className="text-content-muted"> (MIT, © Jason Young)</span>
</div>
</div>
</div>
</section>
</div>
);
}
@@ -0,0 +1,32 @@
import { AgentSettingsPanel } from "../AgentSettingsPanel";
import type { Model } from "../../types";
interface SettingsGlobalTabProps {
rawOther: unknown;
models: Record<string, Model>;
defaultModel: string | null;
onChange: (nextRawOther: unknown) => void;
}
/**
* Global agent settings (thinking / loop control / permission rules / hooks),
* which live in the shared `config.raw_other` and therefore apply to every
* provider rather than to a single one.
*/
export function SettingsGlobalTab({
rawOther,
models,
defaultModel,
onChange,
}: SettingsGlobalTabProps) {
return (
<div className="space-y-4">
<AgentSettingsPanel
rawOther={rawOther}
onChange={onChange}
models={models}
defaultModel={defaultModel}
/>
</div>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { useTranslation } from "../../i18n";
import type { UpdateInfo } from "../../hooks/useUpdateCheck";
import type { Model } from "../../types";
import { SettingsGeneralTab } from "./SettingsGeneralTab";
import { SettingsGlobalTab } from "./SettingsGlobalTab";
import { SettingsAdvancedTab } from "./SettingsAdvancedTab";
type SettingsTab = "general" | "global" | "advanced";
interface SettingsPageProps {
activeTab: SettingsTab;
onTabChange: (tab: SettingsTab) => void;
updateInfo: UpdateInfo | null;
checking: boolean;
onCheckUpdate: () => void;
lastChecked: number | null;
rawOther: unknown;
models: Record<string, Model>;
defaultModel: string | null;
onRawOtherChange: (nextRawOther: unknown) => void;
}
/**
* Full-page settings shell: a tab bar and three tab panels. No back header —
* the top nav group already offers peer-level switching. Every panel stays
* mounted — the inactive ones are only hidden — so the update download state
* (progress + installer path) survives tab switches.
*/
export function SettingsPage({
activeTab,
onTabChange,
updateInfo,
checking,
onCheckUpdate,
lastChecked,
rawOther,
models,
defaultModel,
onRawOtherChange,
}: SettingsPageProps) {
const { t } = useTranslation();
const tabs: { id: SettingsTab; label: string }[] = [
{ id: "global", label: t("settingsTabGlobal") },
{ id: "advanced", label: t("subagentSettings") },
{ id: "general", label: t("settingsAbout") },
];
return (
<div className="h-full flex flex-col bg-app">
{/* Tab bar —— 无返回头:顶部导航组已可平级切换,设置页是平级视图 */}
<div className="flex items-center px-4 py-2 border-b border-border bg-panel">
<div className="flex items-center bg-input border border-border rounded p-0.5">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => onTabChange(tab.id)}
className={`px-3 py-1 text-sm rounded transition-colors ${
activeTab === tab.id
? "bg-blue-600 text-white"
: "text-content-muted hover:text-content-primary"
}`}
>
{tab.label}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-auto p-6 space-y-5">
<div className={activeTab === "general" ? "" : "hidden"}>
<SettingsGeneralTab
updateInfo={updateInfo}
checking={checking}
onCheckUpdate={onCheckUpdate}
lastChecked={lastChecked}
/>
</div>
<div className={activeTab === "global" ? "" : "hidden"}>
<SettingsGlobalTab
rawOther={rawOther}
models={models}
defaultModel={defaultModel}
onChange={onRawOtherChange}
/>
</div>
<div className={activeTab === "advanced" ? "" : "hidden"}>
<SettingsAdvancedTab
rawOther={rawOther}
models={models}
onChange={onRawOtherChange}
/>
</div>
</div>
</div>
);
}
+5
View File
@@ -319,6 +319,7 @@ export const enTranslations: Record<TranslationKey, string> = {
themeAuto: "Auto",
settings: "Settings",
settingsTitle: "Settings",
settingsTabGlobal: "Global Settings",
settingsAppearance: "Appearance",
settingsUpdates: "Updates",
settingsAbout: "About",
@@ -333,6 +334,9 @@ export const enTranslations: Record<TranslationKey, string> = {
downloadComplete: "Download complete, click to install",
install: "Install",
updateCheckFailed: "Update check failed",
updateNoDownload: "No installer available for this release — open the release page to download manually",
openReleasePage: "Open release page",
downloadFailed: "Download failed",
lastChecked: "Last checked",
// Kimi Code version tracking
kimiCodeSection: "Kimi Code version",
@@ -349,6 +353,7 @@ export const enTranslations: Record<TranslationKey, string> = {
appDescription: "Windows desktop LLM provider config manager",
settingsWebsite: "Official website",
kimiEyesSite: "Kimi Eyes plugin site",
openTerminalSite: "OpenTerminal site",
referenceProject: "Reference project",
// Validation errors
+5
View File
@@ -308,6 +308,7 @@ export const zhTranslations = {
themeAuto: "跟随系统",
settings: "设置",
settingsTitle: "设置",
settingsTabGlobal: "全局设置",
settingsAppearance: "外观",
settingsUpdates: "更新",
settingsAbout: "关于",
@@ -322,6 +323,9 @@ export const zhTranslations = {
downloadComplete: "下载完成,点击安装",
install: "安装",
updateCheckFailed: "检查更新失败",
updateNoDownload: "该版本没有可用的安装包,请前往 Release 页面手动下载",
openReleasePage: "打开下载页",
downloadFailed: "下载失败",
lastChecked: "上次检查",
// Kimi Code 版本跟踪
kimiCodeSection: "Kimi Code 版本",
@@ -338,6 +342,7 @@ export const zhTranslations = {
appDescription: "Windows 桌面端 LLM 供应商配置管理器",
settingsWebsite: "官方网站",
kimiEyesSite: "Kimi Eyes 插件站",
openTerminalSite: "OpenTerminal 项目站",
referenceProject: "引用项目",
// Validation errors
+38 -83
View File
@@ -8041,9 +8041,9 @@
"id": "deepseek/deepseek-latest",
"name": "DeepSeek Latest",
"cost": {
"input": 0.8957,
"output": 0.99,
"cache_read": 0.1866
"input": 1.1,
"output": 2.5,
"cache_read": 0.04
},
"context": 1048576,
"input_limit": 1048576,
@@ -8086,9 +8086,9 @@
"id": "deepseek/deepseek-v4-pro-0813:thinking",
"name": "DeepSeek V4 Pro 0813 Thinking",
"cost": {
"input": 0.8957,
"output": 0.99,
"cache_read": 0.1866
"input": 1.1,
"output": 2.5,
"cache_read": 0.04
},
"context": 1048576,
"input_limit": 1048576,
@@ -8101,9 +8101,9 @@
"id": "deepseek/deepseek-v4-pro-0813",
"name": "DeepSeek V4 Pro 0813",
"cost": {
"input": 0.8957,
"output": 0.99,
"cache_read": 0.1866
"input": 1.1,
"output": 2.5,
"cache_read": 0.04
},
"context": 1048576,
"input_limit": 1048576,
@@ -8157,21 +8157,6 @@
"tool_call": true,
"structured_output": true
},
{
"id": "deepseek/deepseek-v4-flash-vision-exp-cybersecurity",
"name": "DeepSeek V4 Flash Vision Exp Cybersecurity",
"cost": {
"input": 0.15,
"output": 0.6,
"cache_read": 0.075
},
"context": 1048576,
"input_limit": 1048576,
"output_limit": 32768,
"reasoning": true,
"tool_call": true,
"image": true
},
{
"id": "deepseek/deepseek-v3.2",
"name": "DeepSeek V3.2",
@@ -11376,9 +11361,9 @@
"id": "moonshotai/kimi-k2.6",
"name": "Kimi K2.6",
"cost": {
"input": 0.441,
"output": 2.45,
"cache_read": 0.099
"input": 0.5,
"output": 2.6,
"cache_read": 0.125
},
"context": 262144,
"input_limit": 262144,
@@ -11448,9 +11433,9 @@
"id": "moonshotai/kimi-k2.6:thinking",
"name": "Kimi K2.6 Thinking",
"cost": {
"input": 0.441,
"output": 2.45,
"cache_read": 0.099
"input": 0.5,
"output": 2.6,
"cache_read": 0.125
},
"context": 262144,
"input_limit": 262144,
@@ -26364,21 +26349,6 @@
"structured_output": true,
"image": true
},
{
"id": "anthropic/claude-sonnet-4",
"name": "Anthropic: Claude Sonnet 4 (retires Oct 15)",
"cost": {
"input": 3,
"output": 15,
"cache_read": 0.3,
"cache_write": 3.75
},
"context": 200000,
"output_limit": 64000,
"reasoning": true,
"tool_call": true,
"image": true
},
{
"id": "anthropic/claude-sonnet-5",
"name": "Claude Sonnet 5",
@@ -27608,9 +27578,9 @@
"id": "~z-ai/glm-latest",
"name": "Z.ai: GLM Latest",
"cost": {
"input": 0.063,
"input": 0.0608,
"output": 4.4,
"cache_read": 0.0586
"cache_read": 0.0565
},
"context": 1048576,
"output_limit": 131072,
@@ -27984,9 +27954,9 @@
"id": "~deepseek/deepseek-pro-latest",
"name": "DeepSeek: DeepSeek Pro Latest",
"cost": {
"input": 0.225,
"input": 0.1307,
"output": 8,
"cache_read": 0.23
"cache_read": 0.13
},
"context": 1048576,
"output_limit": 393216,
@@ -28667,7 +28637,7 @@
"output": 0.3,
"cache_read": 0.01
},
"context": 128000,
"context": 256000,
"output_limit": 16384,
"tool_call": true,
"structured_output": true,
@@ -30268,7 +30238,7 @@
"output": 2.7
},
"context": 262144,
"output_limit": 81920,
"output_limit": 65536,
"reasoning": true,
"tool_call": true,
"structured_output": true,
@@ -39799,21 +39769,6 @@
"structured_output": true,
"image": true
},
{
"id": "anthropic/claude-sonnet-4",
"name": "Claude Sonnet 4",
"cost": {
"input": 3,
"output": 15,
"cache_read": 0.3,
"cache_write": 3.75
},
"context": 200000,
"output_limit": 64000,
"reasoning": true,
"tool_call": true,
"image": true
},
{
"id": "anthropic/claude-sonnet-5",
"name": "Claude Sonnet 5",
@@ -40103,9 +40058,9 @@
"id": "deepseek/deepseek-v4-pro",
"name": "DeepSeek V4 Pro",
"cost": {
"input": 0.95526,
"output": 1.91052,
"cache_read": 0.079605
"input": 0.43935,
"output": 0.8787,
"cache_read": 0.036613
},
"context": 1048576,
"output_limit": 384000,
@@ -40611,9 +40566,9 @@
"id": "z-ai/glm-5.2",
"name": "GLM-5.2",
"cost": {
"input": 0.02,
"input": 0.06,
"output": 7,
"cache_read": 0.019
"cache_read": 0.059
},
"context": 1048576,
"output_limit": 943718,
@@ -40665,9 +40620,9 @@
"id": "z-ai/glm-5.3",
"name": "GLM-5.3",
"cost": {
"input": 0.079,
"output": 4.8,
"cache_read": 0.078
"input": 0.4,
"output": 8,
"cache_read": 0.399
},
"context": 1048576,
"output_limit": 943718,
@@ -41054,9 +41009,9 @@
"id": "~z-ai/glm-latest",
"name": "GLM Latest",
"cost": {
"input": 0.063,
"input": 0.0608,
"output": 4.4,
"cache_read": 0.0586
"cache_read": 0.0565
},
"context": 1048576,
"output_limit": 131072,
@@ -41430,9 +41385,9 @@
"id": "~deepseek/deepseek-pro-latest",
"name": "DeepSeek Pro Latest",
"cost": {
"input": 0.225,
"input": 0.1307,
"output": 8,
"cache_read": 0.23
"cache_read": 0.13
},
"context": 1048576,
"output_limit": 393216,
@@ -42135,8 +42090,8 @@
"id": "mistralai/mistral-small-3.2-24b-instruct",
"name": "Mistral Small 3.2 24B",
"cost": {
"input": 0.075,
"output": 0.2
"input": 0.09375,
"output": 0.25
},
"context": 256000,
"output_limit": 16384,
@@ -43647,11 +43602,11 @@
"id": "qwen/qwen3.6-27b",
"name": "Qwen3.6 27B",
"cost": {
"input": 0.32,
"output": 3.2
"input": 0.45,
"output": 2.7
},
"context": 262144,
"output_limit": 81920,
"output_limit": 65536,
"reasoning": true,
"tool_call": true,
"structured_output": true,
+38 -79
View File
@@ -7166,9 +7166,9 @@
"tool_call": true,
"structured_output": true,
"cost": {
"input": 0.8957,
"output": 0.99,
"cache_read": 0.1866
"input": 1.1,
"output": 2.5,
"cache_read": 0.04
}
},
"nano-gpt/deepseek/deepseek-v4-flash-0423:thinking": {
@@ -7205,9 +7205,9 @@
"tool_call": true,
"structured_output": true,
"cost": {
"input": 0.8957,
"output": 0.99,
"cache_read": 0.1866
"input": 1.1,
"output": 2.5,
"cache_read": 0.04
}
},
"nano-gpt/deepseek/deepseek-v4-pro-0813": {
@@ -7218,9 +7218,9 @@
"tool_call": true,
"structured_output": true,
"cost": {
"input": 0.8957,
"output": 0.99,
"cache_read": 0.1866
"input": 1.1,
"output": 2.5,
"cache_read": 0.04
}
},
"nano-gpt/deepseek/deepseek-v4-flash-0731:thinking": {
@@ -7262,19 +7262,6 @@
"cache_read": 0.013
}
},
"nano-gpt/deepseek/deepseek-v4-flash-vision-exp-cybersecurity": {
"name": "DeepSeek V4 Flash Vision Exp Cybersecurity",
"context": 1048576,
"output": 32768,
"reasoning": true,
"tool_call": true,
"image": true,
"cost": {
"input": 0.15,
"output": 0.6,
"cache_read": 0.075
}
},
"nano-gpt/deepseek/deepseek-v3.2": {
"name": "DeepSeek V3.2",
"context": 163000,
@@ -10032,9 +10019,9 @@
"tool_call": true,
"image": true,
"cost": {
"input": 0.441,
"output": 2.45,
"cache_read": 0.099
"input": 0.5,
"output": 2.6,
"cache_read": 0.125
}
},
"nano-gpt/moonshotai/kimi-k2.5:thinking": {
@@ -10095,9 +10082,9 @@
"tool_call": true,
"image": true,
"cost": {
"input": 0.441,
"output": 2.45,
"cache_read": 0.099
"input": 0.5,
"output": 2.6,
"cache_read": 0.125
}
},
"nano-gpt/moonshotai/kimi-k2-instruct-0711": {
@@ -23499,20 +23486,6 @@
"cache_write": 12.5
}
},
"kilo/anthropic/claude-sonnet-4": {
"name": "Anthropic: Claude Sonnet 4 (retires Oct 15)",
"context": 200000,
"output": 64000,
"reasoning": true,
"tool_call": true,
"image": true,
"cost": {
"input": 3,
"output": 15,
"cache_read": 0.3,
"cache_write": 3.75
}
},
"kilo/anthropic/claude-sonnet-5": {
"name": "Claude Sonnet 5",
"context": 1000000,
@@ -24653,9 +24626,9 @@
"tool_call": true,
"structured_output": true,
"cost": {
"input": 0.063,
"input": 0.0608,
"output": 4.4,
"cache_read": 0.0586
"cache_read": 0.0565
}
},
"kilo/arcee-ai/trinity-large-thinking": {
@@ -25003,9 +24976,9 @@
"tool_call": true,
"structured_output": true,
"cost": {
"input": 0.225,
"input": 0.1307,
"output": 8,
"cache_read": 0.23
"cache_read": 0.13
}
},
"kilo/~deepseek/deepseek-v4-flash-latest": {
@@ -25629,7 +25602,7 @@
},
"kilo/mistralai/mistral-small-3.2-24b-instruct": {
"name": "Mistral Small 3.2 24B",
"context": 128000,
"context": 256000,
"output": 16384,
"tool_call": true,
"structured_output": true,
@@ -27111,7 +27084,7 @@
"kilo/qwen/qwen3.6-27b": {
"name": "Qwen3.6 27B",
"context": 262144,
"output": 81920,
"output": 65536,
"reasoning": true,
"tool_call": true,
"structured_output": true,
@@ -35752,20 +35725,6 @@
"cache_write": 12.5
}
},
"openrouter/anthropic/claude-sonnet-4": {
"name": "Claude Sonnet 4",
"context": 200000,
"output": 64000,
"reasoning": true,
"tool_call": true,
"image": true,
"cost": {
"input": 3,
"output": 15,
"cache_read": 0.3,
"cache_write": 3.75
}
},
"openrouter/anthropic/claude-sonnet-5": {
"name": "Claude Sonnet 5",
"context": 1000000,
@@ -36038,9 +35997,9 @@
"tool_call": true,
"structured_output": true,
"cost": {
"input": 0.95526,
"output": 1.91052,
"cache_read": 0.079605
"input": 0.43935,
"output": 0.8787,
"cache_read": 0.036613
}
},
"openrouter/deepseek/deepseek-r1-0528": {
@@ -36507,9 +36466,9 @@
"tool_call": true,
"structured_output": true,
"cost": {
"input": 0.02,
"input": 0.06,
"output": 7,
"cache_read": 0.019
"cache_read": 0.059
}
},
"openrouter/z-ai/glm-5.1": {
@@ -36557,9 +36516,9 @@
"tool_call": true,
"structured_output": true,
"cost": {
"input": 0.079,
"output": 4.8,
"cache_read": 0.078
"input": 0.4,
"output": 8,
"cache_read": 0.399
}
},
"openrouter/inference-net/schematron-v2-small": {
@@ -36914,9 +36873,9 @@
"tool_call": true,
"structured_output": true,
"cost": {
"input": 0.063,
"input": 0.0608,
"output": 4.4,
"cache_read": 0.0586
"cache_read": 0.0565
}
},
"openrouter/arcee-ai/trinity-large-thinking": {
@@ -37264,9 +37223,9 @@
"tool_call": true,
"structured_output": true,
"cost": {
"input": 0.225,
"input": 0.1307,
"output": 8,
"cache_read": 0.23
"cache_read": 0.13
}
},
"openrouter/~deepseek/deepseek-v4-flash-latest": {
@@ -37920,8 +37879,8 @@
"structured_output": true,
"image": true,
"cost": {
"input": 0.075,
"output": 0.2
"input": 0.09375,
"output": 0.25
}
},
"openrouter/mistralai/mistral-large": {
@@ -39313,15 +39272,15 @@
"openrouter/qwen/qwen3.6-27b": {
"name": "Qwen3.6 27B",
"context": 262144,
"output": 81920,
"output": 65536,
"reasoning": true,
"tool_call": true,
"structured_output": true,
"image": true,
"video": true,
"cost": {
"input": 0.32,
"output": 3.2
"input": 0.45,
"output": 2.7
}
},
"openrouter/qwen/qwen3.6-plus": {