Files
KimiCodeSwitch/src/App.tsx
T
KimiSwitch Dev 8221e05db1
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
feat(oauth): v0.7.7 — 双区域 OAuth 支持(适配 kimi-code 0.38.0 global 账号)+ 修复模型占位别名跟随 + models.dev 快照刷新
2026-08-22 14:00:20 +08:00

915 lines
34 KiB
TypeScript

import { useCallback, useEffect, useMemo, useReducer, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useConfig } from "./hooks/useConfig";
import { useUpdateCheck } from "./hooks/useUpdateCheck";
import { ProviderList } from "./components/ProviderList";
import { ProviderEdit } from "./components/ProviderEdit";
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 { UsageConfigModal } from "./components/UsageConfigModal";
import { PresetPickerModal } from "./components/PresetPickerModal";
import { useTranslation } from "./i18n";
import { getDefaultMaxContextSize } from "./lib/model-defaults";
import { getModelRef, modelsDevReady, onModelsDevReady } from "./lib/models-dev";
import {
presetToProviderAndModels,
type ProviderPreset,
} from "./config/providerPresets";
import { validateProviders } from "./lib/validation";
import type { Agent, Model, Provider, UsageConfig } from "./types";
const AGENT_STORAGE_KEY = "kimi-switch-agent";
function getProviderDefaultModel(provider: Provider): string | null {
const raw = provider.raw_other as Record<string, unknown> | undefined;
const value = raw?.default_model;
return typeof value === "string" ? value : null;
}
function setProviderDefaultModel(provider: Provider, alias: string | null): Provider {
const raw = (provider.raw_other as Record<string, unknown> | undefined) ?? {};
if (alias) {
return { ...provider, raw_other: { ...raw, default_model: alias } };
}
const { default_model: _, ...rest } = raw;
return { ...provider, raw_other: rest };
}
function findFirstModelForProvider(models: Record<string, Model>, providerName: string): string | null {
for (const [key, m] of Object.entries(models)) {
if (m.provider === providerName) {
return key;
}
}
return null;
}
function getInitialAgent(): Agent {
try {
const stored = localStorage.getItem(AGENT_STORAGE_KEY) as Agent | null;
if (stored === "kimi_code") return stored;
} catch {
// ignore
}
return "kimi_code";
}
export default function App() {
const { t, lang, setLang } = useTranslation();
const { updateInfo, checking, checkNow, lastChecked } = useUpdateCheck();
const [agent] = useState<Agent>(getInitialAgent);
const {
config,
dirty,
error,
loading,
refresh,
save,
updateConfig,
} = useConfig(agent);
const [view, setView] = useState<
"list" | "edit" | "subagent" | "dashboard" | "sessions" | "plugins"
>("list");
const [editingProvider, setEditingProvider] = useState<string>("");
const [loadTimeout, setLoadTimeout] = useState(false);
const [switchMessage, setSwitchMessage] = useState<string | null>(null);
const [showSettings, setShowSettings] = useState(false);
const [showPresetPicker, setShowPresetPicker] = useState(false);
/** Provider whose usage-config panel is open; null = closed. */
const [usageConfigProvider, setUsageConfigProvider] = useState<string | null>(null);
// Provider names that have been added to the in-memory config but not
// yet committed via save(). Pressing "back" in the edit form for one of
// these drops it silently (no confirm); all other dirty navigation
// keeps the existing unsavedConfirm flow.
const [pendingNewProviders, setPendingNewProviders] = useState<Set<string>>(
() => new Set(),
);
useEffect(() => {
if (!loading) {
setLoadTimeout(false);
return;
}
const timer = setTimeout(() => setLoadTimeout(true), 5000);
return () => clearTimeout(timer);
}, [loading]);
// Update window title to reflect unsaved changes.
useEffect(() => {
const app = getCurrentWindow();
const prefix = dirty ? "* " : "";
app.setTitle(`${prefix}${t("appTitle")}`);
}, [dirty, t]);
// Warn before closing the window when there are unsaved changes.
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (dirty) {
e.preventDefault();
e.returnValue = "";
}
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, [dirty]);
useEffect(() => {
invoke("debug_log", {
message: `App mounted. agent=${agent} loading=${loading} config=${config ? Object.keys(config.providers).length + " providers" : "null"}`,
}).catch(() => {});
}, [loading, config, agent]);
// Load the models.dev index in the background (not in the main bundle)
// and re-render once it lands so context/capability columns fill in.
const [, forceModelsDevReady] = useReducer((x: number) => x + 1, 0);
useEffect(() => {
onModelsDevReady(forceModelsDevReady);
void modelsDevReady();
}, []);
const providers = useMemo(
() => (config ? Object.values(config.providers) : []),
[config]
);
// trySave calls validateProviders, which takes a library-style
// `(key: string) => string`; useTranslation's `t` is strictly typed to
// the project's i18n key union, so we relax it locally for the call.
const tGeneric = t as unknown as (
key: string,
vars?: Record<string, unknown>,
) => string;
// Wraps `save` with a completeness check. Three flavours of caller:
// 1. Whole-config save (Ctrl+S, footer button, ProviderEdit onSave):
// no `target` → validate every enabled provider, show a confirm
// dialog listing the incomplete ones.
// 2. Switching a specific provider as active: `target` is the
// provider being switched to — only that one needs to be
// complete; sibling providers being merely listed in the config
// must not block the switch.
// 3. Programmatic auto-saves (duplicate) call `save` directly with
// no validation — duplicates inherit completeness from their
// source.
// Returns `true` when the config was actually persisted, `false` when
// the user cancelled the validation confirm (callers can react to this
// to roll back in-memory mutations made before the save).
const trySave = useCallback(
async (target?: string): Promise<boolean> => {
if (!config) return false;
if (dirty) {
const issues = validateProviders(config, config.models, tGeneric, target);
if (issues.length > 0) {
const summary = issues
.map((i) => `• ${i.name}: ${i.reasons.join("; ")}`)
.join("\n");
if (!confirm(t("saveValidationConfirm", { details: summary }))) {
return false;
}
}
}
await save();
// Successful save: clear the "in-memory only" markers — every
// provider is now on disk.
setPendingNewProviders(new Set());
return true;
},
[config, dirty, save, t],
);
// Keyboard shortcuts: Ctrl+S save, Ctrl+R reload, Ctrl+O open config dir
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (!e.ctrlKey || e.metaKey) return;
switch (e.key) {
case "s":
case "S":
e.preventDefault();
void trySave();
break;
case "r":
case "R":
e.preventDefault();
if (dirty && !confirm(t("unsavedConfirm"))) return;
refresh();
break;
case "o":
case "O":
e.preventDefault();
invoke("open_agent_config_dir", { agent }).catch((err) =>
alert(err instanceof Error ? err.message : String(err))
);
break;
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [trySave, refresh, dirty, t, agent]);
// "Add provider" opens the preset picker; the empty form is reachable via
// the picker's "+ custom config" entry (handleAddCustomProvider).
const handleAddProvider = () => {
if (!config) return;
setShowPresetPicker(true);
};
const handleAddCustomProvider = () => {
if (!config) return;
setShowPresetPicker(false);
const name = `provider-${providers.length + 1}`;
const defaultType = agent === "kimi_code" ? "kimi" : "openai";
updateConfig((cfg) => ({
...cfg,
providers: {
...cfg.providers,
[name]: {
name,
provider_type: defaultType,
base_url: null,
api_key: null,
env: {},
note: null,
official_url: null,
managed: false,
enabled: true,
},
},
}));
// Mark as in-memory only — never persisted until the user explicitly
// saves a complete config. Pressing "back" in the edit form will
// silently drop this entry.
setPendingNewProviders((prev) => new Set([...prev, name]));
setEditingProvider(name);
setView("edit");
};
const handleSelectPreset = async (preset: ProviderPreset) => {
if (!config) return;
setShowPresetPicker(false);
const { provider, models, defaultModel } = presetToProviderAndModels(preset, {
existingProviderNames: new Set(Object.keys(config.providers)),
existingModelAliases: new Set(Object.keys(config.models)),
});
updateConfig((cfg) => {
const providers = { ...cfg.providers, [provider.name]: provider };
const updatedModels = { ...cfg.models };
for (const m of models) {
updatedModels[m.alias] = m;
}
return {
...cfg,
providers,
models: updatedModels,
default_model: defaultModel || cfg.default_model,
};
});
// Do NOT auto-save: a freshly-picked preset is intentionally
// incomplete (no api_key yet). The user lands in the edit form
// next; if they fill the key and save, validation passes and the
// config lands on disk. If they back out without saving, the
// in-memory additions are discarded on the next refresh — config.toml
// never receives a half-configured provider.
setPendingNewProviders((prev) => new Set([...prev, provider.name]));
setEditingProvider(provider.name);
setView("edit");
};
const handleUpdateProvider = (provider: Provider) => {
updateConfig((cfg) => {
const providers = { ...cfg.providers };
const oldEntry = Object.entries(providers).find(
([, p]) => p.name === editingProvider
);
if (oldEntry && oldEntry[0] !== provider.name) {
delete providers[oldEntry[0]];
const models = { ...cfg.models };
for (const key of Object.keys(models)) {
if (models[key].provider === editingProvider) {
models[key] = { ...models[key], provider: provider.name };
}
}
providers[provider.name] = provider;
setEditingProvider(provider.name);
return { ...cfg, providers, models };
}
providers[provider.name] = provider;
return { ...cfg, providers };
});
};
const handleDeleteProvider = (name: string) => {
if (!confirm(t("deleteProviderConfirm", { name }))) return;
updateConfig((cfg) => {
const providers = { ...cfg.providers };
delete providers[name];
const models = { ...cfg.models };
for (const key of Object.keys(models)) {
if (models[key].provider === name) {
delete models[key];
}
}
return { ...cfg, providers, models };
});
if (editingProvider === name) {
setEditingProvider("");
setView("list");
}
};
const handleSaveUsageConfig = (name: string, usageConfig: UsageConfig) => {
updateConfig((cfg) => {
const target = cfg.providers[name];
if (!target) return cfg;
return {
...cfg,
providers: {
...cfg.providers,
[name]: { ...target, usageConfig },
},
};
});
// Silent save: a normal save flips App to its loading screen, which
// unmounts this panel and swallows the in-flight test query's result.
// Returned so the panel's test can await persistence before querying.
return save({ silent: true });
};
const handleDuplicateProvider = async (name: string) => {
updateConfig((cfg) => {
const src = cfg.providers[name];
if (!src) return cfg;
let newName = `${name}-copy`;
let n = 2;
while (cfg.providers[newName]) {
newName = `${name}-copy-${n}`;
n++;
}
// Clone the provider (deactivated, clear its remembered default model
// since the model aliases will be re-keyed to the new name below).
const copied = setProviderDefaultModel(
{ ...src, name: newName, active: false },
null
);
const providers = { ...cfg.providers, [newName]: copied };
// Re-key this provider's models to the new provider name so the copy
// has its own independent model set.
const models = { ...cfg.models };
for (const [alias, m] of Object.entries(cfg.models)) {
if (m.provider === name) {
// The model belongs to this provider by `m.provider === name`. To
// re-key it under the new provider, we want a `newName/...`
// alias. The original code assumed `alias` is exactly
// `${name}/${modelId}` and used `alias.slice(name.length)`; that
// breaks for legacy / non-standard aliases (e.g. `kimi-k3` from
// pre-v0.6 data) which would produce `newName-k3` — missing the
// `/` separator and pointing at the wrong model. Use an explicit
// prefix check and fall back to a full re-prefix.
const prefix = `${name}/`;
const newAlias = alias.startsWith(prefix)
? newName + alias.slice(name.length)
: `${newName}/${alias}`;
models[newAlias] = { ...m, alias: newAlias, provider: newName };
}
}
return { ...cfg, providers, models };
});
await save();
setSwitchMessage(t("copiedProvider", { name: `${name}-copy` }));
setTimeout(() => setSwitchMessage(null), 3000);
};
const handleSetDefaultModel = (alias: string) => {
updateConfig((cfg) => {
const model = cfg.models[alias];
if (!model) return cfg;
const provider = cfg.providers[model.provider];
if (!provider) return cfg;
const providers = {
...cfg.providers,
[provider.name]: setProviderDefaultModel(provider, alias),
};
return { ...cfg, providers, default_model: alias };
});
};
const handleSwitchProvider = async (name: string) => {
updateConfig((cfg) => {
const target = cfg.providers[name];
if (!target) return cfg;
// Find the currently active provider so we can remember its default
// model before switching away.
let activeProviderName: string | null = null;
for (const [key, p] of Object.entries(cfg.providers)) {
if (p.active) {
activeProviderName = key;
break;
}
}
// Rebuild providers with the selected one moved to the front so it is
// visually prioritised. All others are deactivated.
const providers: Record<string, Provider> = {};
providers[name] = { ...target, active: true };
// Remember the old active provider's current default model (if it
// belongs to that provider) so switching back later restores the user's
// choice.
if (activeProviderName && activeProviderName !== name && cfg.default_model) {
const oldActive = cfg.providers[activeProviderName];
if (oldActive && cfg.models[cfg.default_model]?.provider === activeProviderName) {
providers[activeProviderName] = setProviderDefaultModel(
{ ...oldActive, active: false },
cfg.default_model
);
}
}
// Add remaining providers (skip selected and old-active if already added).
for (const [key, p] of Object.entries(cfg.providers)) {
if (key === name) continue;
if (providers[key]) continue;
providers[key] = { ...p, active: false };
}
// Prefer the target provider's remembered default model; fall back to
// its first model when there is no saved preference or it is invalid.
let default_model = getProviderDefaultModel(target);
if (!default_model || cfg.models[default_model]?.provider !== name) {
default_model = findFirstModelForProvider(cfg.models, name);
}
return { ...cfg, providers, default_model };
});
// Persist the full config to Kimi Switch's SQLite and activate the selected provider.
const saved = await trySave(name);
if (!saved) {
// Validation was cancelled: undo the in-memory switch by reloading
// the on-disk state, and skip the activation + /reload nudge.
await refresh();
return;
}
await invoke("activate_agent_config_command", { agent });
// /reload is an interactive Kimi Code TUI command with no CLI equivalent,
// so we copy it to the clipboard as a convenience.
try {
await navigator.clipboard.writeText("/reload");
setSwitchMessage(t("reloadCopiedHint"));
setTimeout(() => setSwitchMessage(null), 4000);
} catch {
// Clipboard may be unavailable; ignore silently.
}
};
const handleEditBack = async () => {
const target = editingProvider;
if (target && pendingNewProviders.has(target)) {
// This provider was added in-memory (via preset or "+ custom config")
// but never committed. Drop it silently — no confirm — and reload
// from disk so the list reflects the on-disk truth.
setPendingNewProviders((prev) => {
const next = new Set(prev);
next.delete(target);
return next;
});
await refresh();
} else if (dirty) {
// Real edits to a previously-saved provider — keep the existing
// unsaved-changes confirm so the user doesn't lose work by accident.
if (!confirm(t("unsavedConfirm"))) return;
await refresh();
}
setView("list");
};
const handleApplyProviderJson = (provider: Provider, models: Model[]) => {
updateConfig((cfg) => {
const providers = { ...cfg.providers };
const oldName = editingProvider;
if (oldName && oldName !== provider.name) {
delete providers[oldName];
}
providers[provider.name] = provider;
const updatedModels = { ...cfg.models };
// Remove existing models for this provider.
for (const key of Object.keys(updatedModels)) {
if (updatedModels[key].provider === oldName) {
delete updatedModels[key];
}
}
// Add updated models, ensuring aliases are unique.
for (const m of models) {
let alias = m.alias;
let n = 1;
while (alias in updatedModels) {
alias = `${m.alias}-${n}`;
n++;
}
updatedModels[alias] = {
...m,
alias,
provider: provider.name,
max_context_size:
m.max_context_size ??
getModelRef(m.model)?.context ??
getDefaultMaxContextSize(m.model),
};
}
let default_model = cfg.default_model;
if (default_model && !(default_model in updatedModels)) {
default_model = null;
}
// Remember this provider's default model preference so it survives
// provider switches.
const updatedProvider = providers[provider.name];
if (updatedProvider && default_model && updatedModels[default_model]?.provider === provider.name) {
providers[provider.name] = setProviderDefaultModel(updatedProvider, default_model);
}
return { ...cfg, providers, models: updatedModels, default_model };
});
if (provider.name !== editingProvider) {
setEditingProvider(provider.name);
}
};
if (loading || !config) {
return (
<div className="p-4 space-y-2 text-content-primary">
<div>{t("loading")}</div>
{loadTimeout && (
<div className="text-sm text-orange-600 dark:text-orange-400">{t("loadTimeout")}</div>
)}
{error && (
<div className="text-sm text-red-400 bg-red-900/20 p-2 rounded">
<div className="font-semibold">{t("loadFailed")}</div>
<div>{error}</div>
<button
type="button"
className="mt-2 px-2 py-1 border border-red-400/30 rounded hover:bg-red-900/30"
onClick={() => refresh()}
>
{t("retry")}
</button>
</div>
)}
</div>
);
}
const currentProvider = config.providers[editingProvider];
return (
<div className="flex flex-col h-full bg-app text-content-primary">
<header className="flex items-center justify-between gap-3 px-4 py-3 border-b border-border bg-panel">
<div className="flex items-center bg-input border border-border rounded p-0.5">
<button
type="button"
onClick={() => setView("list")}
className={`px-3 py-1 text-sm rounded transition-colors ${
view === "list" || view === "edit" || view === "subagent"
? "bg-blue-600 text-white"
: "text-content-muted hover:text-content-primary"
}`}
>
{t("providers")}
</button>
<button
type="button"
onClick={() => setView("dashboard")}
className={`px-3 py-1 text-sm rounded transition-colors ${
view === "dashboard"
? "bg-blue-600 text-white"
: "text-content-muted hover:text-content-primary"
}`}
>
{t("dashboard")}
</button>
<button
type="button"
onClick={() => setView("sessions")}
className={`px-3 py-1 text-sm rounded transition-colors ${
view === "sessions"
? "bg-blue-600 text-white"
: "text-content-muted hover:text-content-primary"
}`}
>
{t("sessions")}
</button>
{/* 插件市场功能尚未成熟,导航入口暂时隐藏;页面代码保留,
恢复时把下面的按钮加回即可(view/渲染分支均未移除)。
<button
type="button"
onClick={() => setView("plugins")}
...
</button>
*/}
</div>
<div className="flex items-center gap-2">
{/* Language */}
<select
className="bg-input border border-border rounded px-2 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={lang}
onChange={(e) => setLang(e.target.value as typeof lang)}
title={t("language")}
>
<option value="zh">{t("zh")}</option>
<option value="en">{t("en")}</option>
</select>
{/* Settings gear */}
<button
type="button"
onClick={() => setShowSettings(true)}
title={t("settings")}
className="relative p-1.5 rounded border border-border hover:bg-hover-2 transition-colors"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-content-muted">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
{updateInfo?.updateAvailable && (
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-green-500 ring-1 ring-panel" />
)}
</button>
</div>
</header>
{error && (
<div role="alert" className="bg-red-900/20 text-red-400 p-2 text-sm border-b border-red-500/20">
{error}
</div>
)}
{switchMessage && (
<div className="bg-green-100 dark:bg-green-900/20 text-green-600 dark:text-green-400 p-2 text-sm border-b border-green-300 dark:border-green-500/20 text-center">
{switchMessage}
</div>
)}
<main className="flex-1 overflow-hidden relative">
{view === "dashboard" ? (
<DashboardPage />
) : view === "sessions" ? (
<SessionsPage />
) : view === "plugins" ? (
<PluginMarketplacePage />
) : view === "subagent" ? (
<SubagentSettingsPage
rawOther={config.raw_other}
models={config.models}
onChange={(nextRawOther) =>
updateConfig((cfg) => ({ ...cfg, raw_other: nextRawOther }))
}
onBack={() => setView("list")}
/>
) : view === "list" ? (
<ProviderList
providers={providers}
defaultModel={config.default_model}
models={config.models}
onEdit={(name) => {
setEditingProvider(name);
setView("edit");
}}
onDelete={handleDeleteProvider}
onDuplicate={handleDuplicateProvider}
onAdd={handleAddProvider}
onSwitchProvider={handleSwitchProvider}
onConfigureUsage={(name) => setUsageConfigProvider(name)}
agent={agent}
/>
) : currentProvider ? (
<ProviderEdit
agent={agent}
provider={currentProvider}
models={Object.values(config.models).filter(
(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)}
onModelChange={(model) => {
updateConfig((cfg) => {
const models = { ...cfg.models };
// Auto-fix the placeholder alias from onModelAdd: once the
// real model id is filled in, rename "<provider>/新模型" (and
// the mid-typing "<provider>/<prefix>" states) to
// "<provider>/<model-id>" so the CLI records usage under the
// real model name instead of the placeholder.
let effective = model;
const safeProvider = model.provider.replace(/\//g, "-");
const prefix = `${safeProvider}/`;
const modelId = model.model.trim();
const tail = model.alias.startsWith(prefix)
? model.alias.slice(prefix.length)
: "";
const targetAlias = `${prefix}${modelId}`;
if (
modelId !== "" &&
model.alias !== targetAlias &&
(tail === "新模型" ||
tail.startsWith("新模型-") ||
(tail !== "" && modelId.startsWith(tail))) &&
!(targetAlias in models)
) {
effective = { ...model, alias: targetAlias };
delete models[model.alias];
}
const existingKeys = Object.keys(models).filter(
(k) => models[k].provider === currentProvider.name
);
const oldKey = existingKeys.find((k) =>
effective.alias === k ? true : models[k].alias === effective.alias
);
if (oldKey && oldKey !== effective.alias) {
delete models[oldKey];
}
models[effective.alias] = effective;
let default_model = cfg.default_model;
if (default_model === oldKey) default_model = effective.alias;
else if (effective !== model && default_model === model.alias)
default_model = effective.alias;
return { ...cfg, models, default_model };
});
}}
onModelDelete={(alias) => {
updateConfig((cfg) => {
const models = { ...cfg.models };
delete models[alias];
return {
...cfg,
models,
default_model:
cfg.default_model === alias ? null : cfg.default_model,
};
});
}}
onModelAdd={() => {
const safeProvider = currentProvider.name.replace(/\//g, "-");
updateConfig((cfg) => {
// Deduplicate the placeholder key: a second "add model" click
// while the previous placeholder is still unedited must not
// silently overwrite it.
let alias = `${safeProvider}/新模型`;
let n = 1;
while (alias in cfg.models) {
alias = `${safeProvider}/新模型-${++n}`;
}
return {
...cfg,
models: {
...cfg.models,
[alias]: {
alias,
provider: currentProvider.name,
model: "",
max_context_size: getDefaultMaxContextSize(alias),
display_name: null,
supports_1m: false,
capabilities: agent === "kimi_code" ? ["thinking"] : [],
},
},
};
});
}}
onBulkAdd={(models) => {
updateConfig((cfg) => {
const updated = { ...cfg.models };
for (const m of models) {
// Skip if this model id already exists for the same provider
const dup = Object.values(updated).some(
(mm) => mm.provider === m.provider && mm.model === m.model && m.model !== ""
);
if (dup) continue;
let alias = m.alias;
let n = 1;
while (alias in updated) {
alias = `${m.alias}-${n}`;
n++;
}
updated[alias] = { ...m, alias };
}
return { ...cfg, models: updated };
});
}}
onSetDefault={handleSetDefaultModel}
onApplyJson={handleApplyProviderJson}
onSave={trySave}
/>
) : (
<div className="p-8 text-center text-content-muted">
<div>{t("providerNotFound")}</div>
<button
type="button"
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
onClick={() => setView("list")}
>
{t("backToList")}
</button>
</div>
)}
</main>
{view !== "dashboard" && view !== "sessions" && view !== "plugins" && (
<footer className="flex items-center justify-between px-4 py-2 border-t border-border bg-panel text-sm">
<div className="flex items-center gap-2 min-w-0">
{dirty && (
<span className="text-orange-600 dark:text-orange-400 truncate">● {t("unsavedChanges")}</span>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => { void trySave(); }}
disabled={!dirty || loading}
className={`px-3 py-1.5 text-sm rounded focus:ring-2 focus:outline-none disabled:opacity-50 ${
dirty
? "bg-blue-600 hover:bg-blue-700 text-white focus:ring-blue-500"
: "border border-border hover:bg-hover-2 focus:ring-blue-500"
}`}
>
{dirty ? `● ${t("saveConfig")}` : t("saveConfig")}
</button>
<button
type="button"
onClick={() => {
if (dirty && !confirm(t("unsavedConfirm"))) {
return;
}
refresh();
}}
disabled={loading}
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"
>
{t("reloadConfig")}
</button>
<button
type="button"
onClick={async () => {
try {
await invoke("open_agent_config_dir", { agent });
} catch (err) {
alert(err instanceof Error ? err.message : String(err));
}
}}
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("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)}
onSelect={handleSelectPreset}
onCustom={handleAddCustomProvider}
/>
{usageConfigProvider && config.providers[usageConfigProvider] && (
<UsageConfigModal
open={usageConfigProvider != null}
agent={agent}
provider={config.providers[usageConfigProvider]}
onClose={() => setUsageConfigProvider(null)}
onSave={handleSaveUsageConfig}
/>
)}
</div>
);
}