feat: implement Pi Switch i18n UI and agent config switch

This commit is contained in:
KimiSwitch Dev
2026-07-08 08:53:26 +08:00
parent 7d69aac08f
commit af991a7bda
86 changed files with 4214 additions and 235 deletions
+2
View File
@@ -1,6 +1,8 @@
node_modules/ node_modules/
dist/ dist/
src-tauri/target/ src-tauri/target/
src-tauri/gen/
src-tauri/wix314-binaries/
.vscode/ .vscode/
*.log *.log
.env* .env*
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1,270 @@
# i18n / Agent Switch / UI Cleanup Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Complete Chinese/English i18n, replace the target dropdown with a CC Switch-style icon selector, clean up the header/footer layout, fix backend validation so managed providers and half-empty models dont block saving, and ensure the app only fetches model lists on explicit user action.
**Architecture:** Keep the existing Rust backend commands and `ConfigTarget` abstraction. Front-end state stays in `App.tsx`; UI pieces live in `src/components/`. Validation/sanitization moves into a small Rust helper that normalizes config before validation, so legacy or in-progress configs can be saved without surprising the user.
**Tech Stack:** Tauri v2 (Rust), React + Tailwind CSS, TypeScript, `toml_edit`, `serde_yaml`.
---
### Task 1: Fix backend validation / sanitization
**Files:**
- Modify: `src-tauri/src/validators.rs`
- Modify: `src-tauri/src/commands.rs`
- Test: `src-tauri/tests/validators.rs`
- [ ] **Step 1.1: Treat `managed:*` providers as managed**
In `validators.rs`, add a helper and use it in the credential check:
```rust
fn is_managed_provider(provider: &Provider) -> bool {
provider.managed || provider.name.starts_with("managed:")
}
```
Replace the credential guard condition:
```rust
if !is_managed_provider(provider) && !matches!(provider.provider_type, ProviderType::Vertexai) {
// existing ambiguous / missing checks
}
```
- [ ] **Step 1.2: Sanitize config before validation**
In `commands.rs`, add a `sanitize_config(config: &mut Config)` function before `validate_config` is called in `save_config_command`:
```rust
fn sanitize_config(config: &mut Config) {
// Managed:* providers should carry the managed flag.
for provider in config.providers.values_mut() {
if provider.name.starts_with("managed:") {
provider.managed = true;
}
}
let first_provider = config.providers.keys().next().cloned();
for model in config.models.values_mut() {
if model.provider.trim().is_empty() {
if let Some(name) = &first_provider {
model.provider = name.clone();
}
}
if model.model.trim().is_empty() {
model.model = model.alias.clone();
}
if model.max_context_size == 0 {
model.max_context_size = 262_144;
}
}
}
```
Call it at the top of `save_config_command`:
```rust
pub fn save_config_command(
app: tauri::AppHandle,
target: ConfigTarget,
mut config: Config,
) -> Result<(), String> {
sanitize_config(&mut config);
if let Err(errors) = validate_config(&config) { ... }
...
}
```
- [ ] **Step 1.3: Add/update tests**
Add tests in `src-tauri/tests/validators.rs` for:
1. `managed:kimi-code` with empty credentials passes.
2. `glm-5` with empty provider/model/max_context_size is sanitized and then passes.
Run: `cd src-tauri && cargo test`
Expected: all tests pass.
---
### Task 2: Remove implicit model auto-fetch
**Files:**
- Modify: `src/components/ProviderEdit.tsx`
- [ ] **Step 2.1: Delete the auto-fetch `useEffect`**
Remove:
```tsx
const [autoFetched, setAutoFetched] = useState(false);
useEffect(() => {
if (models.length === 0 && !autoFetched && !discovering) {
setAutoFetched(true);
handleDiscover();
}
}, [models.length, autoFetched, discovering, handleDiscover]);
```
Keep the explicit `获取模型列表` button and the discovered-models panel. Models are now fetched only when the user clicks the button.
---
### Task 3: Redesign header and footer
**Files:**
- Modify: `src/App.tsx`
- Modify: `src/i18n/zh.ts`
- Modify: `src/i18n/en.ts`
- Modify: `src/index.css` (if needed for footer)
- [ ] **Step 3.1: Replace target dropdown with icon selector**
In `App.tsx`, replace the `<select>` for `target` with a row of icon buttons:
```tsx
function AgentSelector({ target, onChange }: { target: ConfigTarget; onChange: (t: ConfigTarget) => void }) {
const { t } = useTranslation();
const agents: { key: ConfigTarget; label: string; icon: string }[] = [
{ key: "kimi", label: t("targetKimi"), icon: "K" },
{ key: "omp", label: t("targetOmp"), icon: "O" },
];
return (
<div className="flex items-center gap-1 bg-[#1f1f23] border border-[#2a2a2e] rounded-lg p-1">
{agents.map((a) => (
<button
key={a.key}
type="button"
title={a.label}
onClick={() => onChange(a.key)}
className={`flex items-center gap-2 px-3 py-1.5 text-sm rounded-md transition-colors ${
target === a.key
? "bg-blue-600 text-white"
: "text-gray-400 hover:text-[#e5e5e7] hover:bg-[#2a2a2e]"
}`}
>
<span className="w-5 h-5 rounded-full bg-current/20 flex items-center justify-center text-xs font-bold">
{a.icon}
</span>
{a.label}
</button>
))}
</div>
);
}
```
Use `<AgentSelector target={target} onChange={handleSetTarget} />` in the header.
- [ ] **Step 3.2: Move global actions to a footer bar**
Render a footer below `<main>`:
```tsx
<footer className="flex items-center justify-between px-4 py-2 border-t border-[#2a2a2e] bg-[#16161a] text-sm">
<div className="flex items-center gap-2">
{dirty && <span className="text-orange-400"> {t("unsavedChanges")}</span>}
</div>
<div className="flex items-center gap-2">
<button ... onClick={save} disabled={!dirty}>{t("saveConfig")}</button>
<button ... onClick={refresh}>{t("reloadConfig")}</button>
<button ... onClick={openConfigDir}>{t("openConfigDir")}</button>
</div>
</footer>
```
Remove the corresponding buttons from the top header.
- [ ] **Step 3.3: Keep language switch in header**
Keep the language `<select>` in the top-right of the header, next to the agent selector.
- [ ] **Step 3.4: Add missing translation keys**
Add `unsavedChanges: "未保存修改"` / `"Unsaved changes"` to both translation files.
---
### Task 4: Improve adaptive / maximize-friendly layout
**Files:**
- Modify: `src/components/ProviderList.tsx`
- Modify: `src/components/ProviderEdit.tsx`
- [ ] **Step 4.1: ProviderList cards adapt to width**
Ensure the list container is `flex-1 overflow-auto` and cards use `w-full`. The right-side action buttons should wrap on narrow widths by adding `flex-wrap` to their container.
- [ ] **Step 4.2: ProviderEdit table scrolls horizontally**
Wrap the model mapping table in a `div` with `overflow-auto`:
```tsx
<div className="overflow-auto">
<table className="w-full min-w-[640px] text-sm">...</table>
</div>
```
Change the two-column basic-info grid to collapse on small widths by using `grid-cols-1 md:grid-cols-2`.
---
### Task 5: Complete zh/en i18n
**Files:**
- Modify: `src/i18n/zh.ts`
- Modify: `src/i18n/en.ts`
- [ ] **Step 5.1: Audit for hardcoded strings**
Search `src/components/` and `src/App.tsx` for any literal Chinese or English UI text not using `t()`. Add keys and replace them. Ensure keys added to both `zh.ts` and `en.ts`.
---
### Task 6: Rename / update logo and icons
**Files:**
- Modify: `index.html`
- Create/overwrite: `public/pi.svg`
- Create/overwrite: `src-tauri/icons/32x32.png`, `src-tauri/icons/128x128.png`, `src-tauri/icons/128x128@2x.png`, `src-tauri/icons/icon.ico`
- [ ] **Step 6.1: Generate Pi Switch icons**
Use a Python script (Pillow) to generate PNG/ICO files with a π symbol. Update `index.html` favicon to `/pi.svg`.
- [ ] **Step 6.2: Verify app title**
`tauri.conf.json` already has `"title": "Pi Switch"`. Ensure `index.html` `<title>` is also `Pi Switch`.
---
### Task 7: Build and run
**Files:** N/A
- [ ] **Step 7.1: Run frontend build**
`npm run build`
Expected: no TypeScript or build errors.
- [ ] **Step 7.2: Run Rust tests**
`cd src-tauri && cargo test`
Expected: all tests pass.
- [ ] **Step 7.3: Start dev app**
`npm run tauri-dev`
Expected: app window opens, agent switch works, save/reload/open buttons work, model fetch is manual.
---
### Self-Review
- **Spec coverage:** managed-provider error → Task 1.1; glm-5 empty fields → Task 1.2; manual model fetch → Task 2; CC Switch icon selector → Task 3.1; footer action placement → Task 3.2; adaptive layout → Task 4; i18n → Task 5; logo/title → Task 6.
- **Placeholder scan:** no TBD/TODO/fill-in details; each step has file paths and code.
- **Type consistency:** `ConfigTarget` values remain `"kimi"` / `"omp"`; translation keys match existing `TranslationKey` type.
+2 -2
View File
@@ -2,9 +2,9 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/pi.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>KimiSwitch</title> <title>Pi Switch</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+2 -2
View File
@@ -1,11 +1,11 @@
{ {
"name": "kimiswitch", "name": "piswitch",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "kimiswitch", "name": "piswitch",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "kimiswitch", "name": "piswitch",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#3b82f6"/>
<stop offset="100%" stop-color="#8b5cf6"/>
</linearGradient>
</defs>
<circle cx="50" cy="50" r="48" fill="url(#g)"/>
<text x="50" y="66" text-anchor="middle" font-size="50" fill="white" font-family="system-ui, -apple-system, sans-serif">π</text>
</svg>

After

Width:  |  Height:  |  Size: 444 B

+82
View File
@@ -0,0 +1,82 @@
from PIL import Image, ImageDraw, ImageFont
import os
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT_DIR = os.path.join(ROOT, "src-tauri", "icons")
SVG_PATH = os.path.join(ROOT, "public", "pi.svg")
os.makedirs(OUT_DIR, exist_ok=True)
os.makedirs(os.path.dirname(SVG_PATH), exist_ok=True)
def draw_icon(size):
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# Gradient-ish circular background: blue -> purple
for y in range(size):
ratio = y / size
r = int(59 + (139 - 59) * ratio)
g = int(130 + (92 - 130) * ratio)
b = int(246 + (246 - 246) * ratio)
draw.line([(0, y), (size, y)], fill=(r, g, b, 255))
# Clip to circle
mask = Image.new("L", (size, size), 0)
mask_draw = ImageDraw.Draw(mask)
mask_draw.ellipse((0, 0, size, size), fill=255)
img.putalpha(mask)
# Draw π symbol
font_size = int(size * 0.55)
try:
font = ImageFont.truetype("segoeui.ttf", font_size)
except Exception:
try:
font = ImageFont.truetype("arial.ttf", font_size)
except Exception:
font = ImageFont.load_default()
draw = ImageDraw.Draw(img)
text = "π"
bbox = draw.textbbox((0, 0), text, font=font)
text_w = bbox[2] - bbox[0]
text_h = bbox[3] - bbox[1]
x = (size - text_w) // 2 - bbox[0]
y = (size - text_h) // 2 - bbox[1]
draw.text((x, y), text, font=font, fill=(255, 255, 255, 255))
return img
sizes = [32, 128, 256]
for s in sizes:
img = draw_icon(s)
if s == 128:
img.save(os.path.join(OUT_DIR, "128x128.png"))
img2x = draw_icon(256)
img2x.save(os.path.join(OUT_DIR, "128x128@2x.png"))
elif s == 32:
img.save(os.path.join(OUT_DIR, "32x32.png"))
# Multi-size ICO
ico_sizes = [(32, 32), (64, 64), (128, 128), (256, 256)]
imgs = [draw_icon(s[0]) for s in ico_sizes]
imgs[0].save(
os.path.join(OUT_DIR, "icon.ico"),
format="ICO",
sizes=ico_sizes,
)
# Simple SVG favicon for the web
svg = '''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#3b82f6"/>
<stop offset="100%" stop-color="#8b5cf6"/>
</linearGradient>
</defs>
<circle cx="50" cy="50" r="48" fill="url(#g)"/>
<text x="50" y="66" text-anchor="middle" font-size="50" fill="white" font-family="system-ui, -apple-system, sans-serif">π</text>
</svg>'''
with open(SVG_PATH, "w", encoding="utf-8") as f:
f.write(svg)
print("Icons generated.")
+715 -126
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -1,7 +1,7 @@
[package] [package]
name = "kimiswitch" name = "kimiswitch"
version = "0.1.0" version = "0.1.0"
description = "Kimi Code CLI config manager" description = "Pi Switch - model config manager"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
@@ -10,15 +10,15 @@ tauri-build = { version = "2.0.0", features = [] }
[dependencies] [dependencies]
tauri = { version = "2.0.0", features = [] } tauri = { version = "2.0.0", features = [] }
tauri-plugin-shell = "2.0.0" tauri-plugin-opener = "2.0.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
toml_edit = "0.22"
indexmap = { version = "2.2", features = ["serde"] } indexmap = { version = "2.2", features = ["serde"] }
dirs = "5.0"
thiserror = "1.0"
chrono = "0.4" chrono = "0.4"
anyhow = "1.0" anyhow = "1.0"
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
dirs = "5.0"
toml = "0.8"
[lib] [lib]
name = "kimiswitch_lib" name = "kimiswitch_lib"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+12
View File
@@ -0,0 +1,12 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capabilities for KimiSwitch",
"windows": ["main"],
"permissions": [
"core:default",
"opener:allow-open-path",
"opener:allow-open-url",
"opener:allow-reveal-item-in-dir"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 654 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 874 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 980 B

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 993 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 987 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 506 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 810 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 810 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 634 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 810 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

+227 -50
View File
@@ -1,66 +1,243 @@
use crate::models::{Config, ProfileSummary}; use crate::models::{Agent, Config, DiscoveredModel, Provider, ProviderType};
use crate::pi_io;
use tauri_plugin_opener::OpenerExt;
fn fmt_anyhow(err: anyhow::Error) -> String {
err.chain()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join(": ")
}
#[tauri::command] #[tauri::command]
pub fn load_config_command() -> Config { pub fn debug_log(message: String) {
Config { eprintln!("[frontend] {}", message);
default_model: None, }
providers: indexmap::IndexMap::new(),
models: indexmap::IndexMap::new(), #[tauri::command]
raw_other: toml_edit::Table::new(), pub fn load_agent_config_command(agent: Agent) -> Result<Config, String> {
match agent {
Agent::KimiCode => crate::kimi_code_io::load_kimi_code_config_as_config()
.map_err(fmt_anyhow),
Agent::Pi => {
let file = pi_io::load_pi_models().map_err(fmt_anyhow)?;
Ok(pi_io::pi_file_to_config(&file))
}
} }
} }
#[tauri::command] #[tauri::command]
pub fn save_config_command(_config: Config) -> Result<(), String> { pub fn save_agent_config_command(agent: Agent, config: Config) -> Result<(), String> {
Ok(()) match agent {
} Agent::KimiCode => crate::kimi_code_io::save_config_as_kimi_code(&config).map_err(fmt_anyhow),
Agent::Pi => {
#[tauri::command] let file = pi_io::config_to_pi_file(&config);
pub fn list_profiles() -> Vec<ProfileSummary> { pi_io::save_pi_models(&file).map_err(fmt_anyhow)
Vec::new() }
}
#[tauri::command]
pub fn load_profile(_filename: String) -> Config {
Config {
default_model: None,
providers: indexmap::IndexMap::new(),
models: indexmap::IndexMap::new(),
raw_other: toml_edit::Table::new(),
} }
} }
#[tauri::command] #[tauri::command]
pub fn save_profile(_filename: String, _config: Config) -> Result<(), String> { pub fn open_agent_config_dir(app: tauri::AppHandle, agent: Agent) -> Result<(), String> {
Ok(()) let path = agent.config_dir();
} std::fs::create_dir_all(&path).map_err(|e| e.to_string())?;
let path_str = path.to_string_lossy().to_string();
#[tauri::command] app.opener()
pub fn switch_profile(_filename: String) -> Result<Config, String> { .open_path(&path_str, None::<&str>)
Ok(Config { .map_err(|e| e.to_string())
default_model: None,
providers: indexmap::IndexMap::new(),
models: indexmap::IndexMap::new(),
raw_other: toml_edit::Table::new(),
})
}
#[tauri::command]
pub fn rename_profile(_old_filename: String, _new_name: String) -> Result<String, String> {
Ok(String::new())
}
#[tauri::command]
pub fn delete_profile(_filename: String) -> Result<(), String> {
Ok(())
}
#[tauri::command]
pub fn open_config_dir() -> Result<(), String> {
Ok(())
} }
#[tauri::command] #[tauri::command]
pub fn get_app_version() -> String { pub fn get_app_version() -> String {
env!("CARGO_PKG_VERSION").to_string() env!("CARGO_PKG_VERSION").to_string()
} }
/// Fetch available models from a provider's API.
#[tauri::command]
pub async fn list_provider_models(provider: Provider) -> Result<Vec<DiscoveredModel>, String> {
let api_key = resolve_api_key(&provider)
.ok_or_else(|| format!("Provider '{}' has no API key configured", provider.name))?;
let base = resolve_base_url(&provider);
match provider.provider_type {
ProviderType::Kimi | ProviderType::Openai | ProviderType::OpenaiResponses => {
fetch_openai_models(&base, &api_key).await
}
ProviderType::Anthropic => fetch_anthropic_models(&base, &api_key).await,
ProviderType::GoogleGenai => fetch_google_genai_models(&base, &api_key).await,
ProviderType::Vertexai => Err("Vertex AI model discovery requires GCP project/location configuration and is not yet supported".to_string()),
}
}
fn resolve_api_key(provider: &Provider) -> Option<String> {
if provider.managed {
return Some("managed".to_string());
}
if let Some(key) = &provider.api_key {
if !key.is_empty() {
return Some(key.clone());
}
}
let env_key = expected_api_key_key(&provider.provider_type);
provider.env.get(env_key).filter(|s| !s.is_empty()).cloned()
}
fn resolve_base_url(provider: &Provider) -> String {
provider
.base_url
.clone()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| {
provider
.provider_type
.default_base_url()
.unwrap_or("")
.to_string()
})
}
fn expected_api_key_key(provider_type: &ProviderType) -> &'static str {
match provider_type {
ProviderType::Kimi => "KIMI_API_KEY",
ProviderType::Anthropic => "ANTHROPIC_API_KEY",
ProviderType::Openai | ProviderType::OpenaiResponses => "OPENAI_API_KEY",
ProviderType::GoogleGenai => "GOOGLE_API_KEY",
ProviderType::Vertexai => "VERTEXAI_API_KEY",
}
}
// ── OpenAI-compatible /models endpoint ──────────────────────────────
async fn fetch_openai_models(base: &str, api_key: &str) -> Result<Vec<DiscoveredModel>, String> {
let url = format!("{}/models", base.trim_end_matches('/'));
let resp = reqwest::Client::new()
.get(&url)
.bearer_auth(api_key)
.send()
.await
.map_err(|e| format!("HTTP request to {} failed: {e}", url))?;
if !resp.status().is_success() {
return Err(format!("{} returned HTTP {}", url, resp.status()));
}
#[derive(serde::Deserialize)]
struct OaiModel {
id: String,
}
#[derive(serde::Deserialize)]
struct OaiList {
data: Vec<OaiModel>,
}
let body: OaiList = resp
.json()
.await
.map_err(|e| format!("failed to parse response from {}: {e}", url))?;
Ok(body
.data
.into_iter()
.map(|m| DiscoveredModel {
id: m.id,
display_name: None,
max_context_size: None,
})
.collect())
}
// ── Anthropic /v1/models endpoint ───────────────────────────────────
async fn fetch_anthropic_models(base: &str, api_key: &str) -> Result<Vec<DiscoveredModel>, String> {
let url = format!("{}/v1/models", base.trim_end_matches('/'));
let resp = reqwest::Client::new()
.get(&url)
.header("x-api-key", api_key)
.header("anthropic-version", "2023-06-01")
.send()
.await
.map_err(|e| format!("HTTP request to {} failed: {e}", url))?;
if !resp.status().is_success() {
return Err(format!("{} returned HTTP {}", url, resp.status()));
}
#[derive(serde::Deserialize)]
struct AntModel {
id: String,
display_name: Option<String>,
}
#[derive(serde::Deserialize)]
struct AntList {
data: Vec<AntModel>,
}
let body: AntList = resp
.json()
.await
.map_err(|e| format!("failed to parse response from {}: {e}", url))?;
Ok(body
.data
.into_iter()
.map(|m| DiscoveredModel {
id: m.id,
display_name: m.display_name,
max_context_size: None,
})
.collect())
}
// ── Google GenAI /v1beta/models endpoint ────────────────────────────
async fn fetch_google_genai_models(
base: &str,
api_key: &str,
) -> Result<Vec<DiscoveredModel>, String> {
let url = format!(
"{}/v1beta/models?key={}",
base.trim_end_matches('/'),
api_key
);
let resp = reqwest::Client::new()
.get(&url)
.send()
.await
.map_err(|e| format!("HTTP request to {} failed: {e}", url))?;
if !resp.status().is_success() {
return Err(format!("{} returned HTTP {}", url, resp.status()));
}
#[derive(serde::Deserialize)]
struct GglModel {
name: String,
#[serde(rename = "displayName")]
display_name: Option<String>,
#[serde(rename = "outputTokenLimit")]
output_token_limit: Option<u64>,
}
#[derive(serde::Deserialize)]
struct GglList {
models: Vec<GglModel>,
}
let body: GglList = resp
.json()
.await
.map_err(|e| format!("failed to parse response from {}: {e}", url))?;
Ok(body
.models
.into_iter()
.map(|m| {
let id = m.name.strip_prefix("models/").unwrap_or(&m.name).to_string();
DiscoveredModel {
id,
display_name: m.display_name,
max_context_size: m.output_token_limit,
}
})
.collect())
}
+503
View File
@@ -0,0 +1,503 @@
//! Kimi Code configuration I/O.
//!
//! Kimi Code stores its configuration in `~/.kimi-code/config.toml`.
//! This module reads/writes that file and converts between Kimi's TOML
//! format and Pi Switch's internal `Config`/`Provider`/`Model` types.
use std::path::PathBuf;
use anyhow::Context;
use indexmap::IndexMap;
use serde_json::Value;
use toml::value::{Table, Value as TomlValue};
use crate::models::{Agent, Config, Model, Provider, ProviderType};
pub type KimiResult<T> = anyhow::Result<T>;
pub fn kimi_code_config_dir() -> PathBuf {
dirs::home_dir()
.map(|h| h.join(".kimi-code"))
.expect("failed to resolve home directory")
}
pub fn kimi_code_config_path() -> PathBuf {
kimi_code_config_dir().join("config.toml")
}
/// Read the existing Kimi Code `config.toml`, or return an empty table if it
/// does not exist yet.
pub fn load_kimi_code_config() -> KimiResult<TomlValue> {
let path = kimi_code_config_path();
if !path.exists() {
return Ok(TomlValue::Table(Table::new()));
}
let content = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
let value: TomlValue = content
.parse()
.with_context(|| format!("failed to parse {}", path.display()))?;
Ok(value)
}
/// Write the given TOML value to Kimi Code's config file, creating the
/// directory if needed and backing up the previous file.
pub fn save_kimi_code_config(value: &TomlValue) -> KimiResult<()> {
let path = kimi_code_config_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create directory {}", parent.display()))?;
}
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()))?;
}
let content = toml::to_string_pretty(value)
.with_context(|| "failed to serialize Kimi Code config.toml")?;
std::fs::write(&path, content)
.with_context(|| format!("failed to write {}", path.display()))?;
Ok(())
}
/// Import a Kimi Code TOML config into Pi Switch's internal `Config`.
pub fn kimi_code_to_config(value: &TomlValue) -> Config {
let root = value.as_table().cloned().unwrap_or_default();
let default_model = root
.get("default_model")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let mut providers = IndexMap::new();
let mut models = IndexMap::new();
if let Some(providers_table) = root.get("providers").and_then(|v| v.as_table()) {
for (name, pv) in providers_table {
let table = pv.as_table().cloned().unwrap_or_default();
let provider_type = table
.get("type")
.and_then(|v| v.as_str())
.map(provider_type_for_kimi_type)
.unwrap_or(ProviderType::Kimi);
let base_url = table.get("base_url").and_then(|v| v.as_str()).map(|s| s.to_string());
let api_key = table.get("api_key").and_then(|v| v.as_str()).map(|s| s.to_string());
let managed = table.contains_key("oauth")
|| table.get("managed").and_then(|v| v.as_bool()).unwrap_or(false);
let enabled = table.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
let raw_other = {
let mut rest = table.clone();
rest.remove("type");
rest.remove("base_url");
rest.remove("api_key");
rest.remove("managed");
rest.remove("enabled");
rest.remove("oauth");
toml_value_to_json(&TomlValue::Table(rest))
};
providers.insert(
name.clone(),
Provider {
name: name.clone(),
provider_type,
base_url: base_url.filter(|s| !s.is_empty()),
api_key: api_key.filter(|s| !s.is_empty()),
env: IndexMap::new(),
note: None,
official_url: None,
managed,
enabled,
raw_other,
},
);
}
}
if let Some(models_table) = root.get("models").and_then(|v| v.as_table()) {
for (alias, mv) in models_table {
let table = mv.as_table().cloned().unwrap_or_default();
let provider = table
.get("provider")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_default();
let model_id = table
.get("model")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_default();
let max_context_size = table
.get("max_context_size")
.and_then(|v| v.as_integer())
.map(|n| n as u64)
.unwrap_or(128_000);
let display_name = table
.get("display_name")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let capabilities: Vec<String> = table
.get("capabilities")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
let supports_1m = capabilities.iter().any(|c| c == "thinking")
|| max_context_size >= 1_000_000;
let raw_other = {
let mut rest = table.clone();
rest.remove("provider");
rest.remove("model");
rest.remove("max_context_size");
rest.remove("display_name");
rest.remove("capabilities");
toml_value_to_json(&TomlValue::Table(rest))
};
models.insert(
alias.clone(),
Model {
alias: alias.clone(),
provider,
model: model_id,
max_context_size,
display_name,
role: None,
supports_1m,
capabilities,
raw_other,
},
);
}
}
let raw_other = {
let mut rest = root;
rest.remove("default_model");
rest.remove("providers");
rest.remove("models");
toml_value_to_json(&TomlValue::Table(rest))
};
Config {
default_model,
providers,
models,
raw_other,
}
}
/// Export Pi Switch's internal `Config` to a Kimi Code TOML config value.
///
/// If `existing` is provided, unknown top-level sections (e.g. `services`) are
/// preserved; otherwise a fresh TOML table is used.
pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> TomlValue {
let mut root = existing
.and_then(|v| v.as_table().cloned())
.unwrap_or_default();
root.insert(
"default_model".to_string(),
config
.default_model
.clone()
.map(TomlValue::String)
.unwrap_or(TomlValue::String("".to_string())),
);
let mut providers_table = Table::new();
for (name, provider) in &config.providers {
let mut pt = Table::new();
pt.insert("type".to_string(), TomlValue::String(provider.provider_type.as_str().to_string()));
if let Some(base_url) = provider.base_url.clone().filter(|s| !s.is_empty()) {
pt.insert("base_url".to_string(), TomlValue::String(base_url));
}
if let Some(api_key) = provider.api_key.clone().filter(|s| !s.is_empty()) {
pt.insert("api_key".to_string(), TomlValue::String(api_key));
}
pt.insert("enabled".to_string(), TomlValue::Boolean(provider.enabled));
if provider.managed {
// Preserve existing oauth config if present, otherwise create a default entry.
let oauth = provider
.raw_other
.get("oauth")
.and_then(json_to_toml)
.unwrap_or_else(|| {
let mut t = Table::new();
t.insert("storage".to_string(), TomlValue::String("file".to_string()));
t.insert("key".to_string(), TomlValue::String(format!("oauth/{}", name.replace(':', "-"))));
TomlValue::Table(t)
});
pt.insert("oauth".to_string(), oauth);
}
// Merge remaining raw fields (oauth is handled explicitly above).
if let TomlValue::Table(mut extra) = json_to_toml(&provider.raw_other).unwrap_or(TomlValue::Table(Table::new())) {
extra.remove("oauth");
for (k, v) in extra {
pt.insert(k, v);
}
}
providers_table.insert(name.clone(), TomlValue::Table(pt));
}
root.insert("providers".to_string(), TomlValue::Table(providers_table));
let mut models_table = Table::new();
for (alias, model) in &config.models {
let mut mt = Table::new();
mt.insert("provider".to_string(), TomlValue::String(model.provider.clone()));
mt.insert("model".to_string(), TomlValue::String(model.model.clone()));
mt.insert(
"max_context_size".to_string(),
TomlValue::Integer(model.max_context_size as i64),
);
if let Some(display_name) = model.display_name.clone().filter(|s| !s.is_empty()) {
mt.insert("display_name".to_string(), TomlValue::String(display_name));
}
let mut capabilities = model.capabilities.clone();
if model.supports_1m && !capabilities.iter().any(|c| c == "thinking") {
capabilities.push("thinking".to_string());
}
if !capabilities.is_empty() {
mt.insert(
"capabilities".to_string(),
TomlValue::Array(capabilities.into_iter().map(TomlValue::String).collect()),
);
}
if let TomlValue::Table(mut extra) = json_to_toml(&model.raw_other).unwrap_or(TomlValue::Table(Table::new())) {
extra.remove("provider");
extra.remove("model");
extra.remove("max_context_size");
extra.remove("display_name");
extra.remove("capabilities");
for (k, v) in extra {
mt.insert(k, v);
}
}
models_table.insert(alias.clone(), TomlValue::Table(mt));
}
root.insert("models".to_string(), TomlValue::Table(models_table));
TomlValue::Table(root)
}
fn provider_type_for_kimi_type(typ: &str) -> ProviderType {
match typ {
"anthropic" => ProviderType::Anthropic,
"openai" => ProviderType::Openai,
"openai_responses" => ProviderType::OpenaiResponses,
"google-genai" => ProviderType::GoogleGenai,
"vertexai" => ProviderType::Vertexai,
_ => ProviderType::Kimi,
}
}
fn toml_value_to_json(value: &TomlValue) -> Value {
match value {
TomlValue::String(s) => Value::String(s.clone()),
TomlValue::Integer(n) => Value::Number((*n).into()),
TomlValue::Float(n) => Value::Number(serde_json::Number::from_f64(*n).unwrap_or(Value::Null.as_u64().unwrap_or(0).into())),
TomlValue::Boolean(b) => Value::Bool(*b),
TomlValue::Array(arr) => Value::Array(arr.iter().map(toml_value_to_json).collect()),
TomlValue::Table(t) => {
let mut map = serde_json::Map::new();
for (k, v) in t {
map.insert(k.clone(), toml_value_to_json(v));
}
Value::Object(map)
}
TomlValue::Datetime(dt) => Value::String(dt.to_string()),
}
}
fn json_to_toml(value: &Value) -> Option<TomlValue> {
match value {
Value::Null => None,
Value::Bool(b) => Some(TomlValue::Boolean(*b)),
Value::Number(n) => {
if let Some(i) = n.as_i64() {
Some(TomlValue::Integer(i))
} else if let Some(f) = n.as_f64() {
Some(TomlValue::Float(f))
} else {
None
}
}
Value::String(s) => Some(TomlValue::String(s.clone())),
Value::Array(arr) => Some(TomlValue::Array(
arr.iter().filter_map(json_to_toml).collect(),
)),
Value::Object(obj) => {
let mut t = Table::new();
for (k, v) in obj {
if let Some(tv) = json_to_toml(v) {
t.insert(k.clone(), tv);
}
}
Some(TomlValue::Table(t))
}
}
}
/// Convenience entry point used by commands: load and convert.
pub fn load_kimi_code_config_as_config() -> KimiResult<Config> {
let value = load_kimi_code_config()?;
Ok(kimi_code_to_config(&value))
}
/// Convenience entry point used by commands: convert and save.
pub fn save_config_as_kimi_code(config: &Config) -> KimiResult<()> {
let existing = load_kimi_code_config().ok();
let value = config_to_kimi_code(config, existing.as_ref());
save_kimi_code_config(&value)
}
/// Open the Kimi Code config directory in the system file manager.
pub fn open_kimi_code_config_dir() -> KimiResult<()> {
let path = kimi_code_config_dir();
std::fs::create_dir_all(&path)
.with_context(|| format!("failed to create directory {}", path.display()))?;
Ok(())
}
impl Agent {
pub fn config_dir(&self) -> PathBuf {
match self {
Agent::KimiCode => kimi_code_config_dir(),
Agent::Pi => crate::pi_io::pi_agent_dir(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kimi_code_import_extracts_providers_and_models() {
let toml_str = r#"
default_model = "kimi-code/kimi-for-coding"
default_thinking = true
[providers."managed:kimi-code"]
type = "kimi"
api_key = ""
base_url = "https://api.kimi.com/coding/v1"
[providers."managed:kimi-code".oauth]
storage = "file"
key = "oauth/kimi-code"
[models."kimi-code/kimi-for-coding"]
provider = "managed:kimi-code"
model = "kimi-for-coding"
max_context_size = 262144
capabilities = ["thinking", "always_thinking", "image_in", "video_in", "tool_use"]
display_name = "K2.7 Code"
[services.moonshot_search]
base_url = "https://api.kimi.com/coding/v1/search"
api_key = ""
"#;
let value: TomlValue = toml_str.parse().unwrap();
let config = kimi_code_to_config(&value);
assert_eq!(config.default_model.as_deref(), Some("kimi-code/kimi-for-coding"));
assert_eq!(config.providers.len(), 1);
assert_eq!(config.models.len(), 1);
let provider = config.providers.get("managed:kimi-code").unwrap();
assert_eq!(provider.provider_type, ProviderType::Kimi);
assert!(provider.managed);
assert_eq!(provider.base_url.as_deref(), Some("https://api.kimi.com/coding/v1"));
let model = config.models.get("kimi-code/kimi-for-coding").unwrap();
assert_eq!(model.provider, "managed:kimi-code");
assert_eq!(model.model, "kimi-for-coding");
assert_eq!(model.max_context_size, 262144);
assert!(model.capabilities.contains(&"thinking".to_string()));
assert!(model.supports_1m);
// Unknown top-level sections are preserved.
let services = config.raw_other.get("services");
assert!(services.is_some());
}
#[test]
fn kimi_code_export_roundtrip_preserves_model() {
let mut providers = IndexMap::new();
providers.insert(
"glmzhongzhuan".to_string(),
Provider {
name: "glmzhongzhuan".to_string(),
provider_type: ProviderType::Anthropic,
base_url: Some("https://fast.cdks.work".to_string()),
api_key: Some("sk-test".to_string()),
env: IndexMap::new(),
note: None,
official_url: None,
managed: false,
enabled: true,
raw_other: Value::Null,
},
);
let mut models = IndexMap::new();
models.insert(
"glm-5.2".to_string(),
Model {
alias: "glm-5.2".to_string(),
provider: "glmzhongzhuan".to_string(),
model: "glm-5.2".to_string(),
max_context_size: 900_000,
display_name: None,
role: None,
supports_1m: true,
capabilities: vec!["thinking".to_string()],
raw_other: Value::Null,
},
);
let config = Config {
default_model: Some("glm-5.2".to_string()),
providers,
models,
raw_other: Value::Null,
};
let exported = config_to_kimi_code(&config, None);
let root = exported.as_table().unwrap();
assert_eq!(
root.get("default_model").and_then(|v| v.as_str()),
Some("glm-5.2")
);
let providers_table = root.get("providers").unwrap().as_table().unwrap();
let provider = providers_table.get("glmzhongzhuan").unwrap().as_table().unwrap();
assert_eq!(provider.get("type").and_then(|v| v.as_str()), Some("anthropic"));
assert_eq!(provider.get("api_key").and_then(|v| v.as_str()), Some("sk-test"));
let models_table = root.get("models").unwrap().as_table().unwrap();
let model = models_table.get("glm-5.2").unwrap().as_table().unwrap();
assert_eq!(model.get("model").and_then(|v| v.as_str()), Some("glm-5.2"));
let caps = model.get("capabilities").unwrap().as_array().unwrap();
assert!(caps.iter().any(|v| v.as_str() == Some("thinking")));
}
}
+8 -13
View File
@@ -1,23 +1,18 @@
pub mod commands; pub mod commands;
pub mod config_io; pub mod kimi_code_io;
pub mod models; pub mod models;
pub mod profile_manager; pub mod pi_io;
pub mod validators;
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
commands::load_config_command, commands::load_agent_config_command,
commands::save_config_command, commands::save_agent_config_command,
commands::list_profiles, commands::open_agent_config_dir,
commands::load_profile,
commands::save_profile,
commands::switch_profile,
commands::rename_profile,
commands::delete_profile,
commands::open_config_dir,
commands::get_app_version, commands::get_app_version,
commands::list_provider_models,
commands::debug_log,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
+146 -27
View File
@@ -1,11 +1,27 @@
use indexmap::IndexMap; use indexmap::IndexMap;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub use toml_edit::Table; use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)] /// Target agent whose provider/model config is being edited.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Agent {
KimiCode,
Pi,
}
impl Agent {
pub fn as_str(&self) -> &'static str {
match self {
Agent::KimiCode => "kimi_code",
Agent::Pi => "pi",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum ProviderType { pub enum ProviderType {
Kimi,
Anthropic, Anthropic,
Openai, Openai,
#[serde(rename = "openai_responses")] #[serde(rename = "openai_responses")]
@@ -13,19 +29,42 @@ pub enum ProviderType {
#[serde(rename = "google-genai")] #[serde(rename = "google-genai")]
GoogleGenai, GoogleGenai,
Vertexai, Vertexai,
/// Kept for compatibility; mapped to OpenAI-compatible in Pi.
Kimi,
}
const fn default_true() -> bool {
true
} }
impl ProviderType { impl ProviderType {
pub fn as_str(&self) -> &'static str { pub fn as_str(&self) -> &'static str {
match self { match self {
ProviderType::Kimi => "kimi",
ProviderType::Anthropic => "anthropic", ProviderType::Anthropic => "anthropic",
ProviderType::Openai => "openai", ProviderType::Openai => "openai",
ProviderType::OpenaiResponses => "openai_responses", ProviderType::OpenaiResponses => "openai_responses",
ProviderType::GoogleGenai => "google-genai", ProviderType::GoogleGenai => "google-genai",
ProviderType::Vertexai => "vertexai", ProviderType::Vertexai => "vertexai",
ProviderType::Kimi => "kimi",
} }
} }
pub fn default_base_url(&self) -> Option<&'static str> {
match self {
ProviderType::Openai | ProviderType::OpenaiResponses | ProviderType::Kimi => {
Some("https://api.openai.com/v1")
}
ProviderType::GoogleGenai => Some("https://generativelanguage.googleapis.com"),
ProviderType::Anthropic | ProviderType::Vertexai => None,
}
}
pub fn is_openai_compatible(&self) -> bool {
matches!(
self,
ProviderType::Kimi | ProviderType::Openai | ProviderType::OpenaiResponses
)
}
} }
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
@@ -34,11 +73,37 @@ pub struct Provider {
pub provider_type: ProviderType, pub provider_type: ProviderType,
pub base_url: Option<String>, pub base_url: Option<String>,
pub api_key: Option<String>, pub api_key: Option<String>,
#[serde(default)]
pub env: IndexMap<String, String>, pub env: IndexMap<String, String>,
#[serde(skip)] #[serde(default, skip_serializing_if = "Option::is_none")]
pub raw_other: Table, pub note: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub official_url: Option<String>,
#[serde(default)]
pub managed: bool,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default, skip_serializing_if = "Value::is_null")]
pub raw_other: Value,
} }
impl PartialEq for Provider {
fn eq(&self, other: &Self) -> bool {
self.name == other.name
&& self.provider_type == other.provider_type
&& self.base_url == other.base_url
&& self.api_key == other.api_key
&& self.env == other.env
&& self.note == other.note
&& self.official_url == other.official_url
&& self.managed == other.managed
&& self.enabled == other.enabled
&& self.raw_other == other.raw_other
}
}
impl Eq for Provider {}
impl std::fmt::Debug for Provider { impl std::fmt::Debug for Provider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Provider") f.debug_struct("Provider")
@@ -47,53 +112,107 @@ impl std::fmt::Debug for Provider {
.field("base_url", &self.base_url) .field("base_url", &self.base_url)
.field("api_key", &"<redacted>") .field("api_key", &"<redacted>")
.field("env", &"<redacted>") .field("env", &"<redacted>")
.field("raw_other", &"<table>") .field("managed", &self.managed)
.field("enabled", &self.enabled)
.field("raw_other", &"<json>")
.finish() .finish()
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
pub struct Model { pub struct Model {
pub alias: String, pub alias: String,
pub provider: String, pub provider: String,
pub model: String, pub model: String,
pub max_context_size: u64, pub max_context_size: u64,
pub display_name: Option<String>, pub display_name: Option<String>,
#[serde(skip)] #[serde(default, skip_serializing_if = "Option::is_none")]
pub raw_other: Table, pub role: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub supports_1m: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub capabilities: Vec<String>,
#[serde(default, skip_serializing_if = "Value::is_null")]
pub raw_other: Value,
} }
impl std::fmt::Debug for Model {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Model")
.field("alias", &self.alias)
.field("provider", &self.provider)
.field("model", &self.model)
.field("max_context_size", &self.max_context_size)
.field("display_name", &self.display_name)
.field("role", &self.role)
.field("supports_1m", &self.supports_1m)
.field("capabilities", &self.capabilities)
.field("raw_other", &"<json>")
.finish()
}
}
impl PartialEq for Model {
fn eq(&self, other: &Self) -> bool {
self.alias == other.alias
&& self.provider == other.provider
&& self.model == other.model
&& self.max_context_size == other.max_context_size
&& self.display_name == other.display_name
&& self.role == other.role
&& self.supports_1m == other.supports_1m
&& self.capabilities == other.capabilities
&& self.raw_other == other.raw_other
}
}
impl Eq for Model {}
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
pub struct Config { pub struct Config {
pub default_model: Option<String>, pub default_model: Option<String>,
#[serde(default)]
pub providers: IndexMap<String, Provider>, pub providers: IndexMap<String, Provider>,
#[serde(default)]
pub models: IndexMap<String, Model>, pub models: IndexMap<String, Model>,
#[serde(skip)] #[serde(default, skip_serializing_if = "Value::is_null")]
pub raw_other: Table, pub raw_other: Value,
} }
impl PartialEq for Config {
fn eq(&self, other: &Self) -> bool {
self.default_model == other.default_model
&& self.providers == other.providers
&& self.models == other.models
&& self.raw_other == other.raw_other
}
}
impl Eq for Config {}
impl std::fmt::Debug for Config { impl std::fmt::Debug for Config {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Config") f.debug_struct("Config")
.field("default_model", &self.default_model) .field("default_model", &self.default_model)
.field("providers", &format!("{} providers", self.providers.len())) .field(
"providers",
&format!(
"{:?} ({} providers)",
self.providers.keys().collect::<Vec<_>>(),
self.providers.len()
),
)
.field("models", &self.models) .field("models", &self.models)
.field("raw_other", &"<table>") .field("raw_other", &"<json>")
.finish() .finish()
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize)] /// A model discovered from a provider's API endpoint.
pub struct ProfileSummary { /// The frontend uses this to populate a `Model` form entry.
pub name: String, #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub filename: String, pub struct DiscoveredModel {
pub is_active: bool, pub id: String,
} pub display_name: Option<String>,
pub max_context_size: Option<u64>,
#[derive(Debug, Clone)]
pub struct Profile {
pub name: String,
pub filename: String,
pub config: Config,
pub is_active: bool,
} }
+358
View File
@@ -0,0 +1,358 @@
//! Pi CLI configuration I/O.
//!
//! Pi stores custom providers and models in `~/.pi/agent/models.json`.
//! This module reads and writes that file and converts between Pi's JSON
//! format and Pi Switch's internal `Config`/`Provider`/`Model` types.
use std::path::PathBuf;
use anyhow::Context;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::models::{Config, Model, Provider, ProviderType};
pub type PiResult<T> = anyhow::Result<T>;
/// Returns the default Pi agent config directory for the current user.
pub fn pi_agent_dir() -> PathBuf {
dirs::home_dir()
.map(|h| h.join(".pi").join("agent"))
.expect("failed to resolve home directory")
}
/// Returns the path to Pi's `models.json` file.
pub fn pi_models_path() -> PathBuf {
pi_agent_dir().join("models.json")
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PiCost {
pub input: f64,
pub output: f64,
#[serde(rename = "cacheRead", default)]
pub cache_read: f64,
#[serde(rename = "cacheWrite", default)]
pub cache_write: f64,
}
/// A single Pi model entry.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PiModel {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default)]
pub reasoning: bool,
#[serde(default = "default_input", skip_serializing_if = "is_default_input")]
pub input: Vec<String>,
#[serde(rename = "contextWindow", default = "default_context_window")]
pub context_window: u64,
#[serde(rename = "maxTokens", default, skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost: Option<PiCost>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compat: Option<Value>,
#[serde(flatten)]
pub extra: Value,
}
fn default_input() -> Vec<String> {
vec!["text".to_string()]
}
fn is_default_input(input: &[String]) -> bool {
input.len() == 1 && input.first().map(String::as_str) == Some("text")
}
fn default_context_window() -> u64 {
128_000
}
fn default_true() -> bool {
true
}
/// A single Pi provider entry.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PiProvider {
#[serde(rename = "baseUrl", default, skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api: Option<String>,
#[serde(rename = "apiKey", default, skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub headers: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compat: Option<Value>,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub models: Vec<PiModel>,
#[serde(flatten)]
pub extra: Value,
}
/// Root Pi `models.json` structure.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PiModelsFile {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_model: Option<String>,
#[serde(default)]
pub providers: IndexMap<String, PiProvider>,
#[serde(flatten)]
pub extra: Value,
}
/// Read the existing Pi `models.json` file, or return a default empty struct
/// if the file does not exist yet.
pub fn load_pi_models() -> PiResult<PiModelsFile> {
let path = pi_models_path();
if !path.exists() {
return Ok(PiModelsFile::default());
}
let content = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
let file: PiModelsFile = serde_json::from_str(&content)
.with_context(|| format!("failed to parse {}", path.display()))?;
Ok(file)
}
/// Write the given Pi models file, creating the parent directory if needed.
/// A backup of the previous file is created when it already exists.
pub fn save_pi_models(file: &PiModelsFile) -> PiResult<()> {
let path = pi_models_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create directory {}", parent.display()))?;
}
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()))?;
}
let content = serde_json::to_string_pretty(file)
.with_context(|| "failed to serialize Pi models.json")?;
std::fs::write(&path, content)
.with_context(|| format!("failed to write {}", path.display()))?;
Ok(())
}
/// Convert a Pi Switch provider type to the Pi `api` field value.
pub fn pi_api_for_provider(provider_type: &ProviderType) -> &'static str {
match provider_type {
ProviderType::Openai => "openai-completions",
ProviderType::OpenaiResponses => "openai-responses",
ProviderType::Anthropic => "anthropic-messages",
ProviderType::GoogleGenai => "google-generative-ai",
ProviderType::Vertexai => "google-vertex",
// Treat Kimi as OpenAI-compatible since it is not a native Pi API.
ProviderType::Kimi => "openai-completions",
}
}
/// Convert a Pi `api` field value back to a Pi Switch provider type.
pub fn provider_type_for_pi_api(api: &str) -> ProviderType {
match api {
"openai-responses" => ProviderType::OpenaiResponses,
"anthropic-messages" => ProviderType::Anthropic,
"google-generative-ai" => ProviderType::GoogleGenai,
"google-vertex" => ProviderType::Vertexai,
_ => ProviderType::Openai,
}
}
/// Import a Pi `models.json` into a Pi Switch `Config`.
pub fn pi_file_to_config(file: &PiModelsFile) -> Config {
let mut providers = IndexMap::new();
let mut models = IndexMap::new();
for (name, pi_provider) in &file.providers {
let provider_type = pi_provider
.api
.as_deref()
.map(provider_type_for_pi_api)
.unwrap_or(ProviderType::Openai);
let provider = Provider {
name: name.clone(),
provider_type,
base_url: pi_provider.base_url.clone().filter(|s| !s.is_empty()),
api_key: pi_provider.api_key.clone().filter(|s| !s.is_empty()),
env: IndexMap::new(),
note: None,
official_url: None,
managed: false,
enabled: pi_provider.enabled,
raw_other: pi_provider.extra.clone(),
};
for (idx, pi_model) in pi_provider.models.iter().enumerate() {
let alias = pi_model
.name
.clone()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| {
if pi_model.id.trim().is_empty() {
format!("{}-model-{}", name, idx + 1)
} else {
pi_model.id.clone()
}
});
let display_name = pi_model.name.clone().filter(|s| !s.is_empty());
models.insert(
alias.clone(),
Model {
alias,
provider: name.clone(),
model: pi_model.id.clone(),
max_context_size: pi_model.context_window,
display_name,
role: None,
supports_1m: pi_model.reasoning || pi_model.context_window >= 1_000_000,
capabilities: vec![],
raw_other: pi_model.extra.clone(),
},
);
}
providers.insert(name.clone(), provider);
}
Config {
default_model: file.default_model.clone(),
providers,
models,
raw_other: file.extra.clone(),
}
}
/// Export a Pi Switch `Config` to a Pi `models.json`.
pub fn config_to_pi_file(config: &Config) -> PiModelsFile {
let mut providers = IndexMap::new();
for (name, provider) in &config.providers {
let mut pi_models = Vec::new();
for model in config.models.values() {
if model.provider != *name {
continue;
}
let display_name = model.display_name.clone().filter(|s| !s.is_empty());
let name_field = display_name
.clone()
.filter(|s| s != &model.model);
pi_models.push(PiModel {
id: model.model.clone(),
name: name_field,
reasoning: model.supports_1m,
input: default_input(),
context_window: model.max_context_size,
max_tokens: None,
cost: None,
compat: None,
extra: model.raw_other.clone(),
});
}
providers.insert(
name.clone(),
PiProvider {
base_url: provider.base_url.clone().filter(|s| !s.is_empty()),
api: Some(pi_api_for_provider(&provider.provider_type).to_string()),
api_key: provider.api_key.clone().filter(|s| !s.is_empty()),
headers: None,
compat: None,
enabled: provider.enabled,
models: pi_models,
extra: provider.raw_other.clone(),
},
);
}
PiModelsFile {
default_model: config.default_model.clone(),
providers,
extra: config.raw_other.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pi_roundtrip_preserves_provider_and_model() {
let mut providers = IndexMap::new();
providers.insert(
"my-openai".to_string(),
PiProvider {
base_url: Some("https://proxy.example.com/v1".to_string()),
api: Some("openai-completions".to_string()),
api_key: Some("sk-test".to_string()),
headers: None,
compat: None,
enabled: true,
models: vec![PiModel {
id: "gpt-test".to_string(),
name: Some("GPT Test".to_string()),
reasoning: true,
input: vec!["text".to_string()],
context_window: 200_000,
max_tokens: Some(4096),
cost: Some(PiCost {
input: 1.0,
output: 2.0,
cache_read: 0.0,
cache_write: 0.0,
}),
compat: None,
extra: Value::Null,
}],
extra: Value::Null,
},
);
let file = PiModelsFile {
default_model: None,
providers,
extra: Value::Null,
};
let config = pi_file_to_config(&file);
assert_eq!(config.providers.len(), 1);
assert_eq!(config.models.len(), 1);
let provider = config.providers.get("my-openai").unwrap();
assert_eq!(provider.provider_type, ProviderType::Openai);
assert_eq!(provider.base_url.as_deref(), Some("https://proxy.example.com/v1"));
assert_eq!(provider.api_key.as_deref(), Some("sk-test"));
let model = config.models.get("GPT Test").unwrap();
assert_eq!(model.model, "gpt-test");
assert!(model.supports_1m);
assert_eq!(model.max_context_size, 200_000);
let exported = config_to_pi_file(&config);
let exported_provider = exported.providers.get("my-openai").unwrap();
assert_eq!(exported_provider.api.as_deref(), Some("openai-completions"));
assert_eq!(exported_provider.models[0].id, "gpt-test");
assert!(exported_provider.models[0].reasoning);
}
}
+17 -5
View File
@@ -1,7 +1,7 @@
{ {
"productName": "KimiSwitch", "productName": "Pi Switch",
"version": "0.1.0", "version": "0.1.0",
"identifier": "com.kimiswitch.app", "identifier": "com.piswitch.app",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build", "beforeBuildCommand": "npm run build",
@@ -11,7 +11,7 @@
"app": { "app": {
"windows": [ "windows": [
{ {
"title": "KimiSwitch", "title": "Pi Switch",
"width": 1200, "width": 1200,
"height": 800, "height": 800,
"minWidth": 1000, "minWidth": 1000,
@@ -21,11 +21,23 @@
} }
], ],
"security": { "security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;" "csp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;"
} }
}, },
"bundle": { "bundle": {
"active": true, "active": true,
"targets": ["msi"] "targets": ["msi"],
"windows": {
"webviewInstallMode": {
"type": "downloadBootstrapper"
},
"nsis": null
},
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.ico"
]
} }
} }
+468 -3
View File
@@ -1,5 +1,470 @@
function App() { import { useEffect, useMemo, useState } from "react";
return <div>KimiSwitch</div>; 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 { useTranslation } from "./i18n";
import type { Agent, Model, Provider } from "./types";
const AGENT_STORAGE_KEY = "pi-switch-agent";
const AGENTS: { key: Agent; label: string }[] = [
{ key: "kimi_code", label: "Kimi Code" },
{ key: "pi", label: "Pi" },
];
function getInitialAgent(): Agent {
try {
const stored = localStorage.getItem(AGENT_STORAGE_KEY) as Agent | null;
if (stored === "kimi_code" || stored === "pi") return stored;
} catch {
// ignore
}
return "pi";
} }
export default App; export default function App() {
const { t, lang, setLang } = useTranslation();
const [agent, setAgentState] = useState<Agent>(getInitialAgent);
const {
config,
dirty,
error,
loading,
refresh,
save,
updateConfig,
} = useConfig(agent);
const [view, setView] = useState<"list" | "edit">("list");
const [editingProvider, setEditingProvider] = useState<string>("");
const [loadTimeout, setLoadTimeout] = useState(false);
const setAgent = (next: Agent) => {
setAgentState(next);
try {
localStorage.setItem(AGENT_STORAGE_KEY, next);
} catch {
// ignore
}
setView("list");
setEditingProvider("");
};
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) => ({ ...cfg, default_model: alias }));
};
const handleToggleProviderEnabled = (name: string) => {
updateConfig((cfg) => {
const provider = cfg.providers[name];
if (!provider) return cfg;
return {
...cfg,
providers: {
...cfg.providers,
[name]: { ...provider, enabled: !provider.enabled },
},
};
});
};
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 };
}
let default_model = cfg.default_model;
if (default_model && !(default_model in updatedModels)) {
default_model = null;
}
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-[#e5e5e7]">
<div>{t("loading")}</div>
{loadTimeout && (
<div className="text-sm 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-[#0f0f11] text-[#e5e5e7]">
<header className="flex items-center justify-between gap-3 px-4 py-3 border-b border-[#2a2a2e] bg-[#16161a]">
<div className="flex items-center bg-[#1f1f23] border border-[#2a2a2e] rounded p-0.5">
{AGENTS.map(({ key, label }) => (
<button
key={key}
type="button"
onClick={() => setAgent(key)}
className={`px-3 py-1 text-sm rounded transition-colors ${
agent === key
? "bg-blue-600 text-white"
: "text-gray-400 hover:text-[#e5e5e7]"
}`}
>
{label}
</button>
))}
</div>
<div className="flex items-center gap-2">
<select
className="bg-[#1f1f23] border border-[#2a2a2e] 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>
</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>
)}
<main className="flex-1 overflow-hidden relative">
{view === "list" ? (
<ProviderList
providers={providers}
defaultModel={config.default_model}
models={config.models}
onEdit={(name) => {
setEditingProvider(name);
setView("edit");
}}
onDelete={handleDeleteProvider}
onAdd={handleAddProvider}
onToggleEnabled={handleToggleProviderEnabled}
/>
) : currentProvider ? (
<ProviderEdit
agent={agent}
provider={currentProvider}
models={Object.values(config.models).filter(
(m) => m.provider === currentProvider.name
)}
defaultModel={config.default_model}
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 providerModels = Object.values(config.models).filter(
(m) => m.provider === currentProvider.name
);
const alias = `${currentProvider.name}-${providerModels.length + 1}`;
updateConfig((cfg) => ({
...cfg,
models: {
...cfg.models,
[alias]: {
alias,
provider: currentProvider.name,
model: "",
max_context_size: 128000,
display_name: null,
role: null,
supports_1m: false,
capabilities: [],
},
},
}));
}}
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}
/>
) : (
<div className="p-8 text-center text-gray-400">
<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>
<footer className="flex items-center justify-between px-4 py-2 border-t border-[#2a2a2e] bg-[#16161a] text-sm">
<div className="flex items-center gap-2 min-w-0">
{dirty && (
<span className="text-orange-400 truncate"> {t("unsavedChanges")}</span>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={save}
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-[#2a2a2e] hover:bg-[#252529] 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-[#2a2a2e] rounded hover:bg-[#252529] 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-[#2a2a2e] rounded hover:bg-[#252529] focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
{t("openConfigDir")}
</button>
</div>
</footer>
</div>
);
}
+662
View File
@@ -0,0 +1,662 @@
import { useEffect, useId, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useTranslation } from "../i18n";
import type { Agent, DiscoveredModel, Model, Provider, ProviderType } from "../types";
const PROVIDER_TYPES: ProviderType[] = [
"openai",
"openai_responses",
"anthropic",
"google-genai",
"vertexai",
"kimi",
];
function defaultBaseUrl(agent: Agent, type: ProviderType): string {
if (agent === "kimi_code") {
switch (type) {
case "kimi":
return "https://api.kimi.com/coding/v1";
case "openai":
case "openai_responses":
return "https://api.openai.com/v1";
case "google-genai":
return "https://generativelanguage.googleapis.com";
case "anthropic":
case "vertexai":
return "";
}
}
// Pi defaults
switch (type) {
case "kimi":
return "https://api.moonshot.ai/v1";
case "openai":
case "openai_responses":
return "https://api.openai.com/v1";
case "google-genai":
return "https://generativelanguage.googleapis.com";
case "anthropic":
case "vertexai":
return "";
}
}
interface ProviderEditProps {
agent: Agent;
provider: Provider;
models: Model[];
defaultModel: string | null;
onBack: () => void;
onChange: (provider: Provider) => void;
onDelete: () => void;
onModelChange: (model: Model) => void;
onModelDelete: (alias: string) => void;
onModelAdd: () => void;
onBulkAdd: (models: Model[]) => void;
onSetDefault: (alias: string) => void;
onApplyJson: (provider: Provider, models: Model[]) => void;
onSave: () => void;
}
export function ProviderEdit({
agent,
provider,
models,
defaultModel,
onBack,
onChange,
onDelete,
onModelChange,
onModelDelete,
onModelAdd,
onBulkAdd,
onSetDefault,
onApplyJson,
onSave,
}: ProviderEditProps) {
const { t } = useTranslation();
const nameId = useId();
const noteId = useId();
const officialUrlId = useId();
const apiKeyId = useId();
const baseUrlId = useId();
const [activeTab, setActiveTab] = useState<"basic" | "models" | "json">("basic");
const [showApiKey, setShowApiKey] = useState(false);
useEffect(() => {
const def = defaultBaseUrl(agent, provider.provider_type);
if (!def) return;
if (!provider.base_url) {
onChange({ ...provider, base_url: def });
}
}, [provider.provider_type, agent]);
return (
<div className="h-full flex flex-col bg-[#0f0f11]">
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-[#2a2a2e] bg-[#16161a]">
<button
type="button"
onClick={onBack}
className="p-2 rounded-lg hover:bg-[#252529] focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
{t("back")}
</button>
<h2 className="font-semibold text-lg">{t("editProvider")}</h2>
<div className="flex-1" />
<button
type="button"
onClick={onSave}
className="px-3 py-1.5 text-sm bg-green-600 hover:bg-green-700 text-white rounded focus:ring-2 focus:ring-green-500 focus:outline-none"
>
{t("saveConfig")}
</button>
<button
type="button"
onClick={onDelete}
className="px-3 py-1.5 text-sm border border-red-500/30 rounded hover:bg-red-900/30 text-red-400 focus:ring-2 focus:ring-red-500 focus:outline-none"
>
{t("delete")}
</button>
</div>
{/* Tabs */}
<div className="flex items-center gap-1 px-4 border-b border-[#2a2a2e] bg-[#16161a]">
{[
{ key: "basic", label: t("basicInfo") },
{ key: "models", label: t("modelMapping") },
{ key: "json", label: t("configJson") },
].map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => setActiveTab(tab.key as typeof activeTab)}
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors focus:ring-2 focus:ring-blue-500 focus:outline-none ${
activeTab === tab.key
? "border-blue-500 text-blue-400"
: "border-transparent text-gray-400 hover:text-[#e5e5e7]"
}`}
>
{tab.label}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-6">
{activeTab === "basic" && (
<div className="space-y-6">
{/* Basic info */}
<section className="bg-[#16161a] border border-[#2a2a2e] rounded-xl p-5">
<h3 className="font-medium mb-4 text-[#e5e5e7]">{t("basicInfo")}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor={nameId} className="block text-sm text-gray-400 mb-1.5">
{t("providerName")}
</label>
<input
id={nameId}
className="w-full bg-[#1f1f23] border border-[#2a2a2e] rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={provider.name}
onChange={(e) => onChange({ ...provider, name: e.target.value })}
/>
</div>
<div>
<label htmlFor={noteId} className="block text-sm text-gray-400 mb-1.5">
{t("note")}
</label>
<input
id={noteId}
className="w-full bg-[#1f1f23] border border-[#2a2a2e] rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={provider.note || ""}
placeholder={t("notePlaceholder")}
onChange={(e) =>
onChange({ ...provider, note: e.target.value || null })
}
/>
</div>
<div className="col-span-1 md:col-span-2">
<label htmlFor={officialUrlId} className="block text-sm text-gray-400 mb-1.5">
{t("officialUrl")}
</label>
<input
id={officialUrlId}
className="w-full bg-[#1f1f23] border border-[#2a2a2e] rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={provider.official_url || ""}
onChange={(e) =>
onChange({ ...provider, official_url: e.target.value || null })
}
/>
</div>
<div className="col-span-1 md:col-span-2 flex items-center gap-2">
<input
id="managed"
type="checkbox"
checked={provider.managed || false}
onChange={(e) =>
onChange({ ...provider, managed: e.target.checked })
}
className="w-4 h-4 rounded border-[#2a2a2e] bg-[#1f1f23] text-blue-600 focus:ring-blue-500"
/>
<label htmlFor="managed" className="text-sm text-gray-400">
{t("managedProvider")}
</label>
</div>
</div>
</section>
{/* API settings */}
<section className="bg-[#16161a] border border-[#2a2a2e] rounded-xl p-5">
<h3 className="font-medium mb-4 text-[#e5e5e7]">{t("apiSettings")}</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm text-gray-400 mb-1.5">
{t("apiFormat")}
</label>
<select
className="w-full bg-[#1f1f23] border border-[#2a2a2e] rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={provider.provider_type}
onChange={(e) =>
onChange({ ...provider, provider_type: e.target.value as ProviderType })
}
>
{PROVIDER_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
</div>
{!provider.managed && (
<div>
<label htmlFor={apiKeyId} className="block text-sm text-gray-400 mb-1.5">
{t("apiKey")}
</label>
<div className="relative">
<input
id={apiKeyId}
type={showApiKey ? "text" : "password"}
className="w-full bg-[#1f1f23] border border-[#2a2a2e] rounded-lg px-3 py-2 pr-10 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={provider.api_key || ""}
onChange={(e) =>
onChange({ ...provider, api_key: e.target.value || null })
}
/>
<button
type="button"
onClick={() => setShowApiKey((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300 text-xs px-1"
>
{showApiKey ? t("hide") : t("show")}
</button>
</div>
</div>
)}
<div>
<label htmlFor={baseUrlId} className="block text-sm text-gray-400 mb-1.5">
{t("requestUrl")}
</label>
<input
id={baseUrlId}
className="w-full bg-[#1f1f23] border border-[#2a2a2e] rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={provider.base_url || ""}
placeholder={defaultBaseUrl(agent, provider.provider_type)}
onChange={(e) =>
onChange({ ...provider, base_url: e.target.value || null })
}
/>
<div className="mt-2 text-xs text-orange-400/80 bg-orange-900/20 border border-orange-500/20 rounded-lg px-3 py-2">
{t("baseUrlHint", { type: provider.provider_type })}
</div>
</div>
</div>
</section>
</div>
)}
{activeTab === "models" && (
<ModelMapping
agent={agent}
provider={provider}
models={models}
defaultModel={defaultModel}
onModelChange={onModelChange}
onModelDelete={onModelDelete}
onModelAdd={onModelAdd}
onBulkAdd={onBulkAdd}
onSetDefault={onSetDefault}
/>
)}
{activeTab === "json" && (
<JsonPreview
provider={provider}
models={models}
onApply={onApplyJson}
/>
)}
</div>
</div>
);
}
function ModelMapping({
agent,
provider,
models,
defaultModel,
onModelChange,
onModelDelete,
onModelAdd,
onBulkAdd,
onSetDefault,
}: {
agent: Agent;
provider: Provider;
models: Model[];
defaultModel: string | null;
onModelChange: (model: Model) => void;
onModelDelete: (alias: string) => void;
onModelAdd: () => void;
onBulkAdd: (models: Model[]) => void;
onSetDefault: (alias: string) => void;
}) {
const { t } = useTranslation();
const [discovering, setDiscovering] = useState(false);
const [discovered, setDiscovered] = useState<DiscoveredModel[] | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [discoverError, setDiscoverError] = useState<string | null>(null);
const handleDiscover = async () => {
setDiscovering(true);
setDiscoverError(null);
setDiscovered(null);
setSelected(new Set());
try {
const result = await invoke<DiscoveredModel[]>("list_provider_models", {
provider,
});
setDiscovered(result);
} catch (err) {
setDiscoverError(err instanceof Error ? err.message : String(err));
} finally {
setDiscovering(false);
}
};
const toggleSelect = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleAddSelected = () => {
if (!discovered || selected.size === 0) return;
const toAdd: Model[] = [];
for (const dm of discovered) {
if (!selected.has(dm.id)) continue;
const alias = dm.id.replace(/[^a-zA-Z0-9_-]/g, "-");
toAdd.push({
alias,
provider: provider.name,
model: dm.id,
max_context_size: dm.max_context_size ?? 128000,
display_name: dm.display_name,
role: null,
supports_1m: (dm.max_context_size ?? 0) >= 1_000_000,
capabilities: [],
});
}
onBulkAdd(toAdd);
setDiscovered(null);
setSelected(new Set());
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-[#e5e5e7]">{t("modelMapping")}</h3>
<p className="text-xs text-gray-500 mt-1">{t("modelMappingDesc")}</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onModelAdd}
className="px-3 py-1.5 text-sm border border-[#2a2a2e] rounded hover:bg-[#252529] focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
{t("oneClickSetup")}
</button>
<button
type="button"
onClick={handleDiscover}
disabled={discovering || provider.managed}
className="px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
{discovering ? t("fetchingModels") : t("fetchModels")}
</button>
</div>
</div>
{discoverError && (
<div className="text-red-400 text-sm bg-red-900/20 border border-red-500/20 p-3 rounded-lg">
{discoverError}
</div>
)}
{discovered && (
<div className="bg-[#16161a] border border-[#2a2a2e] rounded-xl p-4 space-y-3">
<div className="text-sm font-medium">
{t("discoveredModels", { count: discovered.length })}
</div>
<div className="max-h-48 overflow-auto space-y-1">
{discovered.map((m) => (
<label
key={m.id}
className="flex items-center gap-2 text-sm cursor-pointer hover:bg-[#1f1f23] p-1.5 rounded"
>
<input
type="checkbox"
checked={selected.has(m.id)}
onChange={() => toggleSelect(m.id)}
/>
<span className="font-mono text-xs text-gray-300">{m.id}</span>
{m.display_name && (
<span className="text-gray-500">({m.display_name})</span>
)}
</label>
))}
</div>
<button
type="button"
onClick={handleAddSelected}
disabled={selected.size === 0}
className="px-4 py-1.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 text-white text-sm rounded"
>
{t("addSelected")} {selected.size > 0 ? `(${selected.size})` : ""}
</button>
</div>
)}
<div className="bg-[#16161a] border border-[#2a2a2e] rounded-xl overflow-auto">
<table className="w-full min-w-[640px] text-sm">
<thead className="bg-[#1f1f23] text-gray-400">
<tr>
<th className="text-left px-4 py-3 font-medium">{t("modelRole")}</th>
<th className="text-left px-4 py-3 font-medium">{t("displayName")}</th>
<th className="text-left px-4 py-3 font-medium">{t("actualModel")}</th>
<th className="text-left px-4 py-3 font-medium w-28">{t("contextSize")}</th>
<th className="text-center px-4 py-3 font-medium w-28">{t("supports1M")}</th>
{agent === "kimi_code" && (
<th className="text-left px-4 py-3 font-medium">{t("capabilities")}</th>
)}
<th className="text-center px-4 py-3 font-medium w-32">{t("default")}</th>
<th className="text-center px-4 py-3 font-medium w-20">{t("operation")}</th>
</tr>
</thead>
<tbody className="divide-y divide-[#2a2a2e]">
{models.map((m) => (
<tr key={m.alias} className="hover:bg-[#1c1c20]">
<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.role || ""}
placeholder={m.alias}
onChange={(e) =>
onModelChange({
...m,
role: e.target.value || null,
})
}
/>
</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">
<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.model}
onChange={(e) =>
onModelChange({ ...m, model: e.target.value })
}
/>
</td>
<td className="px-4 py-2">
<input
type="number"
min={0}
step={1024}
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.max_context_size}
onChange={(e) => {
const value = parseInt(e.target.value, 10);
onModelChange({
...m,
max_context_size: isNaN(value) ? 0 : value,
});
}}
/>
</td>
<td className="px-4 py-2 text-center">
<input
type="checkbox"
checked={m.supports_1m || false}
onChange={(e) =>
onModelChange({
...m,
supports_1m: e.target.checked,
})
}
className="w-4 h-4 rounded border-[#2a2a2e] bg-[#1f1f23] text-blue-600 focus:ring-blue-500"
/>
</td>
{agent === "kimi_code" && (
<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.capabilities || []).join(", ")}
placeholder="thinking, image_in"
onChange={(e) =>
onModelChange({
...m,
capabilities: e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
})
}
/>
</td>
)}
<td className="px-4 py-2 text-center">
<button
type="button"
onClick={() => onSetDefault(m.alias)}
className={`text-xs px-2 py-1 rounded border ${
defaultModel === m.alias
? "bg-green-900/30 text-green-400 border-green-500/30"
: "border-[#2a2a2e] text-gray-400 hover:bg-[#252529]"
}`}
>
{defaultModel === m.alias ? t("isDefault") : t("setDefault")}
</button>
</td>
<td className="px-4 py-2 text-center">
<button
type="button"
onClick={() => {
if (confirm(t("deleteModelConfirm", { alias: m.alias }))) {
onModelDelete(m.alias);
}
}}
className="text-red-400 hover:text-red-300 text-sm"
>
{t("delete")}
</button>
</td>
</tr>
))}
</tbody>
</table>
{models.length === 0 && (
<div className="text-center py-8 text-gray-500 text-sm">
{t("noModelMappings")}
</div>
)}
</div>
<button
type="button"
onClick={onModelAdd}
className="px-4 py-2 border border-[#2a2a2e] rounded-lg hover:bg-[#252529] text-sm"
>
{t("addModelMapping")}
</button>
</div>
);
}
function JsonPreview({
provider,
models,
onApply,
}: {
provider: Provider;
models: Model[];
onApply: (provider: Provider, models: Model[]) => void;
}) {
const { t } = useTranslation();
const [text, setText] = useState(() =>
JSON.stringify({ provider, models }, null, 2)
);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setText(JSON.stringify({ provider, models }, null, 2));
setError(null);
}, [provider, models]);
const handleApply = () => {
try {
const parsed = JSON.parse(text);
if (!parsed.provider || typeof parsed.provider !== "object") {
throw new Error("missing provider object");
}
if (!Array.isArray(parsed.models)) {
throw new Error("models must be an array");
}
onApply(parsed.provider as Provider, parsed.models as Model[]);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
return (
<div className="w-full h-full flex flex-col">
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium text-[#e5e5e7]">{t("configJson")}</h3>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">{t("readOnlyPreview")}</span>
<button
type="button"
onClick={handleApply}
className="px-3 py-1 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
{t("apply")}
</button>
</div>
</div>
{error && (
<div className="mb-2 text-red-400 text-sm bg-red-900/20 border border-red-500/20 p-2 rounded">
{t("invalidJson", { message: error })}
</div>
)}
<textarea
className="flex-1 min-h-[40vh] bg-[#16161a] border border-[#2a2a2e] rounded-xl p-4 overflow-auto text-xs font-mono text-green-400 focus:ring-2 focus:ring-blue-500 focus:outline-none resize-none"
value={text}
onChange={(e) => setText(e.target.value)}
spellCheck={false}
/>
</div>
);
}
+194
View File
@@ -0,0 +1,194 @@
import { useTranslation } from "../i18n";
import type { Model, Provider } from "../types";
interface ProviderListProps {
providers: Provider[];
defaultModel: string | null;
models: Record<string, Model>;
onEdit: (name: string) => void;
onDelete: (name: string) => void;
onAdd: () => void;
onToggleEnabled: (name: string) => void;
}
const PROVIDER_ICONS: Record<string, string> = {
kimi: "K",
anthropic: "A",
openai: "O",
openai_responses: "R",
"google-genai": "G",
vertexai: "V",
};
function getInitial(provider: Provider): string {
return (
PROVIDER_ICONS[provider.provider_type] ||
provider.name.charAt(0).toUpperCase()
);
}
function getProviderColor(type: string): string {
switch (type) {
case "kimi":
return "from-blue-500 to-cyan-400";
case "anthropic":
return "from-orange-500 to-red-400";
case "openai":
return "from-green-500 to-emerald-400";
case "openai_responses":
return "from-teal-500 to-green-400";
case "google-genai":
return "from-purple-500 to-pink-400";
case "vertexai":
return "from-indigo-500 to-purple-400";
default:
return "from-gray-500 to-gray-400";
}
}
export function ProviderList({
providers,
defaultModel,
models,
onEdit,
onDelete,
onAdd,
onToggleEnabled,
}: ProviderListProps) {
const { t } = useTranslation();
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-between px-4 py-3 border-b border-[#2a2a2e]">
<h2 className="font-medium text-[#e5e5e7]">{t("providers")}</h2>
<button
type="button"
onClick={onAdd}
title={t("addProvider")}
className="w-9 h-9 flex items-center justify-center rounded-full bg-orange-500 hover:bg-orange-600 text-white text-xl shadow-lg focus:ring-2 focus:ring-orange-400 focus:outline-none transition-colors"
>
+
</button>
</div>
<div className="flex-1 overflow-auto p-4">
{providers.length === 0 ? (
<div className="text-center py-12 text-gray-400">
<div className="text-4xl mb-3 opacity-30"></div>
<div>{t("noProviders")}</div>
<button
type="button"
onClick={onAdd}
className="mt-4 w-12 h-12 rounded-full bg-orange-500 hover:bg-orange-600 text-white text-2xl shadow-lg flex items-center justify-center mx-auto"
>
+
</button>
</div>
) : (
<div className="grid grid-cols-1 gap-3 w-full">
{providers.map((provider) => {
const providerModels = Object.values(models).filter(
(m) => m.provider === provider.name
);
const defaultModelName = defaultModel
? models[defaultModel]?.display_name || defaultModel
: null;
const enabled = provider.enabled !== false;
return (
<div
key={provider.name}
className={`group relative flex items-center gap-4 p-4 rounded-xl border transition-colors cursor-pointer w-full ${
enabled
? "bg-[#16161a] border-[#2a2a2e] hover:border-[#3a3a42] hover:bg-[#1c1c20]"
: "bg-[#16161a]/50 border-[#2a2a2e]/50 opacity-60"
}`}
onClick={() => onEdit(provider.name)}
>
<div
className={`w-11 h-11 rounded-xl bg-gradient-to-br ${getProviderColor(
provider.provider_type
)} flex items-center justify-center text-white font-bold text-lg shadow-lg`}
>
{getInitial(provider)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-[#e5e5e7] truncate">
{provider.name}
</h3>
{!enabled && (
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-800 text-gray-400 border border-gray-700">
{t("disabled")}
</span>
)}
{provider.note && (
<span className="text-xs text-gray-500 truncate max-w-[200px]">
{provider.note}
</span>
)}
</div>
<div className="mt-1 flex items-center gap-3 text-sm text-gray-400 flex-wrap">
<span className="font-mono text-xs">
{provider.official_url || provider.base_url || t("noUrl")}
</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-[#252529] text-gray-400 border border-[#2a2a2e]">
{provider.provider_type}
</span>
<span className="text-xs">
{t("modelCount", { count: providerModels.length })}
</span>
</div>
</div>
<div className="flex items-center flex-wrap justify-end gap-2">
{defaultModel &&
models[defaultModel]?.provider === provider.name && (
<span className="text-xs px-2 py-1 rounded-full bg-green-900/30 text-green-400 border border-green-500/20">
{t("defaultModel", { name: defaultModelName ?? "" })}
</span>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggleEnabled(provider.name);
}}
className={`px-3 py-1.5 text-sm rounded focus:ring-2 focus:outline-none ${
enabled
? "bg-blue-600 hover:bg-blue-700 text-white focus:ring-blue-500"
: "border border-[#2a2a2e] hover:bg-[#252529] text-gray-400 focus:ring-blue-500"
}`}
>
{enabled ? `${t("activated")}` : t("activate")}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onEdit(provider.name);
}}
className="px-3 py-1.5 text-sm border border-[#2a2a2e] rounded hover:bg-[#252529] focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
{t("edit")}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDelete(provider.name);
}}
className="px-3 py-1.5 text-sm border border-[#2a2a2e] rounded hover:bg-red-900/30 hover:border-red-500/30 text-red-400 focus:ring-2 focus:ring-red-500 focus:outline-none"
>
{t("delete")}
</button>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useTranslation } from "../i18n";
import type { Agent, Config } from "../types";
interface UseConfigReturn {
config: Config | null;
dirty: boolean;
error: string | null;
loading: boolean;
refresh: () => Promise<void>;
save: () => Promise<void>;
updateConfig: (updater: (config: Config) => Config) => void;
}
export function useConfig(agent: Agent): UseConfigReturn {
const { t } = useTranslation();
const [config, setConfig] = useState<Config | null>(null);
const configRef = useRef(config);
const [dirty, setDirty] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
configRef.current = config;
}, [config]);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const loadedConfig = await invoke<Config>("load_agent_config_command", {
agent,
});
setConfig(loadedConfig);
setDirty(false);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
console.error("useConfig refresh failed:", message);
} finally {
setLoading(false);
}
}, [agent]);
useEffect(() => {
refresh();
}, [refresh]);
const updateConfig = useCallback((updater: (config: Config) => Config) => {
setConfig((prev) => (prev ? updater(prev) : prev));
setDirty(true);
}, []);
const save = useCallback(async () => {
const current = configRef.current;
if (!current) return;
setLoading(true);
setError(null);
try {
await invoke("save_agent_config_command", { agent, config: current });
await refresh();
} catch (err) {
const raw = err instanceof Error ? err.message : String(err);
setError(raw);
} finally {
setLoading(false);
}
}, [refresh, t, agent]);
return {
config,
dirty,
error,
loading,
refresh,
save,
updateConfig,
};
}
+111
View File
@@ -0,0 +1,111 @@
import type { TranslationKey } from "./zh";
export const enTranslations: Record<TranslationKey, string> = {
// App / global
appTitle: "Pi Switch",
loading: "Loading...",
loadTimeout: "Loading is taking a while. Try restarting the app if it stays stuck.",
loadFailed: "Failed to load:",
retry: "Retry",
saveConfig: "Save Config",
reloadConfig: "Reload Config",
openConfigDir: "Open Config Dir",
unsavedConfirm: "You have unsaved changes. Are you sure you want to reload?",
providerNotFound: "Provider not found",
backToList: "Back to list",
syncToOmp: "Sync to OMP",
syncedToOmp: "Synced to OMP: ",
ompOverwriteConfirm: "OMP already has provider \"{existing}\". Replace with \"{name}\"?",
deleteProviderConfirm: "Delete provider {name}?",
// Provider list
providers: "Providers",
addProvider: "Add Provider",
noProviders: "No providers yet",
addFirstProvider: "Add first provider",
noUrl: "No URL",
modelCount: "{count} models",
ompActive: "OMP Active",
defaultModel: "Default: {name}",
edit: "Edit",
delete: "Delete",
// Provider edit
editProvider: "Edit Provider",
back: "Back",
basicInfo: "Basic Info",
modelMapping: "Model Mapping",
configJson: "Config JSON",
providerName: "Provider Name",
note: "Note",
notePlaceholder: "e.g. company account",
officialUrl: "Official URL",
managedProvider: "Managed provider (no credentials needed)",
apiSettings: "API Settings",
apiFormat: "API Format",
authField: "Auth Field",
apiKey: "API Key",
show: "Show",
hide: "Hide",
requestUrl: "Request URL",
fullUrl: "Full URL",
baseUrlHint: "Enter a {type} API-compatible endpoint without a trailing slash.",
envPairs: "Env pairs",
addEnv: "+ Add",
modelMappingDesc: "Display name only affects the /model menu; 1M declares context capability for Kimi Code.",
oneClickSetup: "One-click setup",
fetchModels: "Fetch models",
fetchingModels: "Fetching...",
discoveredModels: "Discovered {count} models",
addSelected: "Add selected",
modelRole: "Model role",
displayName: "Display name",
actualModel: "Actual model",
contextSize: "Context size",
supports1M: "Supports 1M",
default: "Default",
operation: "Operation",
isDefault: "Default",
setDefault: "Set default",
deleteModelConfirm: "Delete model {alias}?",
noModelMappings: "No model mappings",
addModelMapping: "+ Add model mapping",
readOnlyPreview: "Editable JSON",
apply: "Apply",
invalidJson: "Invalid JSON: {message}",
// Agent / target
target: "Target",
targetKimi: "Kimi Code",
targetOmp: "OMP",
targetQwen: "Qwen Code",
agent: "Agent",
agentKimiCode: "Kimi Code",
agentPi: "Pi",
activate: "Activate",
activated: "Activated",
disabled: "Disabled",
unsavedChanges: "Unsaved changes",
qwenActivateNotImplemented: "Qwen Code activation will be implemented in a follow-up.",
importConfig: "Import current config",
importConfigConfirm: "Importing will overwrite unsaved changes. Continue?",
importConfigFailed: "Import failed: {message}",
capabilities: "Capabilities",
// Languages
language: "Language",
zh: "中文",
en: "English",
// Validation errors
emptyProviderName: "Provider name is empty",
duplicateProviderName: "Duplicate provider name: {0}",
missingCredentials: "Provider '{0}' is missing credentials (api_key or env key)",
ambiguousCredentials: "Provider '{0}' has both direct and env credentials; only one source is allowed",
missingVertexProject: "Vertex provider '{0}' is missing GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_LOCATION",
emptyModelAlias: "Model alias is empty",
duplicateModelAlias: "Duplicate model alias: {0}",
unknownModelProvider: "Model '{0}' references unknown provider '{1}'",
emptyModelId: "Model '{0}' has an empty model ID",
invalidMaxContextSize: "Model '{0}' has an invalid max_context_size",
};
+82
View File
@@ -0,0 +1,82 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import { enTranslations } from "./en";
import { zhTranslations, type TranslationKey } from "./zh";
export type Language = "zh" | "en";
const translations = {
zh: zhTranslations,
en: enTranslations,
};
function interpolate(template: string, params?: Record<string, string | number>): string {
if (!params) return template;
return template.replace(/\{(\w+)\}/g, (_, key) => String(params[key] ?? `{${key}}`));
}
function interpolatePositional(template: string, args: string[]): string {
return template.replace(/\{(\d+)\}/g, (_, index) => args[Number(index)] ?? `{${index}}`);
}
interface I18nContextValue {
lang: Language;
setLang: (lang: Language) => void;
t: (key: TranslationKey, params?: Record<string, string | number>) => string;
tv: (code: string, args: string[]) => string;
}
const I18nContext = createContext<I18nContextValue | null>(null);
const STORAGE_KEY = "pi-switch-lang";
function getInitialLang(): Language {
try {
const stored = localStorage.getItem(STORAGE_KEY) as Language | null;
if (stored === "zh" || stored === "en") return stored;
} catch {
// ignore
}
const browser = navigator.language.toLowerCase();
return browser.startsWith("zh") ? "zh" : "en";
}
export function I18nProvider({ children }: { children: ReactNode }) {
const [lang, setLangState] = useState<Language>(getInitialLang);
const setLang = (next: Language) => {
setLangState(next);
try {
localStorage.setItem(STORAGE_KEY, next);
} catch {
// ignore
}
};
useEffect(() => {
document.documentElement.lang = lang === "zh" ? "zh-CN" : "en";
}, [lang]);
const t = (key: TranslationKey, params?: Record<string, string | number>) => {
const template = translations[lang][key];
return interpolate(template ?? key, params);
};
const tv = (code: string, args: string[]) => {
const template = translations[lang][code as TranslationKey];
return interpolatePositional(template ?? code, args);
};
return (
<I18nContext.Provider value={{ lang, setLang, t, tv }}>
{children}
</I18nContext.Provider>
);
}
export function useTranslation() {
const ctx = useContext(I18nContext);
if (!ctx) {
throw new Error("useTranslation must be used within I18nProvider");
}
return ctx;
}
+111
View File
@@ -0,0 +1,111 @@
export const zhTranslations = {
// App / global
appTitle: "Pi Switch",
loading: "加载中...",
loadTimeout: "加载时间较长,如果一直卡住,请尝试重新启动应用。",
loadFailed: "加载失败:",
retry: "重试",
saveConfig: "保存配置",
reloadConfig: "读取配置",
openConfigDir: "打开配置目录",
unsavedConfirm: "有未保存修改,确定要重新读取配置吗?",
providerNotFound: "供应商不存在",
backToList: "返回列表",
syncToOmp: "同步到 OMP",
syncedToOmp: "已同步到 OMP",
ompOverwriteConfirm: "OMP 中已存在供应商「{existing}」,是否替换为「{name}」?",
deleteProviderConfirm: "确定删除供应商 {name}",
// Provider list
providers: "供应商",
addProvider: "添加供应商",
noProviders: "暂无供应商",
addFirstProvider: "添加第一个供应商",
noUrl: "无 URL",
modelCount: "{count} 个模型",
ompActive: "OMP 活跃",
defaultModel: "默认: {name}",
edit: "编辑",
delete: "删除",
// Provider edit
editProvider: "编辑供应商",
back: "返回",
basicInfo: "基本信息",
modelMapping: "模型映射",
configJson: "配置 JSON",
providerName: "供应商名称",
note: "备注",
notePlaceholder: "例如:公司专用账号",
officialUrl: "官网链接",
managedProvider: "托管供应商(无需凭证)",
apiSettings: "API 设置",
apiFormat: "API 格式",
authField: "认证字段",
apiKey: "API Key",
show: "显示",
hide: "隐藏",
requestUrl: "请求地址",
fullUrl: "完整 URL",
baseUrlHint: "填写兼容 {type} API 的服务端点地址,不要以斜杠结尾",
envPairs: "Env 键值对",
addEnv: "+ 添加",
modelMappingDesc: "显示名称只影响 /model 菜单;1M 只是给 Kimi Code 的上下文能力声明。",
oneClickSetup: "一键设置",
fetchModels: "获取模型列表",
fetchingModels: "获取中...",
discoveredModels: "发现 {count} 个模型",
addSelected: "添加选中的",
modelRole: "模型角色",
displayName: "显示名称",
actualModel: "实际请求模型",
contextSize: "上下文长度",
supports1M: "声明支持 1M",
default: "默认",
operation: "操作",
isDefault: "已默认",
setDefault: "设为默认",
deleteModelConfirm: "确定删除模型 {alias}",
noModelMappings: "暂无模型映射",
addModelMapping: "+ 添加模型映射",
readOnlyPreview: "可编辑 JSON",
apply: "应用",
invalidJson: "JSON 格式错误:{message}",
// Agent / target
target: "目标",
targetKimi: "Kimi Code",
targetOmp: "OMP",
targetQwen: "Qwen Code",
agent: "智能体",
agentKimiCode: "Kimi Code",
agentPi: "Pi",
activate: "启用",
activated: "已启用",
disabled: "已停用",
unsavedChanges: "未保存修改",
qwenActivateNotImplemented: "Qwen Code 启用功能将在后续实现。",
importConfig: "导入当前配置",
importConfigConfirm: "导入会覆盖当前未保存的修改,是否继续?",
importConfigFailed: "导入失败:{message}",
capabilities: "能力标签",
// Languages
language: "语言",
zh: "中文",
en: "English",
// Validation errors
emptyProviderName: "供应商名称为空",
duplicateProviderName: "重复的供应商名称:{0}",
missingCredentials: "供应商「{0}」缺少凭证(api_key 或环境变量键)",
ambiguousCredentials: "供应商「{0}」同时设置了直接凭证和 Env 凭证,只允许一种",
missingVertexProject: "Vertex 供应商「{0}」缺少 GOOGLE_CLOUD_PROJECT 或 GOOGLE_CLOUD_LOCATION",
emptyModelAlias: "模型别名为空",
duplicateModelAlias: "重复的模型别名:{0}",
unknownModelProvider: "模型「{0}」引用了未知供应商「{1}」",
emptyModelId: "模型「{0}」的模型 ID 为空",
invalidMaxContextSize: "模型「{0}」的 max_context_size 无效",
} as const;
export type TranslationKey = keyof typeof zhTranslations;
+33
View File
@@ -7,3 +7,36 @@ html, body, #root {
margin: 0; margin: 0;
overflow: hidden; overflow: hidden;
} }
:root {
color-scheme: dark;
}
body {
background-color: #0f0f11;
color: #e5e5e7;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
/* Custom scrollbar for dark theme */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #1a1a1e;
}
::-webkit-scrollbar-thumb {
background: #3a3a42;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #4a4a52;
}
input, select, textarea {
color-scheme: dark;
}
+44 -1
View File
@@ -1,10 +1,53 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { invoke } from "@tauri-apps/api/core";
import App from "./App"; import App from "./App";
import { I18nProvider } from "./i18n";
import "./index.css"; import "./index.css";
class ErrorBoundary extends React.Component<
{ children: React.ReactNode },
{ error: Error | null }
> {
constructor(props: { children: React.ReactNode }) {
super(props);
this.state = { error: null };
}
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
const payload = `${error.toString()}\n${info.componentStack || ""}`;
console.error("React render error:", payload);
try {
invoke("debug_log", { message: payload }).catch(() => {});
} catch {
// ignore
}
}
render() {
if (this.state.error) {
return (
<div style={{ padding: 16, color: "#ff6b6b", whiteSpace: "pre-wrap" }}>
<h2>Render error</h2>
<pre>{this.state.error.toString()}</pre>
<pre>{(this.state.error as Error).stack}</pre>
</div>
);
}
return this.props.children;
}
}
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<App /> <I18nProvider>
<ErrorBoundary>
<App />
</ErrorBoundary>
</I18nProvider>
</React.StrictMode> </React.StrictMode>
); );
+57
View File
@@ -0,0 +1,57 @@
export type Agent = "kimi_code" | "pi";
export type ProviderType =
| "kimi"
| "anthropic"
| "openai"
| "openai_responses"
| "google-genai"
| "vertexai";
export interface Provider {
name: string;
provider_type: ProviderType;
base_url: string | null;
api_key: string | null;
env: Record<string, string>;
/** Optional note / remark for the provider. */
note?: string | null;
/** Optional official website URL. */
official_url?: string | null;
/** Managed/OAuth providers skip credential validation. */
managed?: boolean;
/** Whether the provider is enabled/activated. */
enabled?: boolean;
/** Extra agent-specific provider fields preserved across edits. */
raw_other?: unknown;
}
export interface Model {
alias: string;
provider: string;
model: string;
max_context_size: number;
display_name: string | null;
/** Optional model role, e.g. Sonnet/Opus/Fable/Haiku for Claude-style mapping. */
role?: string | null;
/** Whether the model declares extended context / thinking support. */
supports_1m?: boolean;
/** Agent-specific capability flags (e.g. Kimi Code `capabilities`). */
capabilities?: string[];
/** Extra agent-specific model fields preserved across edits. */
raw_other?: unknown;
}
export interface Config {
default_model: string | null;
providers: Record<string, Provider>;
models: Record<string, Model>;
raw_other?: unknown;
}
/** A model discovered from a provider's API endpoint. */
export interface DiscoveredModel {
id: string;
display_name: string | null;
max_context_size: number | null;
}