diff --git a/.gitignore b/.gitignore index a8ab969..0aa6617 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ node_modules/ dist/ src-tauri/target/ +src-tauri/gen/ +src-tauri/wix314-binaries/ .vscode/ *.log .env* diff --git a/Snipaste_2026-07-07_16-28-01.png b/Snipaste_2026-07-07_16-28-01.png new file mode 100644 index 0000000..80d2acf Binary files /dev/null and b/Snipaste_2026-07-07_16-28-01.png differ diff --git a/Snipaste_2026-07-07_16-28-30.png b/Snipaste_2026-07-07_16-28-30.png new file mode 100644 index 0000000..1334f56 Binary files /dev/null and b/Snipaste_2026-07-07_16-28-30.png differ diff --git a/Snipaste_2026-07-07_17-13-14.png b/Snipaste_2026-07-07_17-13-14.png new file mode 100644 index 0000000..e0a755c Binary files /dev/null and b/Snipaste_2026-07-07_17-13-14.png differ diff --git a/Snipaste_2026-07-07_17-39-23.png b/Snipaste_2026-07-07_17-39-23.png new file mode 100644 index 0000000..d16efef Binary files /dev/null and b/Snipaste_2026-07-07_17-39-23.png differ diff --git a/Snipaste_2026-07-07_17-40-54.png b/Snipaste_2026-07-07_17-40-54.png new file mode 100644 index 0000000..17ab0ff Binary files /dev/null and b/Snipaste_2026-07-07_17-40-54.png differ diff --git a/Snipaste_2026-07-07_23-03-39.png b/Snipaste_2026-07-07_23-03-39.png new file mode 100644 index 0000000..ff80bef Binary files /dev/null and b/Snipaste_2026-07-07_23-03-39.png differ diff --git a/docs/superpowers/plans/2026-07-07-i18n-ui-agent-switch.md b/docs/superpowers/plans/2026-07-07-i18n-ui-agent-switch.md new file mode 100644 index 0000000..6567850 --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-i18n-ui-agent-switch.md @@ -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 `` 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 +
+ ...
+
+``` + +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` `` 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. diff --git a/index.html b/index.html index 84a0147..868db57 100644 --- a/index.html +++ b/index.html @@ -2,9 +2,9 @@ <html lang="zh-CN"> <head> <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" /> - <title>KimiSwitch + Pi Switch
diff --git a/package-lock.json b/package-lock.json index 7e58c71..e15ae53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "kimiswitch", + "name": "piswitch", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "kimiswitch", + "name": "piswitch", "version": "0.1.0", "dependencies": { "@tauri-apps/api": "^2.0.0", diff --git a/package.json b/package.json index 91480ac..8f5fc39 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "kimiswitch", + "name": "piswitch", "private": true, "version": "0.1.0", "type": "module", diff --git a/public/pi.svg b/public/pi.svg new file mode 100644 index 0000000..55a4412 --- /dev/null +++ b/public/pi.svg @@ -0,0 +1,10 @@ + + + + + + + + + π + \ No newline at end of file diff --git a/scripts/generate-icons.py b/scripts/generate-icons.py new file mode 100644 index 0000000..6d7b0e0 --- /dev/null +++ b/scripts/generate-icons.py @@ -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 = ''' + + + + + + + + π +''' +with open(SVG_PATH, "w", encoding="utf-8") as f: + f.write(svg) + +print("Icons generated.") diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5117444..10a703c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -47,6 +47,137 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "atk" version = "0.18.2" @@ -142,6 +273,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "brotli" version = "8.0.4" @@ -309,6 +453,23 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core", +] + [[package]] name = "chrono" version = "0.4.45" @@ -333,6 +494,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "cookie" version = "0.18.1" @@ -392,6 +562,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -725,12 +904,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" [[package]] -name = "encoding_rs" -version = "0.8.35" +name = "endi" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" dependencies = [ - "cfg-if", + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", ] [[package]] @@ -760,6 +957,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -881,6 +1099,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -1036,8 +1267,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1059,8 +1292,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core", + "wasm-bindgen", ] [[package]] @@ -1235,6 +1471,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1310,6 +1552,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1658,13 +1916,13 @@ dependencies = [ "chrono", "dirs 5.0.1", "indexmap 2.14.0", + "reqwest 0.12.28", "serde", "serde_json", "tauri", "tauri-build", - "tauri-plugin-shell", - "thiserror 1.0.69", - "toml_edit 0.22.27", + "tauri-plugin-opener", + "toml 0.8.23", ] [[package]] @@ -1725,6 +1983,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -1746,6 +2010,12 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "markup5ever" version = "0.38.0" @@ -2106,13 +2376,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] -name = "os_pipe" -version = "1.2.3" +name = "ordered-stream" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" dependencies = [ - "libc", - "windows-sys 0.61.2", + "futures-core", + "pin-project-lite", ] [[package]] @@ -2140,6 +2410,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2228,6 +2504,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -2273,6 +2560,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2364,6 +2665,62 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.46" @@ -2385,6 +2742,32 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -2471,6 +2854,44 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "reqwest" version = "0.13.4" @@ -2505,6 +2926,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -2520,12 +2955,66 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -2716,6 +3205,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.21.0" @@ -2786,48 +3287,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] -[[package]] -name = "shared_child" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7" -dependencies = [ - "libc", - "sigchld", - "windows-sys 0.60.2", -] - [[package]] name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" -[[package]] -name = "sigchld" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1" -dependencies = [ - "libc", - "os_pipe", - "signal-hook", -] - -[[package]] -name = "signal-hook" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" -dependencies = [ - "libc", - "signal-hook-registry", -] - [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -2956,6 +3425,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3108,7 +3583,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.4", "serde", "serde_json", "serde_repr", @@ -3208,24 +3683,25 @@ dependencies = [ ] [[package]] -name = "tauri-plugin-shell" -version = "2.3.5" +name = "tauri-plugin-opener" +version = "2.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8457dbf9e2bab1edd8df22bb2c20857a59a9868e79cb3eac5ed639eec4d0c73b" +checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" dependencies = [ - "encoding_rs", - "log", + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", "open", - "os_pipe", - "regex", "schemars 0.8.22", "serde", "serde_json", - "shared_child", "tauri", "tauri-plugin", "thiserror 2.0.18", - "tokio", + "url", + "windows", + "zbus", ] [[package]] @@ -3328,6 +3804,19 @@ dependencies = [ "toml 1.1.2+spec-1.1.0", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.5.0" @@ -3447,6 +3936,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -3650,9 +4149,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -3702,6 +4213,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -3755,6 +4277,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -3948,6 +4476,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.5" @@ -4004,6 +4542,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -4245,20 +4792,20 @@ dependencies = [ [[package]] name = "windows-sys" -version = "0.59.0" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ "windows-targets 0.52.6", ] [[package]] name = "windows-sys" -version = "0.60.2" +version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.53.5", + "windows-targets 0.52.6", ] [[package]] @@ -4309,30 +4856,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link 0.2.1", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows-threading" version = "0.1.0" @@ -4369,12 +4899,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -4393,12 +4917,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -4417,24 +4935,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -4453,12 +4959,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -4477,12 +4977,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -4501,12 +4995,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -4525,12 +5013,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "0.5.40" @@ -4668,6 +5150,67 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.3", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +dependencies = [ + "serde", + "winnow 1.0.3", + "zvariant", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -4689,6 +5232,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.4" @@ -4727,3 +5276,43 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.3", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.118", + "winnow 1.0.3", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0e78e1f..6a25c86 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "kimiswitch" version = "0.1.0" -description = "Kimi Code CLI config manager" +description = "Pi Switch - model config manager" authors = ["you"] edition = "2021" @@ -10,15 +10,15 @@ tauri-build = { version = "2.0.0", features = [] } [dependencies] 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_json = "1.0" -toml_edit = "0.22" indexmap = { version = "2.2", features = ["serde"] } -dirs = "5.0" -thiserror = "1.0" chrono = "0.4" anyhow = "1.0" +reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } +dirs = "5.0" +toml = "0.8" [lib] name = "kimiswitch_lib" diff --git a/src-tauri/build.rs b/src-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json new file mode 100644 index 0000000..620b328 --- /dev/null +++ b/src-tauri/capabilities/default.json @@ -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" + ] +} diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png new file mode 100644 index 0000000..5a75484 Binary files /dev/null and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..3d539e7 Binary files /dev/null and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png new file mode 100644 index 0000000..274362a Binary files /dev/null and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png new file mode 100644 index 0000000..60e985d Binary files /dev/null and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000..d494625 Binary files /dev/null and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 0000000..85865cd Binary files /dev/null and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000..8fff50e Binary files /dev/null and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000..edb080b Binary files /dev/null and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 0000000..dcc4735 Binary files /dev/null and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000..6d4ee60 Binary files /dev/null and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000..ef0ea8a Binary files /dev/null and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000..4c44253 Binary files /dev/null and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000..4a507dc Binary files /dev/null and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png new file mode 100644 index 0000000..9fb0492 Binary files /dev/null and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..2ffbf24 --- /dev/null +++ b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..94619d2 Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..1a8a6cd Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..bd39060 Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..f849f27 Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..83cd1ce Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..c2fcac1 Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..d3cbcc3 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..e53499c Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..6c536fc Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..bb07b47 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..62f5401 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..5a6ce52 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..0b8f738 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..5d533f6 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..1a8df10 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/values/ic_launcher_background.xml b/src-tauri/icons/android/values/ic_launcher_background.xml new file mode 100644 index 0000000..ea9c223 --- /dev/null +++ b/src-tauri/icons/android/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #fff + \ No newline at end of file diff --git a/src-tauri/icons/icon-source.png b/src-tauri/icons/icon-source.png new file mode 100644 index 0000000..69abf33 Binary files /dev/null and b/src-tauri/icons/icon-source.png differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns new file mode 100644 index 0000000..16180da Binary files /dev/null and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico new file mode 100644 index 0000000..c767b39 Binary files /dev/null and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png new file mode 100644 index 0000000..8d0752a Binary files /dev/null and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@1x.png b/src-tauri/icons/ios/AppIcon-20x20@1x.png new file mode 100644 index 0000000..3423d7b Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png new file mode 100644 index 0000000..5657b28 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x.png b/src-tauri/icons/ios/AppIcon-20x20@2x.png new file mode 100644 index 0000000..5657b28 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@3x.png b/src-tauri/icons/ios/AppIcon-20x20@3x.png new file mode 100644 index 0000000..426b951 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@1x.png b/src-tauri/icons/ios/AppIcon-29x29@1x.png new file mode 100644 index 0000000..1e4f81a Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png new file mode 100644 index 0000000..429a5af Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x.png b/src-tauri/icons/ios/AppIcon-29x29@2x.png new file mode 100644 index 0000000..429a5af Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@3x.png b/src-tauri/icons/ios/AppIcon-29x29@3x.png new file mode 100644 index 0000000..82dd4ad Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@1x.png b/src-tauri/icons/ios/AppIcon-40x40@1x.png new file mode 100644 index 0000000..5657b28 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png new file mode 100644 index 0000000..3f17361 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x.png b/src-tauri/icons/ios/AppIcon-40x40@2x.png new file mode 100644 index 0000000..3f17361 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@3x.png b/src-tauri/icons/ios/AppIcon-40x40@3x.png new file mode 100644 index 0000000..61e24b0 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-512@2x.png b/src-tauri/icons/ios/AppIcon-512@2x.png new file mode 100644 index 0000000..9b37f23 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@2x.png b/src-tauri/icons/ios/AppIcon-60x60@2x.png new file mode 100644 index 0000000..61e24b0 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@3x.png b/src-tauri/icons/ios/AppIcon-60x60@3x.png new file mode 100644 index 0000000..2306b3a Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@1x.png b/src-tauri/icons/ios/AppIcon-76x76@1x.png new file mode 100644 index 0000000..e1b5cd1 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@2x.png b/src-tauri/icons/ios/AppIcon-76x76@2x.png new file mode 100644 index 0000000..6f5d0d2 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png new file mode 100644 index 0000000..40d0df9 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 758f49f..53894c9 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -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::>() + .join(": ") +} #[tauri::command] -pub fn load_config_command() -> Config { - Config { - default_model: None, - providers: indexmap::IndexMap::new(), - models: indexmap::IndexMap::new(), - raw_other: toml_edit::Table::new(), +pub fn debug_log(message: String) { + eprintln!("[frontend] {}", message); +} + +#[tauri::command] +pub fn load_agent_config_command(agent: Agent) -> Result { + 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] -pub fn save_config_command(_config: Config) -> Result<(), String> { - Ok(()) -} - -#[tauri::command] -pub fn list_profiles() -> Vec { - 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(), +pub fn save_agent_config_command(agent: Agent, config: Config) -> Result<(), String> { + match agent { + Agent::KimiCode => crate::kimi_code_io::save_config_as_kimi_code(&config).map_err(fmt_anyhow), + Agent::Pi => { + let file = pi_io::config_to_pi_file(&config); + pi_io::save_pi_models(&file).map_err(fmt_anyhow) + } } } #[tauri::command] -pub fn save_profile(_filename: String, _config: Config) -> Result<(), String> { - Ok(()) -} - -#[tauri::command] -pub fn switch_profile(_filename: String) -> Result { - Ok(Config { - 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 { - Ok(String::new()) -} - -#[tauri::command] -pub fn delete_profile(_filename: String) -> Result<(), String> { - Ok(()) -} - -#[tauri::command] -pub fn open_config_dir() -> Result<(), String> { - Ok(()) +pub fn open_agent_config_dir(app: tauri::AppHandle, agent: Agent) -> Result<(), String> { + 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(); + app.opener() + .open_path(&path_str, None::<&str>) + .map_err(|e| e.to_string()) } #[tauri::command] pub fn get_app_version() -> 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, 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 { + 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, 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, + } + + 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, 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, + } + #[derive(serde::Deserialize)] + struct AntList { + data: Vec, + } + + 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, 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, + #[serde(rename = "outputTokenLimit")] + output_token_limit: Option, + } + #[derive(serde::Deserialize)] + struct GglList { + models: Vec, + } + + 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()) +} diff --git a/src-tauri/src/kimi_code_io.rs b/src-tauri/src/kimi_code_io.rs new file mode 100644 index 0000000..4ff2e55 --- /dev/null +++ b/src-tauri/src/kimi_code_io.rs @@ -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 = anyhow::Result; + +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 { + 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 = 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 { + 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 { + 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"))); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index eb36fa9..daa46bd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,23 +1,18 @@ pub mod commands; -pub mod config_io; +pub mod kimi_code_io; pub mod models; -pub mod profile_manager; -pub mod validators; +pub mod pi_io; pub fn run() { tauri::Builder::default() - .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_opener::init()) .invoke_handler(tauri::generate_handler![ - commands::load_config_command, - commands::save_config_command, - commands::list_profiles, - commands::load_profile, - commands::save_profile, - commands::switch_profile, - commands::rename_profile, - commands::delete_profile, - commands::open_config_dir, + commands::load_agent_config_command, + commands::save_agent_config_command, + commands::open_agent_config_dir, commands::get_app_version, + commands::list_provider_models, + commands::debug_log, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index eb4ec10..d626820 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -1,11 +1,27 @@ use indexmap::IndexMap; 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")] pub enum ProviderType { - Kimi, Anthropic, Openai, #[serde(rename = "openai_responses")] @@ -13,19 +29,42 @@ pub enum ProviderType { #[serde(rename = "google-genai")] GoogleGenai, Vertexai, + /// Kept for compatibility; mapped to OpenAI-compatible in Pi. + Kimi, +} + +const fn default_true() -> bool { + true } impl ProviderType { pub fn as_str(&self) -> &'static str { match self { - ProviderType::Kimi => "kimi", ProviderType::Anthropic => "anthropic", ProviderType::Openai => "openai", ProviderType::OpenaiResponses => "openai_responses", ProviderType::GoogleGenai => "google-genai", 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)] @@ -34,11 +73,37 @@ pub struct Provider { pub provider_type: ProviderType, pub base_url: Option, pub api_key: Option, + #[serde(default)] pub env: IndexMap, - #[serde(skip)] - pub raw_other: Table, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub official_url: Option, + #[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 { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Provider") @@ -47,53 +112,107 @@ impl std::fmt::Debug for Provider { .field("base_url", &self.base_url) .field("api_key", &"") .field("env", &"") - .field("raw_other", &"") + .field("managed", &self.managed) + .field("enabled", &self.enabled) + .field("raw_other", &"") .finish() } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Clone, Serialize, Deserialize)] pub struct Model { pub alias: String, pub provider: String, pub model: String, pub max_context_size: u64, pub display_name: Option, - #[serde(skip)] - pub raw_other: Table, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub role: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub supports_1m: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub capabilities: Vec, + #[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", &"") + .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)] pub struct Config { pub default_model: Option, + #[serde(default)] pub providers: IndexMap, + #[serde(default)] pub models: IndexMap, - #[serde(skip)] - pub raw_other: Table, + #[serde(default, skip_serializing_if = "Value::is_null")] + 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 { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Config") .field("default_model", &self.default_model) - .field("providers", &format!("{} providers", self.providers.len())) + .field( + "providers", + &format!( + "{:?} ({} providers)", + self.providers.keys().collect::>(), + self.providers.len() + ), + ) .field("models", &self.models) - .field("raw_other", &"
") + .field("raw_other", &"") .finish() } } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProfileSummary { - pub name: String, - pub filename: String, - pub is_active: bool, -} - -#[derive(Debug, Clone)] -pub struct Profile { - pub name: String, - pub filename: String, - pub config: Config, - pub is_active: bool, +/// A model discovered from a provider's API endpoint. +/// The frontend uses this to populate a `Model` form entry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DiscoveredModel { + pub id: String, + pub display_name: Option, + pub max_context_size: Option, } diff --git a/src-tauri/src/pi_io.rs b/src-tauri/src/pi_io.rs new file mode 100644 index 0000000..1769b11 --- /dev/null +++ b/src-tauri/src/pi_io.rs @@ -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 = anyhow::Result; + +/// 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, + #[serde(default)] + pub reasoning: bool, + #[serde(default = "default_input", skip_serializing_if = "is_default_input")] + pub input: Vec, + #[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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cost: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub compat: Option, + #[serde(flatten)] + pub extra: Value, +} + +fn default_input() -> Vec { + 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api: Option, + #[serde(rename = "apiKey", default, skip_serializing_if = "Option::is_none")] + pub api_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub headers: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub compat: Option, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub models: Vec, + #[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, + #[serde(default)] + pub providers: IndexMap, + #[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 { + 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); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 94246bb..8d60681 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { - "productName": "KimiSwitch", + "productName": "Pi Switch", "version": "0.1.0", - "identifier": "com.kimiswitch.app", + "identifier": "com.piswitch.app", "build": { "beforeDevCommand": "npm run dev", "beforeBuildCommand": "npm run build", @@ -11,7 +11,7 @@ "app": { "windows": [ { - "title": "KimiSwitch", + "title": "Pi Switch", "width": 1200, "height": 800, "minWidth": 1000, @@ -21,11 +21,23 @@ } ], "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": { "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" + ] } } diff --git a/src/App.tsx b/src/App.tsx index 77fc199..0e2434c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,470 @@ -function App() { - return
KimiSwitch
; +import { useEffect, useMemo, useState } from "react"; +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(getInitialAgent); + const { + config, + dirty, + error, + loading, + refresh, + save, + updateConfig, + } = useConfig(agent); + + const [view, setView] = useState<"list" | "edit">("list"); + const [editingProvider, setEditingProvider] = useState(""); + 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 ( +
+
{t("loading")}
+ {loadTimeout && ( +
{t("loadTimeout")}
+ )} + {error && ( +
+
{t("loadFailed")}
+
{error}
+ +
+ )} +
+ ); + } + + const currentProvider = config.providers[editingProvider]; + + return ( +
+
+
+ {AGENTS.map(({ key, label }) => ( + + ))} +
+
+ +
+
+ + {error && ( +
+ {error} +
+ )} + +
+ {view === "list" ? ( + { + setEditingProvider(name); + setView("edit"); + }} + onDelete={handleDeleteProvider} + onAdd={handleAddProvider} + onToggleEnabled={handleToggleProviderEnabled} + /> + ) : currentProvider ? ( + 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} + /> + ) : ( +
+
{t("providerNotFound")}
+ +
+ )} +
+ +
+
+ {dirty && ( + ● {t("unsavedChanges")} + )} +
+
+ + + +
+
+
+ ); +} diff --git a/src/components/ProviderEdit.tsx b/src/components/ProviderEdit.tsx new file mode 100644 index 0000000..a978d61 --- /dev/null +++ b/src/components/ProviderEdit.tsx @@ -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 ( +
+ {/* Header */} +
+ +

{t("editProvider")}

+
+ + +
+ + {/* Tabs */} +
+ {[ + { key: "basic", label: t("basicInfo") }, + { key: "models", label: t("modelMapping") }, + { key: "json", label: t("configJson") }, + ].map((tab) => ( + + ))} +
+ + {/* Content */} +
+ {activeTab === "basic" && ( +
+ {/* Basic info */} +
+

{t("basicInfo")}

+
+
+ + onChange({ ...provider, name: e.target.value })} + /> +
+
+ + + onChange({ ...provider, note: e.target.value || null }) + } + /> +
+
+ + + onChange({ ...provider, official_url: e.target.value || null }) + } + /> +
+
+ + onChange({ ...provider, managed: e.target.checked }) + } + className="w-4 h-4 rounded border-[#2a2a2e] bg-[#1f1f23] text-blue-600 focus:ring-blue-500" + /> + +
+
+
+ + {/* API settings */} +
+

