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 | 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 { updateInfo, checking, checkNow, lastChecked } = useUpdateCheck(); const [agent] = useState(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(""); const [loadTimeout, setLoadTimeout] = useState(false); const [switchMessage, setSwitchMessage] = useState(null); const [showSettings, setShowSettings] = useState(false); const [showPresetPicker, setShowPresetPicker] = useState(false); /** Provider whose usage-config panel is open; null = closed. */ const [usageConfigProvider, setUsageConfigProvider] = useState(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>( () => 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; // 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 => { 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 = {}; 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 (
{t("loading")}
{loadTimeout && (
{t("loadTimeout")}
)} {error && (
{t("loadFailed")}
{error}
)}
); } const currentProvider = config.providers[editingProvider]; return (
{/* 插件市场功能尚未成熟,导航入口暂时隐藏;页面代码保留, 恢复时把下面的按钮加回即可(view/渲染分支均未移除)。 */}
{/* Language */} {/* Settings gear */}
{error && (
{error}
)} {switchMessage && (
{switchMessage}
)}
{view === "dashboard" ? ( ) : view === "sessions" ? ( ) : view === "plugins" ? ( ) : view === "subagent" ? ( updateConfig((cfg) => ({ ...cfg, raw_other: nextRawOther })) } onBack={() => setView("list")} /> ) : view === "list" ? ( { setEditingProvider(name); setView("edit"); }} onDelete={handleDeleteProvider} onDuplicate={handleDuplicateProvider} onAdd={handleAddProvider} onSwitchProvider={handleSwitchProvider} onConfigureUsage={(name) => setUsageConfigProvider(name)} agent={agent} /> ) : currentProvider ? ( 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 "/新模型" (and // the mid-typing "/" states) to // "/" 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} /> ) : (
{t("providerNotFound")}
)}
{view !== "dashboard" && view !== "sessions" && view !== "plugins" && (
{dirty && ( ● {t("unsavedChanges")} )}
{view === "list" && ( )}
)} setShowSettings(false)} updateInfo={updateInfo} checking={checking} onCheckUpdate={checkNow} lastChecked={lastChecked} /> setShowPresetPicker(false)} onSelect={handleSelectPreset} onCustom={handleAddCustomProvider} /> {usageConfigProvider && config.providers[usageConfigProvider] && ( setUsageConfigProvider(null)} onSave={handleSaveUsageConfig} /> )}
); }