feat: implement Pi Switch i18n UI and agent config switch
@@ -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*
|
||||||
|
|||||||
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
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 don’t 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,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>
|
||||||
|
|||||||
@@ -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,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "kimiswitch",
|
"name": "piswitch",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -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 |
@@ -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.")
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 484 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 654 B |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 874 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
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>
|
||||||
|
After Width: | Height: | Size: 993 B |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 987 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
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>
|
||||||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 506 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 478 B |
|
After Width: | Height: | Size: 810 B |
|
After Width: | Height: | Size: 810 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 634 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 810 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
@@ -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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||