{t("apiSettings")}

+
+
+
+ + +
+
+ + {!provider.managed && ( +
+ +
+ + onChange({ ...provider, api_key: e.target.value || null }) + } + /> + +
+
+ )} + +
+ + + onChange({ ...provider, base_url: e.target.value || null }) + } + /> +
+ {t("baseUrlHint", { type: provider.provider_type })} +
+
+
+
+
+ )} + + {activeTab === "models" && ( + + )} + + {activeTab === "json" && ( + + )} +
+
+ ); +} + +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(null); + const [selected, setSelected] = useState>(new Set()); + const [discoverError, setDiscoverError] = useState(null); + + const handleDiscover = async () => { + setDiscovering(true); + setDiscoverError(null); + setDiscovered(null); + setSelected(new Set()); + try { + const result = await invoke("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 ( +
+
+
+

{t("modelMapping")}

+

{t("modelMappingDesc")}

+
+
+ + +
+
+ + {discoverError && ( +
+ {discoverError} +
+ )} + + {discovered && ( +
+
+ {t("discoveredModels", { count: discovered.length })} +
+
+ {discovered.map((m) => ( + + ))} +
+ +
+ )} + +
+
+ + + + + + + + {agent === "kimi_code" && ( + + )} + + + + + + {models.map((m) => ( + + + + + + + {agent === "kimi_code" && ( + + )} + + + + ))} + +
{t("modelRole")}{t("displayName")}{t("actualModel")}{t("contextSize")}{t("supports1M")}{t("capabilities")}{t("default")}{t("operation")}
+ + onModelChange({ + ...m, + role: e.target.value || null, + }) + } + /> + + + onModelChange({ + ...m, + display_name: e.target.value || null, + }) + } + /> + + + onModelChange({ ...m, model: e.target.value }) + } + /> + + { + const value = parseInt(e.target.value, 10); + onModelChange({ + ...m, + max_context_size: isNaN(value) ? 0 : value, + }); + }} + /> + + + onModelChange({ + ...m, + supports_1m: e.target.checked, + }) + } + className="w-4 h-4 rounded border-[#2a2a2e] bg-[#1f1f23] text-blue-600 focus:ring-blue-500" + /> + + + onModelChange({ + ...m, + capabilities: e.target.value + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + }) + } + /> + + + + +
+ {models.length === 0 && ( +
+ {t("noModelMappings")} +
+ )} + + + + + ); +} + +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(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 ( +
+
+

{t("configJson")}

+
+ {t("readOnlyPreview")} + +
+
+ {error && ( +
+ {t("invalidJson", { message: error })} +
+ )} +