feat: update provider/model config handling
This commit is contained in:
@@ -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.
|
//! 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<PathBuf> {
|
||||||
|
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<Local> = modified.into();
|
||||||
|
if modified < cutoff {
|
||||||
|
std::fs::remove_file(&path)
|
||||||
|
.with_context(|| format!("failed to remove old backup {}", path.display()))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use indexmap::IndexMap;
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use toml::value::{Table, Value as TomlValue};
|
use toml::value::{Table, Value as TomlValue};
|
||||||
|
|
||||||
|
use crate::config_io::backup_file;
|
||||||
use crate::models::{Agent, Config, Model, Provider, ProviderType};
|
use crate::models::{Agent, Config, Model, Provider, ProviderType};
|
||||||
|
|
||||||
pub type KimiResult<T> = anyhow::Result<T>;
|
pub type KimiResult<T> = anyhow::Result<T>;
|
||||||
@@ -51,21 +52,9 @@ pub fn save_kimi_code_config(value: &TomlValue) -> KimiResult<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
let timestamp = chrono::Local::now()
|
backup_file(&path, 7).with_context(|| {
|
||||||
.format("%Y%m%d_%H%M%S_%.3f")
|
format!("failed to back up Kimi Code config {}", path.display())
|
||||||
.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()))?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = toml::to_string_pretty(value)
|
let content = toml::to_string_pretty(value)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod commands;
|
pub mod commands;
|
||||||
|
pub mod config_io;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod kimi_code_io;
|
pub mod kimi_code_io;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
|
|||||||
+7
-30
@@ -11,6 +11,7 @@ use indexmap::IndexMap;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::config_io::backup_file;
|
||||||
use crate::models::{Config, Model, Provider, ProviderType};
|
use crate::models::{Config, Model, Provider, ProviderType};
|
||||||
|
|
||||||
pub type PiResult<T> = anyhow::Result<T>;
|
pub type PiResult<T> = anyhow::Result<T>;
|
||||||
@@ -132,21 +133,9 @@ pub fn save_pi_models(file: &PiModelsFile) -> PiResult<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
let timestamp = chrono::Local::now()
|
backup_file(&path, 7).with_context(|| {
|
||||||
.format("%Y%m%d_%H%M%S_%.3f")
|
format!("failed to back up Pi models {}", path.display())
|
||||||
.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()))?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = serde_json::to_string_pretty(file)
|
let content = serde_json::to_string_pretty(file)
|
||||||
@@ -196,21 +185,9 @@ pub fn save_pi_settings(file: &PiSettingsFile) -> PiResult<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
let timestamp = chrono::Local::now()
|
backup_file(&path, 7).with_context(|| {
|
||||||
.format("%Y%m%d_%H%M%S_%.3f")
|
format!("failed to back up Pi settings {}", path.display())
|
||||||
.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()))?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = serde_json::to_string_pretty(file)
|
let content = serde_json::to_string_pretty(file)
|
||||||
|
|||||||
+74
-8
@@ -10,6 +10,30 @@ import type { Agent, Model, Provider } from "./types";
|
|||||||
|
|
||||||
const AGENT_STORAGE_KEY = "pi-switch-agent";
|
const AGENT_STORAGE_KEY = "pi-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;
|
||||||
|
}
|
||||||
|
|
||||||
const AGENTS: { key: Agent; label: string }[] = [
|
const AGENTS: { key: Agent; label: string }[] = [
|
||||||
{ key: "kimi_code", label: "Kimi Code" },
|
{ key: "kimi_code", label: "Kimi Code" },
|
||||||
{ key: "pi", label: "Pi" },
|
{ key: "pi", label: "Pi" },
|
||||||
@@ -189,7 +213,17 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSetDefaultModel = (alias: string) => {
|
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) => {
|
const handleSwitchProvider = async (name: string) => {
|
||||||
@@ -197,6 +231,16 @@ export default function App() {
|
|||||||
const target = cfg.providers[name];
|
const target = cfg.providers[name];
|
||||||
if (!target) return cfg;
|
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
|
// Only the selected provider is active. All other providers (including
|
||||||
// Kimi native/managed and custom) are deactivated so the target agent
|
// Kimi native/managed and custom) are deactivated so the target agent
|
||||||
// follows Pi Switch's choice exactly.
|
// follows Pi Switch's choice exactly.
|
||||||
@@ -205,15 +249,25 @@ export default function App() {
|
|||||||
providers[key] = { ...p, active: key === name };
|
providers[key] = { ...p, active: key === name };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set default model to the first model of the selected provider.
|
// Remember the active provider's current default model (if it belongs to
|
||||||
let default_model: string | null = null;
|
// that provider) so switching back later restores the user's choice.
|
||||||
for (const [key, m] of Object.entries(cfg.models)) {
|
if (activeProviderName && cfg.default_model) {
|
||||||
if (m.provider === name) {
|
const activeProvider = providers[activeProviderName];
|
||||||
default_model = key;
|
if (activeProvider && cfg.models[cfg.default_model]?.provider === activeProviderName) {
|
||||||
break;
|
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 };
|
return { ...cfg, providers, default_model };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -259,6 +313,13 @@ export default function App() {
|
|||||||
default_model = null;
|
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 };
|
return { ...cfg, providers, models: updatedModels, default_model };
|
||||||
});
|
});
|
||||||
if (provider.name !== editingProvider) {
|
if (provider.name !== editingProvider) {
|
||||||
@@ -391,6 +452,11 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onModelAdd={() => {
|
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}]`;
|
const alias = `新模型[${currentProvider.name}]`;
|
||||||
updateConfig((cfg) => ({
|
updateConfig((cfg) => ({
|
||||||
...cfg,
|
...cfg,
|
||||||
@@ -402,7 +468,7 @@ export default function App() {
|
|||||||
model: "",
|
model: "",
|
||||||
max_context_size: getDefaultMaxContextSize(alias),
|
max_context_size: getDefaultMaxContextSize(alias),
|
||||||
display_name: null,
|
display_name: null,
|
||||||
role: `新模型[${currentProvider.name}]`,
|
role: defaultRole,
|
||||||
supports_1m: false,
|
supports_1m: false,
|
||||||
capabilities: [],
|
capabilities: [],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -482,23 +482,23 @@ function ModelMapping({
|
|||||||
<tbody className="divide-y divide-[#2a2a2e]">
|
<tbody className="divide-y divide-[#2a2a2e]">
|
||||||
{models.map((m) => (
|
{models.map((m) => (
|
||||||
<tr key={m.alias} className="hover:bg-[#1c1c20]">
|
<tr key={m.alias} className="hover:bg-[#1c1c20]">
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<select
|
||||||
|
className="w-full bg-[#1f1f23] border border-[#2a2a2e] rounded px-2 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
|
value={m.role || "Sonnet"}
|
||||||
|
onChange={(e) => onModelChange({ ...m, role: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="Sonnet">Sonnet</option>
|
||||||
|
<option value="Opus">Opus</option>
|
||||||
|
<option value="Fable">Fable</option>
|
||||||
|
<option value="Haiku">Haiku</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<span className="text-sm text-gray-400">
|
<span className="text-sm text-gray-400">
|
||||||
{m.model ? `${m.model}[${m.provider}]` : m.alias}
|
{m.model ? `${m.model}[${m.provider}]` : m.alias}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2">
|
|
||||||
<input
|
|
||||||
className="w-full bg-transparent border border-[#2a2a2e] rounded px-2 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
||||||
value={m.display_name || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
onModelChange({
|
|
||||||
...m,
|
|
||||||
display_name: e.target.value || null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<input
|
<input
|
||||||
className="w-full bg-transparent border border-[#2a2a2e] rounded px-2 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
className="w-full bg-transparent border border-[#2a2a2e] rounded px-2 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export function ProviderList({
|
|||||||
(m) => m.provider === provider.name
|
(m) => m.provider === provider.name
|
||||||
);
|
);
|
||||||
const defaultModelName = defaultModel
|
const defaultModelName = defaultModel
|
||||||
? models[defaultModel]?.display_name || defaultModel
|
? models[defaultModel]?.model || models[defaultModel]?.display_name || defaultModel
|
||||||
: null;
|
: null;
|
||||||
const isActive = provider.active === true;
|
const isActive = provider.active === true;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user