diff --git a/src-tauri/src/config_io.rs b/src-tauri/src/config_io.rs index 46a0f76..4699c86 100644 --- a/src-tauri/src/config_io.rs +++ b/src-tauri/src/config_io.rs @@ -1,2 +1,84 @@ -//! Configuration file I/O for KimiSwitch. +//! Configuration file I/O utilities for KimiSwitch. //! Reads and writes the global config file and profile files. + +use std::path::{Path, PathBuf}; +use anyhow::{Context, Result}; +use chrono::{Duration, Local}; + +/// Creates a backup of `path` inside a `backups/` subdirectory next to the +/// original file, then removes backup files older than `retention_days`. +pub fn backup_file(path: &Path, retention_days: i64) -> Result { + if !path.exists() { + return Ok(PathBuf::new()); + } + + let parent = path.parent().context("path has no parent directory")?; + let backups_dir = parent.join("backups"); + std::fs::create_dir_all(&backups_dir) + .with_context(|| format!("failed to create backups directory {}", backups_dir.display()))?; + + let file_name = path.file_name().context("path has no file name")?; + let timestamp = Local::now().format("%Y%m%d_%H%M%S_%.3f").to_string(); + let backup_name = format!("{}.bak.{}", file_name.to_string_lossy(), timestamp); + let mut backup_path = backups_dir.join(&backup_name); + + if backup_path.exists() { + for n in 1..1000 { + let candidate = backups_dir.join(format!( + "{}.bak.{}.{:03}", + file_name.to_string_lossy(), + timestamp, + n + )); + if !candidate.exists() { + backup_path = candidate; + break; + } + } + } + + std::fs::copy(path, &backup_path).with_context(|| { + format!( + "failed to back up {} to {}", + path.display(), + backup_path.display() + ) + })?; + + cleanup_old_backups(&backups_dir, retention_days)?; + + Ok(backup_path) +} + +/// Removes backup files in `dir` older than `retention_days`, based on file +/// modification time. Only files whose names contain `.bak.` are deleted. +fn cleanup_old_backups(dir: &Path, retention_days: i64) -> Result<()> { + let cutoff = Local::now() - Duration::days(retention_days); + let entries = std::fs::read_dir(dir) + .with_context(|| format!("failed to read backups directory {}", dir.display()))?; + + for entry in entries { + let entry = entry.context("failed to read directory entry")?; + let path = entry.path(); + if !path.is_file() { + continue; + } + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if !name.contains(".bak.") { + continue; + } + let metadata = entry + .metadata() + .with_context(|| format!("failed to read metadata for {}", path.display()))?; + let modified = metadata + .modified() + .with_context(|| format!("failed to read modified time for {}", path.display()))?; + let modified: chrono::DateTime = modified.into(); + if modified < cutoff { + std::fs::remove_file(&path) + .with_context(|| format!("failed to remove old backup {}", path.display()))?; + } + } + + Ok(()) +} diff --git a/src-tauri/src/kimi_code_io.rs b/src-tauri/src/kimi_code_io.rs index 3ccf60a..82cbcf9 100644 --- a/src-tauri/src/kimi_code_io.rs +++ b/src-tauri/src/kimi_code_io.rs @@ -11,6 +11,7 @@ use indexmap::IndexMap; use serde_json::Value; use toml::value::{Table, Value as TomlValue}; +use crate::config_io::backup_file; use crate::models::{Agent, Config, Model, Provider, ProviderType}; pub type KimiResult = anyhow::Result; @@ -51,21 +52,9 @@ pub fn save_kimi_code_config(value: &TomlValue) -> KimiResult<()> { } if path.exists() { - let timestamp = chrono::Local::now() - .format("%Y%m%d_%H%M%S_%.3f") - .to_string(); - let mut backup_path = path.with_extension(format!("toml.bak.{}", timestamp)); - if backup_path.exists() { - for n in 1..1000 { - let candidate = path.with_extension(format!("toml.bak.{}.{:03}", timestamp, n)); - if !candidate.exists() { - backup_path = candidate; - break; - } - } - } - std::fs::copy(&path, &backup_path) - .with_context(|| format!("failed to back up {} to {}", path.display(), backup_path.display()))?; + backup_file(&path, 7).with_context(|| { + format!("failed to back up Kimi Code config {}", path.display()) + })?; } let content = toml::to_string_pretty(value) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6e300f5..25d938e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ pub mod commands; +pub mod config_io; pub mod db; pub mod kimi_code_io; pub mod models; diff --git a/src-tauri/src/pi_io.rs b/src-tauri/src/pi_io.rs index 8a277fc..6fcd0fb 100644 --- a/src-tauri/src/pi_io.rs +++ b/src-tauri/src/pi_io.rs @@ -11,6 +11,7 @@ use indexmap::IndexMap; use serde::{Deserialize, Serialize}; use serde_json::Value; +use crate::config_io::backup_file; use crate::models::{Config, Model, Provider, ProviderType}; pub type PiResult = anyhow::Result; @@ -132,21 +133,9 @@ pub fn save_pi_models(file: &PiModelsFile) -> PiResult<()> { } if path.exists() { - let timestamp = chrono::Local::now() - .format("%Y%m%d_%H%M%S_%.3f") - .to_string(); - let mut backup_path = path.with_extension(format!("json.bak.{}", timestamp)); - if backup_path.exists() { - for n in 1..1000 { - let candidate = path.with_extension(format!("json.bak.{}.{:03}", timestamp, n)); - if !candidate.exists() { - backup_path = candidate; - break; - } - } - } - std::fs::copy(&path, &backup_path) - .with_context(|| format!("failed to back up {} to {}", path.display(), backup_path.display()))?; + backup_file(&path, 7).with_context(|| { + format!("failed to back up Pi models {}", path.display()) + })?; } let content = serde_json::to_string_pretty(file) @@ -196,21 +185,9 @@ pub fn save_pi_settings(file: &PiSettingsFile) -> PiResult<()> { } if path.exists() { - let timestamp = chrono::Local::now() - .format("%Y%m%d_%H%M%S_%.3f") - .to_string(); - let mut backup_path = path.with_extension(format!("json.bak.{}", timestamp)); - if backup_path.exists() { - for n in 1..1000 { - let candidate = path.with_extension(format!("json.bak.{}.{:03}", timestamp, n)); - if !candidate.exists() { - backup_path = candidate; - break; - } - } - } - std::fs::copy(&path, &backup_path) - .with_context(|| format!("failed to back up {} to {}", path.display(), backup_path.display()))?; + backup_file(&path, 7).with_context(|| { + format!("failed to back up Pi settings {}", path.display()) + })?; } let content = serde_json::to_string_pretty(file) diff --git a/src/App.tsx b/src/App.tsx index 9c4b09d..8d8b12a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,30 @@ import type { Agent, Model, Provider } from "./types"; const AGENT_STORAGE_KEY = "pi-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; +} + const AGENTS: { key: Agent; label: string }[] = [ { key: "kimi_code", label: "Kimi Code" }, { key: "pi", label: "Pi" }, @@ -189,7 +213,17 @@ export default function App() { }; const handleSetDefaultModel = (alias: string) => { - updateConfig((cfg) => ({ ...cfg, default_model: alias })); + 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) => { @@ -197,6 +231,16 @@ export default function App() { 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; + } + } + // Only the selected provider is active. All other providers (including // Kimi native/managed and custom) are deactivated so the target agent // follows Pi Switch's choice exactly. @@ -205,15 +249,25 @@ export default function App() { providers[key] = { ...p, active: key === name }; } - // Set default model to the first model of the selected provider. - let default_model: string | null = null; - for (const [key, m] of Object.entries(cfg.models)) { - if (m.provider === name) { - default_model = key; - break; + // Remember the active provider's current default model (if it belongs to + // that provider) so switching back later restores the user's choice. + if (activeProviderName && cfg.default_model) { + const activeProvider = providers[activeProviderName]; + if (activeProvider && cfg.models[cfg.default_model]?.provider === activeProviderName) { + providers[activeProviderName] = setProviderDefaultModel( + activeProvider, + cfg.default_model + ); } } + // 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 }; }); @@ -259,6 +313,13 @@ export default function App() { 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) { @@ -391,6 +452,11 @@ export default function App() { }); }} onModelAdd={() => { + const providerModels = Object.values(config.models).filter( + (m) => m.provider === currentProvider.name + ); + const roles = ["Sonnet", "Opus", "Fable", "Haiku"]; + const defaultRole = roles[providerModels.length % roles.length]; const alias = `新模型[${currentProvider.name}]`; updateConfig((cfg) => ({ ...cfg, @@ -402,7 +468,7 @@ export default function App() { model: "", max_context_size: getDefaultMaxContextSize(alias), display_name: null, - role: `新模型[${currentProvider.name}]`, + role: defaultRole, supports_1m: false, capabilities: [], }, diff --git a/src/components/ProviderEdit.tsx b/src/components/ProviderEdit.tsx index bb7411f..94b2d1f 100644 --- a/src/components/ProviderEdit.tsx +++ b/src/components/ProviderEdit.tsx @@ -482,23 +482,23 @@ function ModelMapping({ {models.map((m) => ( + + + {m.model ? `${m.model}[${m.provider}]` : m.alias} - - - onModelChange({ - ...m, - display_name: e.target.value || null, - }) - } - /> - m.provider === provider.name ); const defaultModelName = defaultModel - ? models[defaultModel]?.display_name || defaultModel + ? models[defaultModel]?.model || models[defaultModel]?.display_name || defaultModel : null; const isActive = provider.active === true;