import { useEffect, useMemo, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { useConfig } from "./hooks/useConfig"; import { ProviderList } from "./components/ProviderList"; import { ProviderEdit } from "./components/ProviderEdit"; import { DashboardPage } from "./components/dashboard/DashboardPage"; import { SessionsPage } from "./components/sessions/SessionsPage"; import { useTranslation } from "./i18n"; import { getDefaultMaxContextSize } from "./lib/model-defaults"; import { getModelRef } from "./lib/models-dev"; import type { Agent, Model, Provider } from "./types"; const AGENT_STORAGE_KEY = "kimi-switch-agent"; function getProviderDefaultModel(provider: Provider): string | null { const raw = provider.raw_other as Record | 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 | 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, 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 [agent] = useState(getInitialAgent); const { config, dirty, error, loading, refresh, save, updateConfig, } = useConfig(agent); const [view, setView] = useState<"list" | "edit" | "dashboard" | "sessions">("list"); const [editingProvider, setEditingProvider] = useState(""); const [loadTimeout, setLoadTimeout] = useState(false); const [switchMessage, setSwitchMessage] = useState(null); useEffect(() => { if (!loading) { setLoadTimeout(false); return; } const timer = setTimeout(() => setLoadTimeout(true), 5000); return () => clearTimeout(timer); }, [loading]); // 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(); save(); 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); }, [save, refresh, dirty, t, agent]); // 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]); const providers = useMemo( () => (config ? Object.values(config.providers) : []), [config] ); const handleAddProvider = () => { if (!config) return; 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, }, }, })); setEditingProvider(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 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 = {}; 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. await save(); 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 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 (
{t("loading")}
{loadTimeout && (
{t("loadTimeout")}
)} {error && (
{t("loadFailed")}
{error}
)}
); } const currentProvider = config.providers[editingProvider]; return (
{error && (
{error}
)} {switchMessage && (
{switchMessage}
)}
{view === "dashboard" ? ( ) : view === "sessions" ? ( ) : view === "list" ? ( { setEditingProvider(name); setView("edit"); }} onDelete={handleDeleteProvider} onAdd={handleAddProvider} onSwitchProvider={handleSwitchProvider} agent={agent} /> ) : currentProvider ? ( m.provider === currentProvider.name )} defaultModel={config.default_model} rawOther={config.raw_other} onRawOtherChange={(nextRawOther) => updateConfig((cfg) => ({ ...cfg, raw_other: nextRawOther })) } onBack={() => setView("list")} onChange={handleUpdateProvider} onDelete={() => handleDeleteProvider(currentProvider.name)} onModelChange={(model) => { updateConfig((cfg) => { const models = { ...cfg.models }; const existingKeys = Object.keys(models).filter( (k) => models[k].provider === currentProvider.name ); const oldKey = existingKeys.find((k) => model.alias === k ? true : models[k].alias === model.alias ); if (oldKey && oldKey !== model.alias) { delete models[oldKey]; } models[model.alias] = model; let default_model = cfg.default_model; if (default_model === oldKey) default_model = model.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 alias = `新模型[${currentProvider.name}]`; updateConfig((cfg) => ({ ...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) { 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={save} /> ) : (
{t("providerNotFound")}
)}
{view !== "dashboard" && view !== "sessions" && ( )}
); }