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
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:
1 parent
e00f6b6591
commit
38dbb5527a
50 files changed
+3904
-2571
No files matched your search
+39
-26
@@ -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,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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
Reference in new issue
Block a user