commit 549c8b5b138ef14aae4d6b2e91edbe4acd78d4da Author: 954801926@qq.com Date: Wed Jul 29 23:36:41 2026 +0800 chore: init main branch diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0942c13 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +node_modules/ +dist/ +src-tauri/target/ +src-tauri/gen/ +src-tauri/wix314-binaries/ +.vscode/ +*.log +.env* +.DS_Store +Thumbs.db +Snipaste_*.png +.worktrees/ + +# 内部设计规范与实施计划,仅供本地查阅,不入版本库 +docs/superpowers/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8410cd8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 CodingPlan.site + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..24f199e --- /dev/null +++ b/README.md @@ -0,0 +1,633 @@ +# Kimi Switch + +> Windows 桌面端的 **Kimi Code CLI** 配置管理器——统一管理多家 LLM 供应商、模型、图标、连通性、用量统计与版本更新。 + +[English](./README_EN.md) | **中文** + +[![Tauri](https://img.shields.io/badge/Tauri-v2-blue?logo=tauri)](https://tauri.app) +[![React](https://img.shields.io/badge/React-18-61dafb?logo=react)](https://react.dev) +[![TypeScript](https://img.shields.io/badge/TypeScript-5.5-3178c6?logo=typescript)](https://www.typescriptlang.org) +[![Rust](https://img.shields.io/badge/Rust-2021-ed764d?logo=rust)](https://www.rust-lang.org) +[![Version](https://img.shields.io/badge/release-v0.5.2-brightgreen)](https://git.codingplan.site/admin/KimiCodeSwitch/releases) +[![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE) + +--- + +## 目录 + +- [这是什么](#这是什么) +- [核心特性](#核心特性) +- [界面预览](#界面预览) +- [架构总览](#架构总览) +- [功能详情](#功能详情) +- [支持的供应商类型](#支持的供应商类型) +- [数据存储位置](#数据存储位置) +- [快速上手](#快速上手) +- [开发指南](#开发指南) +- [键盘快捷键](#键盘快捷键) +- [国际化](#国际化) +- [测试](#测试) +- [打包发布](#打包发布) +- [发布历史](#发布历史) +- [常见问题](#常见问题) +- [已知限制与后续建议](#已知限制与后续建议) +- [安全提示](#安全提示) +- [致谢](#致谢) + +--- + +## 这是什么 + +**Kimi Switch** 是一个为 [Kimi Code CLI](https://github.com/MoonshotAI/kimi-code) 用户打造的 Windows 桌面配置管理工具。它把 `~/.kimi-code/config.toml` 的手改工作搬到图形界面里,并补上一系列 CLI 自身没提供的便利: + +- **多供应商统一管理**:Kimi / Anthropic / OpenAI / OpenAI Responses / Google GenAI / Vertex AI,一个界面搞定;新增的供应商会**自动提升到列表最前**,再切换不会覆盖丢失 +- **模型一键发现**:根据供应商 API 自动拉取可用模型列表,模型显示名、上下文大小、能力等**优先取自 models.dev** 缓存(缺字段时再由后端/UI 兜底) +- **连通性测试**:参考 cc-switch 语义对 `base_url` 发 GET 请求,显示延迟气泡(绿/橙/红),6 秒自动消失 +- **重复供应商**:一键深拷贝现有供应商及其模型,改 key 到 `xxx-copy` 即可二次定制 +- **图标系统**:内置 100+ 主流供应商品牌图标,参考 cc-switch 实现;无匹配的供应商自动按命名规则生成首字母默认图标 +- **用量仪表盘**:移植自 kimicode-dashboard,KPI / 热力图 / 按模型分色每日趋势 / 最近请求翻页 +- **会话管理**:按工作区浏览、预览、归档、批量删除 Kimi Code 会话 +- **版本更新**:进入应用时自动检测(可关闭),并支持一键下载安装 +- **主题/语言切换**:设置面板中切换深色/浅色/跟随系统,简体中文/English +- **Windows 友好**:单实例;最小化保留任务栏按钮;关闭按钮隐藏到托盘 + +## 核心特性 + +| 分类 | 功能 | +| --- | --- | +| **多供应商** | Kimi / Anthropic / OpenAI / OpenAI Responses / Google GenAI / Vertex AI | +| **多 Agent** | 主目标 Kimi Code,Pi 代码保留但 UI 隐藏 | +| **图标系统** | 100+ 品牌图标 + 首字母默认图标;图标选择器按 brands / inference 分类 | +| **快捷切换** | 新增或激活的供应商自动提升到列表最前;切换只改 `default_model`,不丢其他供应商 | +| **连通性测试** | 实测 `base_url` 延迟,绿/橙/红彩色气泡,6 秒自动消失 | +| **重复供应商** | 一键深拷贝供应商 + 全部模型,key 改 `xxx-copy` | +| **图标按钮操作** | 启用 / 编辑 / 复制 / 测试连通 / 删除 全图标化(lucide-react) | +| **模型映射** | 别名("provider/model" 形式)↔ 实际请求模型 ID,自定义显示名、上下文长度、1M 上下文声明、能力 | +| **自动上下文** | 拉取模型时 **API 返回 > models.dev ref > 正则兜底** 三级优先级自动适配 | +| **能力自动推导** | `image_in / video_in / tool_use` 全部由 models.dev 推得;UI 仅暴露 `thinking` 一个手动开关 | +| **全局设置** | `[thinking]` 表完整支持(enabled / effort / keep),仅 Kimi Code 生效 | +| **JSON 直编** | 高级用户可手动编辑完整配置 JSON,未知字段通过 `raw_other` 透传不丢 | +| **i18n / 主题** | 简体中文 / English;深色 / 浅色 / 跟随系统;设置持久化 | +| **自动备份** | 写入 `config.toml` 前自动备份,按时间戳命名,保留最近 7 天 | +| **快捷键** | Ctrl+S 保存、Ctrl+R 重载、Ctrl+O 打开配置目录 | +| **用量仪表盘** | 8 项 KPI、每日趋势(按模型分色堆叠)、全年热力图、最近请求翻页、双击柱状图查看模型分布 | +| **会话管理** | 按工作区浏览、归档/复活、批量删除;流式逐行预览(20MB 字节上限 + 500 字符折叠) | +| **版本更新** | 启动自动 + 每 8 小时周期 + 手动检查;下载进度条;下载完成后引导安装 | +| **未保存提示** | 关闭窗口前原生 `beforeunload` 提示 + 标题栏 `*` 前缀 | +| **窗口/托盘** | 最小化保留任务栏;关闭 X 隐藏到托盘;托盘左键始终显示并聚焦 | + +## 界面预览 + +**供应商列表**(浅色主题) + +![供应商列表](docs/screenshots/providers.png) + +展示当前激活的供应商、默认模型、延迟气泡、可用模型数量与品牌图标;支持快速切换、复制、测试连通、跳转官网、编辑、删除。 + +**编辑供应商 — 基本信息** + +![编辑供应商-基本信息](docs/screenshots/provider-basic-info.png) + +包括供应商名称、备注、官网链接、托管供应商开关、API 格式、API Key、请求地址。 + +**编辑供应商 — 模型映射** + +![编辑供应商-模型映射](docs/screenshots/provider-model-mapping.png) + +一张表管理全部模型映射:显示名、实际请求模型、上下文长度、1M 上下文声明、能力(仅"思考")、设为默认、删除。 + +**用量仪表盘** + +![用量仪表盘](docs/screenshots/dashboard.png) + +8 项 KPI(请求数、非缓存输入、输出、缓存读/写/命中、总 Token、预估费用)+ 全年热力图 + 每日用量趋势(按模型分色堆叠柱状图,贴底布局)+ 模型用量明细 + 最近请求(分页)。 + +**会话管理** + +![会话管理](docs/screenshots/sessions.png) + +按工作区隔离浏览 Kimi Code 会话,支持活跃/已归档/全部筛选;流式逐行预览(20MB 字节上限,500 字符折叠);归档/取消归档/批量删除。 + +## 架构总览 + +``` +┌────────────────────────────────────────────────────────────────────────┐ +│ Kimi Switch (Tauri v2) │ +│ │ +│ ┌──────────────────────────┐ ┌──────────────────────────────┐ │ +│ │ React Frontend (TS) │ │ Rust Backend (lib.rs) │ │ +│ │ │ │ │ │ +│ │ src/App.tsx │ │ src-tauri/src/ │ │ +│ │ src/components/ │◄──►│ ├── lib.rs │ │ +│ │ ProviderList │ │ ├── main.rs │ │ +│ │ ProviderEdit │ │ ├── commands.rs │ │ +│ │ AgentSettingsPanel │ │ ├── db.rs │ │ +│ │ SettingsModal │ │ ├── kimi_code_io.rs │ │ +│ │ ProviderIcon / │ │ ├── pi_io.rs (legacy) │ │ +│ │ IconPicker │ │ ├── config_io.rs │ │ +│ │ dashboard/ │ │ ├── models.rs │ │ +│ │ sessions/ │ │ ├── validators.rs │ │ +│ │ src/hooks/ │ │ ├── dashboard.rs │ │ +│ │ useConfig / Dashboard│ │ └── profile_manager.rs │ │ +│ │ / Sessions / Theme │ │ │ │ +│ │ / UpdateCheck │ │ │ │ +│ │ src/lib/ │ │ │ │ +│ │ models-dev.ts │ │ │ │ +│ │ model-defaults.ts │ │ │ │ +│ │ agent-settings.ts │ │ │ │ +│ │ src/icons/ │ │ │ │ +│ │ brands / inference / │ │ │ │ +│ │ extracted (cc-switch)│ │ │ │ +│ │ src/i18n/{zh,en}.ts │ │ │ │ +│ │ src/types/{...} │ │ │ │ +│ └──────────────────────────┘ └──────────────────────────────┘ │ +│ │ │ │ +└──────────────────┼──────────────────────────────────┼──────────────────┘ + │ │ + ▼ ▼ + ┌──────────────────────┐ ┌──────────────────────────┐ + │ SQLite │ │ Agent 原生配置 │ + │ ~/.kimi-switch/ │ │ ├─ ~/.kimi-code/ │ + │ kimi-switch.db │ │ │ └─ config.toml │ + │ (元数据 + 兜底) │ │ └─ ~/.pi/agent/ │ + │ + localStorage │ │ ├─ models.json │ + │ (主题/语言/检查) │ │ └─ settings.json │ + └──────────────────────┘ └──────────────────────────┘ + ▲ + │ + ┌──────────────────────────────┐ + │ models.dev 快照(前端) │ + │ src/lib/models-dev.json │ + │ + 脚本 scripts/fetch-models- │ + │ dev.mjs(可选定时刷新) │ + └──────────────────────────────┘ +``` + +**关键设计**: + +- **`config.toml` 是 Kimi Code 的权威来源**:所有供应商和模型始终全量保留,`default_model` 决定哪个生效(与 CLI 原生 `/provider` 行为一致)。切换时只改 `default_model`,新增的供应商会被自动提升到列表最前,不会被覆盖 +- **SQLite 只存 Kimi Switch 专有元数据**:备注、官网、每个 Agent 记住的默认模型(`settings` 表)。主题 / 语言 / 上次更新检查时间存在前端 `localStorage`(WebView2),不在 `~/.kimi-switch` 下。`config.toml` 不完整时 SQLite 兜底 +- **`raw_other` 透传未知字段**:含 `[oauth]` 段,前后往返不丢字段 +- **models.dev 快照**:来源于 `https://models.dev/api.json`,本地 JSON 缓存 → `capabilitiesFromRef` 推导 `thinking/image_in/video_in/tool_use`,`getModelRef` 推导 `max_context_size/display_name` +- **启动版本与配置目录**:`KIMI_CODE_HOME` / `PI_CODING_AGENT_DIR` 覆盖 Kimi Code / Pi 的目录;Kimi Switch 自己的数据目录固定为 `~/.kimi-switch`,暂无环境变量覆盖(详见 [数据存储位置](#数据存储位置)) + +## 功能详情 + +### 供应商优先级与切换 + +- **新增/激活自动提升到列表最前**:添加或切换供应商时会把它移到 `db.providers` 排序的第一位,UI 直接渲染最新顺序 +- **切换只改 `default_model`**:所有供应商写入 `config.toml`,但只有被切换的成为 `default_model`,与 Kimi Code CLI `/provider` 命令一致 +- **不会覆盖丢失**:通过 `/provider` 在 CLI 端增/改/删的供应商,加载时以 `config.toml` 为准,下一次保存时全部回写 + +### 图标系统 + +- 来自 cc-switch 的 `src/icons/extracted/` 库(100+ 主流供应商品牌图标) +- 找不到精确匹配的供应商时,按命名规则生成**首字母默认图标**(例如 `kimi-code` → `K`,`deepseek-v4` → `D`) +- `IconPicker` 内置 brands / inference 分类筛选;选中的图标保存到 `provider.icon` 字段 + +### 连通性测试 + +- 调 Rust 端 `test_connectivity` 命令:GET `provider.base_url`,任何 HTTP 响应 = 可达 +- 返回 `{ ok, latency_ms, status_code, error }` +- 前端以**彩色气泡**形式内联展示(绿/橙/红 + 毫秒),6 秒后自动消失 +- 测速气泡位置在"使用中/切换"按钮之前,不挡眼 + +### 重复供应商 + +- 点击"复制"图标按钮 → 深拷贝供应商 + 全部模型 +- `provider.name` 加 `-copy` 后缀,模型自动改 key 为 `xxx-copy` +- 立即持久化到 SQLite + `config.toml`,toast 提示 + +### 自动上下文与能力 + +- 拉取模型时 `max_context_size` 三级优先级:**API 响应 > models.dev ref > 正则兜底** +- models.dev ref → `capabilities = ["thinking","image_in","video_in","tool_use"]`(按字段真值选) +- UI 上**只暴露"思考"复选框**(其它能力自动写入但不便手动编辑——保持与 Kimi Code 语义一致:能力只追加不能移除) +- `always_thinking` 只能手动加(models.dev 推导不出来),但当前 UI 屏蔽;需要时改 `config.toml` 即可 + +### 能力 vs 思考开关 + +- **模型能力**(`capabilities`)= "能不能":决定模型是否支持思考,没声明 `thinking` 即使全局开关开着也不生效 +- **全局 `[thinking]`**(设置面板)= "要不要":新会话默认开/关、强度(low/medium/high/max)、保留思考内容 +- `always_thinking` 锁死为开,忽略全局开关 +- 全局配置仅 Kimi Code 生效 + +### 用量仪表盘 + +- 8 项 KPI、每日趋势(按模型分色堆叠,**贴底布局**)、全年热力图(按 Token 量 5 级着色)、最近请求(30 条/页) +- 双击每日趋势柱状图 → 弹出 `DailyDetailModal` 显示每个模型的用量分布 +- 数据源:`src-tauri/src/dashboard.rs` + `src/hooks/useDashboard.ts` + +### 会话管理 + +- 按工作区隔离浏览 Kimi Code 会话,支持活跃/已归档/全部筛选 +- 流式逐行预览(20MB 字节上限,500 字符折叠可展开) +- 归档 / 取消归档 / 批量删除 +- 早期版本的"闪崩"已通过流式读取 + 限制解决 + +### 设置面板 + +- **主题**:深色 / 浅色 / 跟随系统(`useTheme`,持久化到前端 `localStorage`) +- **语言**:简体中文 / English +- **版本**:当前版本 + 上次检查时间 +- **更新检查**:启动自动 + 每 8 小时周期 + 手动检查;下载带进度条;下载完成后引导安装 + +### 窗口与托盘 + +- **最小化**:保留任务栏按钮,不再被劫持到托盘 +- **关闭按钮(X)**:`prevent_close` + `hide` → 隐藏到托盘而非退出 +- **托盘菜单**:显示 / 退出 +- **托盘左键**:始终 `show + unminimize + focus`(不再 toggle hide) +- **第二次启动**:`single_instance` 插件接住,回前台 + 聚焦 + +## 支持的供应商类型 + +| 类型 | 标识符 | 默认 base_url | 模型发现 | 凭证 | +| --- | --- | --- | --- | --- | +| Kimi | `kimi` | `https://api.openai.com/v1` | ✅ OpenAI 协议 | `KIMI_API_KEY` 或 `env.KIMI_API_KEY` | +| Anthropic | `anthropic` | — | ✅ `/v1/models` | `ANTHROPIC_API_KEY` 或 `env` | +| OpenAI | `openai` | `https://api.openai.com/v1` | ✅ `/models` | `OPENAI_API_KEY` 或 `env` | +| OpenAI Responses | `openai_responses` | `https://api.openai.com/v1` | ✅ `/models` | `OPENAI_API_KEY` 或 `env` | +| Google GenAI | `google-genai` | `https://generativelanguage.googleapis.com` | ✅ `/v1beta/models` | `GOOGLE_API_KEY` 或 `env` | +| Vertex AI | `vertexai` | — | ⚠️ 待实现 | `VERTEXAI_API_KEY` + `GOOGLE_CLOUD_PROJECT` + `GOOGLE_CLOUD_LOCATION` | + +凭证优先级:`api_key` 字段 > `env` 表里的同名键。 + +## 数据存储位置 + +| 文件 | 用途 | 备份 | +| --- | --- | --- | +| `%USERPROFILE%\.kimi-switch\kimi-switch.db` | Kimi Switch 自己的 SQLite,存元数据(备注/官网/记住的默认模型 + 排序)+ 兜底 | — | +| `%USERPROFILE%\.kimi-code\config.toml` | Kimi Code CLI 的 TOML 配置(**权威数据源,切换/保存时写入**) | 同目录 `backups/config.toml.bak.{YYYYMMDD_HHMMSS}`,保留 7 天 | +| `%USERPROFILE%\.pi\agent\models.json` | Pi 的供应商+模型配置(**切换时写入**) | 同目录 `backups/models.json.bak.{YYYYMMDD_HHMMSS}`,保留 7 天 | +| `%USERPROFILE%\.pi\agent\settings.json` | Pi 的默认供应商/模型(**切换时写入**) | 同目录 `backups/settings.json.bak.{YYYYMMDD_HHMMSS}`,保留 7 天 | +| WebView2 `localStorage` | 前端状态:`kimi-switch-theme` / `kimi-switch-lang` / `kimi-switch-last-update-check` / `kimi-switch-agent` / `kimi-switch-dashboard-range`(主题 / 语言 / 上次更新检查 / 上次选中 Agent / 仪表盘时间范围) | — | +| `src/lib/models-dev.json` | models.dev `api.json` 快照(前端内置) | — | + +环境变量覆盖: + +- `KIMI_CODE_HOME` 覆盖 Kimi Code 配置目录(默认 `~/.kimi-code`) +- `PI_CODING_AGENT_DIR` 覆盖 Pi Agent 配置目录(默认 `~/.pi/agent`) +- Kimi Switch 自己的数据目录固定为 `~/.kimi-switch`,**暂无环境变量覆盖** + +## 快速上手 + +### 前置依赖 + +| 工具 | 版本 | 说明 | +| --- | --- | --- | +| Node.js | ≥ 18 | 前端构建 | +| Rust | stable 最新版(edition 2021) | Tauri 后端编译 | +| WebView2 Runtime | Windows 10/11 默认已装 | Tauri v2 运行时 | +| Microsoft C++ Build Tools | 最新版 | Rust 编译依赖 | +| WiX Toolset 3.14 | `src-tauri/wix314-binaries/` | MSI 打包(首次构建自动下载) | + +### 安装 + +```bash +npm install +``` + +### 开发模式(热重载) + +```bash +npm run tauri-dev +``` + +同时启动 Vite 开发服务器(端口 1420)和 Tauri 窗口;前端热重载,Rust 自动重编译。 + +### 仅前端开发(无 Tauri 窗口) + +```bash +npm run dev +``` + +适用于纯 UI 调试。 + +## 开发指南 + +### 项目结构 + +``` +. +├── src/ # React 前端 +│ ├── App.tsx # 主组件,路由 ProviderList/ProviderEdit + 仪表盘/会话 +│ ├── main.tsx # React 入口 + ErrorBoundary + I18nProvider +│ ├── components/ +│ │ ├── ProviderList.tsx # 供应商列表 + 切换 / 复制 / 测试连通 / 编辑 / 删除 +│ │ ├── ProviderEdit.tsx # 供应商编辑 + 模型映射 + JSON 直编 + 能力 +│ │ ├── AgentSettingsPanel.tsx # Kimi Code 全局设置(思考/循环/权限/钩子) +│ │ ├── SettingsModal.tsx # 设置弹窗(主题 / 语言 / 版本 / 检查更新) +│ │ ├── ProviderIcon.tsx # 供商品牌图标(带首字母兜底) +│ │ ├── IconPicker.tsx # 图标选择器(brands / inference 分类) +│ │ ├── dashboard/ # 用量仪表盘 +│ │ │ ├── DashboardPage.tsx +│ │ │ ├── DailyBars.tsx +│ │ │ ├── DailyDetailModal.tsx +│ │ │ └── Heatmap.tsx +│ │ └── sessions/ # 会话管理 +│ │ └── SessionsPage.tsx +│ ├── hooks/ +│ │ ├── useConfig.ts # 加载/保存配置 +│ │ ├── useDashboard.ts # 仪表盘数据 +│ │ ├── useSessions.ts # 会话数据 +│ │ ├── useTheme.ts # 主题切换 +│ │ └── useUpdateCheck.ts # 版本检查 + 下载 +│ ├── lib/ +│ │ ├── agent-settings.ts # AgentSettings 解析/序列化 +│ │ ├── model-defaults.ts # 模型默认上下文大小 +│ │ ├── models-dev.ts # models.dev 快照查找 + 能力映射 +│ │ ├── models-dev.json # 内置快照 +│ │ └── dashboard-format.ts # 仪表盘格式化 +│ ├── icons/ +│ │ ├── brands.ts # 品牌图标入口 +│ │ ├── inference.ts # 推理服务图标 +│ │ └── extracted/ # 来自 cc-switch 的图标库 +│ │ ├── index.ts +│ │ └── metadata.ts +│ ├── types/ +│ │ ├── index.ts # Provider/Model/Config +│ │ ├── dashboard.ts +│ │ ├── sessions.ts +│ │ └── icon.ts +│ ├── i18n/ +│ │ ├── zh.ts # 中文翻译(源) +│ │ ├── en.ts # 英文翻译 +│ │ └── index.tsx # useTranslation hook + Provider +│ └── index.css # Tailwind 入口 +│ +├── src-tauri/ # Rust 后端 +│ ├── src/ +│ │ ├── lib.rs # Tauri Builder + 托盘 + 窗口事件 + invoke_handler +│ │ ├── main.rs # 二进制入口 +│ │ ├── commands.rs # ~14 个 Tauri Command +│ │ ├── db.rs # SQLite 持久化 +│ │ ├── kimi_code_io.rs # ~/.kimi-code/config.toml 读写 +│ │ ├── pi_io.rs # ~/.pi/agent/*.json 读写(保留) +│ │ ├── config_io.rs # 文件备份工具 +│ │ ├── models.rs # Config/Provider/Model 数据结构 +│ │ ├── profile_manager.rs # 多 Profile 管理(占位) +│ │ ├── validators.rs # 配置校验 +│ │ └── dashboard.rs # 会话/用量数据聚合 +│ ├── capabilities/ # Tauri 权限声明 +│ ├── icons/ # 应用图标(脚本生成) +│ └── tauri.conf.json # Tauri 配置(窗口/打包/CSP) +│ +├── scripts/ +│ ├── fetch-models-dev.mjs # 刷新 models-dev.json 快照 +│ └── generate-icons.py # 从 SVG 生成各尺寸图标 +├── public/kimi.svg # 应用图标源(蓝紫渐变 π) +└── docs/ + ├── screenshots/ # README 引用的界面截图 + └── superpowers/ # 设计规范与实施计划 +``` + +### Tauri Commands(前端 ↔ 后端) + +| 命令 | 说明 | +| --- | --- | +| `load_agent_config_command(agent)` | 加载配置:Kimi Code 以 `config.toml` 为权威 + SQLite 补充;Pi 优先 SQLite | +| `save_agent_config_command(agent, config)` | 保存到 SQLite;Kimi Code 同时写入 `config.toml` | +| `activate_agent_config_command(agent)` | 全量写入 Agent 原生配置(Kimi Code 写全部供应商,`default_model` 决定生效项;Pi 仅写活跃供应商) | +| `open_agent_config_dir(agent)` | 打开 Agent 配置目录 | +| `get_app_version()` | 返回 `Cargo.toml` 版本号 | +| `list_provider_models(provider)` | 调供应商 API 拉取模型列表(异步,分页) | +| `test_connectivity(provider)` | GET `base_url` 测连通性,返回 `{ ok, latency_ms, status_code, error }` | +| `get_app_setting(key)` | 读取 App 设置(主题 / 语言 / 上次更新检查时间) | +| `set_app_setting(key, value)` | 写入 App 设置 | +| `check_for_update()` | 检查 GitHub releases,返回版本号 + 资产 URL | +| `download_update(url, path)` | 流式下载更新包,发 `download-progress` / `download-complete` 事件 | +| `open_installer(path)` | 用系统 shell 打开下载好的安装包 | +| `dashboard::get_paths()` / `get_prices()` / `get_summary()` / `list_sessions()` / `archive_session()` / `unarchive_session()` / `delete_session()` / `delete_workspace()` / `get_session_preview()` | 用量与会话相关 | +| `debug_log(message)` | 前端日志 → stderr(开发用) | + +### 添加新供应商类型 + +1. `src-tauri/src/models.rs` 的 `ProviderType` 枚举加新变体 +2. `default_base_url()` 加默认值 +3. `commands.rs::list_provider_models` 的 `match` 加分发 +4. `kimi_code_io.rs::provider_type_for_kimi_type` 加映射 +5. `src/components/ProviderEdit.tsx` 的 API 格式下拉加选项 +6. `src/i18n/{zh,en}.ts` 加 i18n 键 + +### 添加新 Tauri Command + +1. `src-tauri/src/commands.rs` 加 `#[tauri::command]` +2. `src-tauri/src/lib.rs` 的 `tauri::generate_handler![...]` 注册 +3. 前端 `import { invoke } from "@tauri-apps/api/core"` 调用 +4. `src-tauri/capabilities/default.json` 加权限(如需文件/网络) + +### 添加新模型能力 + +1. `src/components/ProviderEdit.tsx` 的 `KNOWN_CAPABILITIES` 加新键 +2. `CAPABILITY_LABELS` 加 i18n 映射 +3. `src/i18n/{zh,en}.ts` 加翻译 +4. `src/lib/models-dev.ts` 的 `capabilitiesFromRef` 加推导(若可从 models.dev 推) + +## 键盘快捷键 + +| 快捷键 | 作用 | +| --- | --- | +| `Ctrl + S` | 保存当前修改到 SQLite + `config.toml`(Kimi Code) | +| `Ctrl + R` | 重新读取配置(未保存时提示) | +| `Ctrl + O` | 打开当前 Agent 的配置目录 | + +## 国际化 + +- 翻译源:`src/i18n/zh.ts`(源) + `src/i18n/en.ts`(目标) +- 新增 key **先在 `zh.ts` 里加**,`en.ts` 的 `Record` 类型会自动校验缺失条目(编译时报错) +- `useTranslation` hook 暴露 `{ t, lang, setLang }` +- 运行时切换由 `SettingsModal` 提供,持久化到前端 `localStorage`(`kimi-switch-lang`) + +## 测试 + +### Rust 单元测试 + +```bash +cd src-tauri +cargo test +``` + +当前覆盖: + +- `kimi_code_io::tests` — TOML 导入/导出往返 +- `pi_io::tests` — JSON 往返,含 advanced fields(headers/compat/cost/extra) +- `validators::tests` — 配置校验 +- `dashboard::tests` — 用量聚合与时区处理 + +### 前端 + +暂未自动化测试,建议手动验证清单: + +- [ ] 切换供应商后默认模型正确回填 +- [ ] 添加新供应商自动提升到列表最前 +- [ ] 重复供应商生成的 key 不冲突 +- [ ] 测试连通性气泡在 6 秒后自动消失 +- [ ] 关闭 X 隐藏到托盘;最小化保留任务栏 +- [ ] 主题切换(深色 / 浅色 / 跟随系统)即时生效 +- [ ] 切换语言后 UI 文案立即更新 +- [ ] 启动检查更新 → 下载 → 引导安装 +- [ ] 拉取模型时 `max_context_size` 自动填充 +- [ ] 切换供应商后 Kimi Code 会话经 `/reload` + `/model` 选回默认(或 `/exit` 重开会话)后生效 + +## 打包发布 + +```bash +npm run tauri-build +``` + +产物位置: + +``` +src-tauri/target/release/bundle/msi/Kimi Switch__x64_en-US.msi +``` + +Windows 安装包(MSI),含 WebView2 bootstrapper 自动下载。`nsis` 已禁用,目前只产 MSI。 + +### 首次打包 + +首次运行会下载: + +- WiX Toolset 3.14 binaries → `src-tauri/wix314-binaries/` +- WebView2 bootstrapper → 打包进 MSI + +### 常见构建问题 + +- **`os error 5`**(WiX light 步骤):先 `taskkill //F //IM kimiswitch.exe` 杀掉占用进程后重试 +- **prebuild 拉 models.dev 超时**:属正常,本地快照兜底不影响构建 + +## 发布历史 + +### v0.5.1(最新) + +- 窗口/托盘:最小化保留任务栏;关闭 X 隐藏到托盘;托盘左键始终显示并聚焦 +- 模型用量表:模型列宽度自适应,长模型名完整显示不再截断 +- 模型能力编辑:只保留"思考",其余能力仍由 models.dev 自动推导 +- 模型映射表格:行分隔线改为主题感知,修复浅色模式突兀问题 + +### v0.5.0 + +- 供应商操作图标化(lucide-react):启用 / 编辑 / 复制 / 测试连通 / 删除 +- 重复供应商(深拷贝供应商 + 全部模型) +- 连通性测试(绿/橙/红气泡 + 6 秒自动消失) +- 每日趋势柱状图锚定到面板底部 + +### v0.4.1 + +- 模型发现分页(OpenAI / Anthropic / Google),减少大列表命中限制 + +### v0.4.0 + +- 完善的图标系统(移植自 cc-switch 图标库) +- 设置面板(主题、语言) +- 启动版本检查 + +### v0.3.0 + +[移植自 kimicode-dashboard](https://github.com/JochenYang/kimicode-dashboard): + +- 用量仪表盘:8 KPI、每日趋势、全年热力图、最近请求翻页 +- 会话管理:按工作区浏览、预览、归档、批量删除 +- 时区修复:`today` / 热力图 / `day_key` 改用本地日历日 +- Pi 选项隐藏 + +## 常见问题 + +**Q: 切换供应商后 Kimi Code 没生效?** +A: 需要分两步: +1. 在 Kimi Code 会话里执行 `/reload`,让 CLI 重新读取 `~/.kimi-code/config.toml`(此时模型**下拉列表**会刷新); +2. **再执行一次 `/model` 选回新的默认模型**,或者直接 `/exit` 重开会话。 + +仅 `/reload` **不会**把新的 `default_model` 自动套用到当前会话——这是 Kimi Code 的已知行为,详见下方[已知限制与后续建议](#已知限制与后续建议)。应用会在 UI 上提示这两步。 + +**Q: 切换会覆盖其他供应商吗?** +A: 不会。Kimi Code 的 `config.toml` 始终写入全部供应商,仅 `default_model` 决定生效项。这与 CLI 原生 `/provider` 行为一致。 + +**Q: 新增/激活的供应商位置怎么变?** +A: 自动提升到列表最前,UI 立即反映。 + +**Q: 为什么能力只显示"思考"?** +A: Kimi Code 语义上 `capabilities` 只追加不能移除,models.dev 已有自动推导,再手动暴露只会增加误操作风险。需要声明 `always_thinking` 等特殊值时直接编辑 `config.toml` 即可。 + +**Q: 改了配置但忘了保存就关了窗口?** +A: 关闭窗口前有原生 `beforeunload` 提示,标题栏也会显示 `*` 前缀。 + +**Q: 怎么备份/迁移我的配置?** +A: 对 Kimi Code,`config.toml` 本身就是全量权威,直接备份即可;`kimi-switch.db` 存备注 / 官网 / 排序,迁移时一并备份。主题 / 语言 / 上次更新检查时间存在前端 `localStorage`(WebView2),不是独立文件,迁移时通常无需单独处理。 + +**Q: Vertex AI 为什么不能拉取模型列表?** +A: Vertex 需要 GCP project / location 凭证,当前实现留 TODO,等 GCP SDK 集成后补。 + +**Q: 支持 macOS / Linux 吗?** +A: 代码不依赖 Windows 专属 API,但 `tauri.conf.json` 的 bundle 目标目前只配了 `msi`。理论上把 `bundle.targets` 改成 `["app", "dmg"]` 等即可跨平台,但未验证。 + +**Q: 怎么改主题 / 语言?** +A: 顶部齿轮 → 设置弹窗 → 主题 / 语言。保存到前端 `localStorage`(WebView2),重启后保持。 + +**Q: 把窗口关了怎么再打开?** +A: 关闭按钮(X)已改为隐藏到托盘。点击托盘图标(菜单栏 / 系统托盘)即可拉回;点击任务栏图标正常切换最小化/还原。 + +**Q: 检查更新是怎么触发的?** +A: 启动时静默检查一次,之后每 8 小时自动检查一次(无网络时静默失败,不弹错)。也可手动:设置弹窗 → 版本 → 检查更新。下载有进度条,下载完成后会有"打开安装包"按钮。 + +## 已知限制与后续建议 + +### `/reload` 不会切换当前会话的默认模型(Kimi Code 上游限制) + +**现象**:在 Kimi Switch 切换供应商后,`config.toml` 顶层的 `default_model` 已经被正确更新;回到 Kimi Code CLI 执行 `/reload`,模型**下拉列表**能列出新的默认模型,但**当前会话实际仍在使用旧模型**(底部状态栏显示的模型、真正发出的请求都没变)。 + +**根因**(已对照 Kimi Code 源码确认,不是 Kimi Switch 的 bug): + +- `/reload` 命令(`apps/kimi-code/src/tui/commands/reload.ts`)在刷新 `availableModels / availableProviders` 之后,**没有**把 `config.defaultModel` 重新套用到当前会话的 agent 上。 +- 会话当前的模型(`agent.config.modelAlias`)来自会话创建时的 `options.model ?? config.defaultModel`(`packages/agent-core/src/rpc/core-impl.ts:438-440`),并通过 `records.logRecord` 持久化进会话日志;`Agent.resume()` 调 `records.replay()` 时会**回放这些历史记录**,从而把模型恢复成"上次手动选的",而不是 `config.defaultModel`。 +- 单元测试(`apps/kimi-code/test/tui/commands/reload.test.ts:87-89`)也只断言"模型列表刷新了",没有任何"当前会话切到新默认模型"的断言——因为实现层就没做这一步。 +- 全仓检索 `FOLLOW_DEFAULT / reloadDefault / KIMI_CODE_RELOAD` 等关键字**零命中**,说明 Kimi Code 目前**没有**任何"reload 时跟随 default_model"的开关。 + +**Workaround(当前可用)**: + +1. `/reload` 后再执行 `/model`,手动选一次新默认模型;或 +2. `/exit` 退出会话再重开(重新走 `createSession` 路径,会把 `config.defaultModel` 套到新会话)。 + +**建议反馈给 Kimi Code 上游的修复点**: + +- 文件:`apps/kimi-code/src/tui/commands/reload.ts` 的 `handleReloadCommand`。 +- 位置:`applyRuntimeConfig(host, config)` 之后,补一段把 `config.defaultModel` 同步到当前会话 agent 的逻辑,例如: + ```ts + const newDefault = config.defaultModel; + const current = /* host.session 当前 agent 的 modelAlias */; + if (newDefault && newDefault !== current) { + await host.session.mainAgent.config.update({ modelAlias: newDefault }); + } + ``` +- 配套断言:在 `reload.test.ts` 增加对"reload 后会话当前模型 == 新 `defaultModel`"的测试,防回归。 +- 可选增强:暴露一个会话级"follow default on reload"开关,或当会话旧 alias 已被删除时自动回退到 `default_model`。 + +> 注:以上建议**尚未提交给官方**,记录于此供后续跟进。Kimi Switch 侧只能保证 `config.toml` 写入正确,无法绕开 Kimi Code 自身的 `/reload` 语义。 + +### 其它后续建议(按优先级) + +- **P1 — 模型别名规范**:早期裸名或 `-1/-2` 后缀的别名(如 `kimi-k3`、`glm-5-2-1`)已批量重命名为 `provider/model` 形式;新增供应商时建议**强制**采用规范命名,避免再出现补全后缀。可在保存前加一层 lint。 +- **P1 — 用量趋势多维度**:用量趋势已支持"模型用量趋势 / 供应商模型用量趋势"两个 Tab;后续可考虑增加"按工作区"或"按 Token / 按费用"的第三维度切换。 +- **P2 — 周期检查更新**:当前为启动 + 每 8 小时一次;后续可考虑做成可配置项(设置面板里设周期 / 关闭)。 +- **P2 — 跨平台**:目前只产 MSI,代码不依赖 Windows 专属 API;待 Tauri v2 的 macOS/Linux bundle 配置后即可验证。 +- **P3 — 配置校验增强**:`validators.rs` 当前偏基础,可补:`base_url` 合法性、`env` 与 `api_key` 互斥、`oauth` 段完整性。 + +## 安全提示 + +- API Key 明文存储在本地 SQLite 和 Agent 原生配置里——**不要在共享电脑上保存** +- 不要把 `kimi-switch.db`、`config.toml`、`models.json` 提交到 Git +- 应用 CSP 已收紧(`default-src 'self'`),但 WebView2 仍可能缓存表单内容,注意在公共电脑用完退出 + +--- + +## 致谢 + +用量仪表盘与会话管理功能基于 [kimicode-dashboard](https://github.com/JochenYang/kimicode-dashboard)(MIT 许可证,© JochenYang)移植。Rust 后端(`src-tauri/src/dashboard.rs`)、前端仪表盘(`src/components/dashboard/`)、会话管理页(`src/components/sessions/`)均源自该项目,感谢原作者的开源贡献。 + +供应商品牌图标资源(`src/icons/extracted/`)与图标选择器(`src/components/IconPicker.tsx`)参考自 [cc-switch](https://github.com/farion1231/cc-switch)(MIT 许可证,© Jason Young),感谢原作者的开源贡献。 + +供应商预设结构(`src/config/providerPresets.ts`)与余额/套餐查询实现(`src-tauri/src/services/`)同样参考自 [cc-switch](https://github.com/farion1231/cc-switch)(MIT 许可证,© Jason Young)。 + +--- + +许可证:MIT,详见 [LICENSE](./LICENSE)。Copyright (c) 2026 CodingPlan.site diff --git a/README_EN.md b/README_EN.md new file mode 100644 index 0000000..16d6b4f --- /dev/null +++ b/README_EN.md @@ -0,0 +1,629 @@ +# Kimi Switch + +> A Windows desktop **Kimi Code CLI** configuration manager — unify multi-provider LLM setup, models, icons, connectivity testing, usage analytics, and update checks in one place. + +**English** | [中文](./README.md) + +[![Tauri](https://img.shields.io/badge/Tauri-v2-blue?logo=tauri)](https://tauri.app) +[![React](https://img.shields.io/badge/React-18-61dafb?logo=react)](https://react.dev) +[![TypeScript](https://img.shields.io/badge/TypeScript-5.5-3178c6?logo=typescript)](https://www.typescriptlang.org) +[![Rust](https://img.shields.io/badge/Rust-2021-ed764d?logo=rust)](https://www.rust-lang.org) +[![Version](https://img.shields.io/badge/release-v0.5.2-brightgreen)](https://git.codingplan.site/admin/KimiCodeSwitch/releases) +[![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE) + +--- + +## Table of Contents + +- [What Is This](#what-is-this) +- [Key Features](#key-features) +- [Screenshots](#screenshots) +- [Architecture Overview](#architecture-overview) +- [Feature Details](#feature-details) +- [Supported Provider Types](#supported-provider-types) +- [Data Storage Locations](#data-storage-locations) +- [Getting Started](#getting-started) +- [Development Guide](#development-guide) +- [Keyboard Shortcuts](#keyboard-shortcuts) +- [Internationalization](#internationalization) +- [Testing](#testing) +- [Build & Release](#build--release) +- [Release History](#release-history) +- [FAQ](#faq) +- [Known Limitations & Next Steps](#known-limitations--next-steps) +- [Security Notes](#security-notes) +- [Credits](#credits) + +--- + +## What Is This + +**Kimi Switch** is a Windows desktop configuration tool built for [Kimi Code CLI](https://github.com/MoonshotAI/kimi-code) users. It pulls the hand-editing of `~/.kimi-code/config.toml` into a GUI and adds a pile of conveniences the CLI itself doesn't ship: + +- **Unified multi-provider management** — Kimi / Anthropic / OpenAI / OpenAI Responses / Google GenAI / Vertex AI in one view; newly added providers are **automatically promoted to the top of the list**, and switching never overwrites or loses them +- **One-click model discovery** — fetch the available model list from the provider API; display names, context size, and capabilities **prefer the models.dev cache** (backend/UI fallback for missing fields) +- **Connectivity testing** — GET `base_url` (cc-switch semantics), with a coloured latency bubble (green / orange / red) that auto-dismisses in 6 seconds +- **Duplicate provider** — deep-copy an existing provider along with all its models in one click; the key becomes `xxx-copy` +- **Icon system** — 100+ first-party provider brand icons, adapted from cc-switch; unmatched providers get a deterministic initial-letter placeholder +- **Usage dashboard** — ported from kimicode-dashboard: KPIs / heatmap / per-model daily trend stacked bars / paginated recent requests +- **Session manager** — per-workspace browsing, preview, archive, and bulk delete for Kimi Code sessions +- **Update check** — auto-check on launch (toggleable) and one-click download + install +- **Theme / language** — dark / light / follow-system themes; Simplified Chinese / English +- **Windows polish** — single instance; minimize keeps the taskbar button; close hides to tray + +## Key Features + +| Category | Feature | +| --- | --- | +| **Multi-provider** | Kimi / Anthropic / OpenAI / OpenAI Responses / Google GenAI / Vertex AI | +| **Multi-agent** | Primary target is Kimi Code; Pi code is preserved but hidden from the UI | +| **Icon system** | 100+ first-party icons + initial-letter fallback; picker grouped by brands / inference | +| **Quick switch** | Added/activated providers move to the top of the list; switching only changes `default_model` and never drops other providers | +| **Connectivity test** | Real `base_url` latency, coloured bubble (green / orange / red), auto-dismiss in 6 seconds | +| **Duplicate provider** | Deep-copy a provider + all its models; key auto-suffixed to `xxx-copy` | +| **Iconified actions** | Activate / Edit / Duplicate / Test Connectivity / Delete via lucide-react | +| **Model mapping** | Alias (`"provider/model"`) ↔ real model ID, with display name, context size, 1M-context flag, and capabilities | +| **Auto context size** | On model fetch: **API response > models.dev ref > regex fallback** — three-tier priority | +| **Auto capabilities** | `image_in / video_in / tool_use` all derived from models.dev; UI only exposes `thinking` as a manual toggle | +| **Global settings** | Full `[thinking]` table (enabled / effort / keep); Kimi Code only | +| **Raw JSON editing** | Power users can hand-edit the full config; unknown fields pass through `raw_other` and never get dropped | +| **i18n / Theme** | Simplified Chinese / English; dark / light / follow-system; persisted | +| **Auto backup** | Before writing `config.toml`, backup with timestamp-based names kept 7 days | +| **Shortcuts** | `Ctrl+S` save, `Ctrl+R` reload, `Ctrl+O` open config dir | +| **Usage dashboard** | 8 KPIs, per-model stacked daily trend, full-year heatmap, paginated recent requests, double-click bars for per-model breakdown modal | +| **Session manager** | Per-workspace browsing, archive/unarchive, bulk delete; streaming line-by-line preview (20MB cap + 500-char collapse) | +| **Update check** | Auto-check on launch + every 8h periodic + manual; download progress bar; guided install after download | +| **Unsaved-changes prompt** | Native `beforeunload` warning + `*` prefix in title bar | +| **Window / tray** | Minimize keeps the taskbar button; close X hides to tray; tray left-click always shows + focuses | + +## Screenshots + +**Provider list** (light theme) + +![Provider list](docs/screenshots/providers.png) + +Active provider, default model, latency bubble, model count, and brand icons at a glance. Quick switch, copy, test connectivity, open website, edit, delete. + +**Edit provider — basic info** + +![Edit provider — basic info](docs/screenshots/provider-basic-info.png) + +Provider name, notes, official URL, managed-provider toggle, API format, API key, and base URL. + +**Edit provider — model mapping** + +![Edit provider — model mapping](docs/screenshots/provider-model-mapping.png) + +A single table for all model mappings: display name, real model ID, context length, 1M-context flag, capability (thinking only), default toggle, delete. + +**Usage dashboard** + +![Usage dashboard](docs/screenshots/dashboard.png) + +8 KPIs (requests, non-cached input, output, cache read / write / hit, total tokens, estimated cost) + full-year heatmap + per-model stacked daily trend (bottom-aligned) + per-model usage table + paginated recent requests. + +**Session manager** + +![Session manager](docs/screenshots/sessions.png) + +Per-workspace Kimi Code session browsing, active / archived / all filters; streaming line-by-line preview (20MB cap, 500-char collapse); archive/unarchive/bulk delete. + +## Architecture Overview + +``` +┌────────────────────────────────────────────────────────────────────────┐ +│ Kimi Switch (Tauri v2) │ +│ │ +│ ┌──────────────────────────┐ ┌──────────────────────────────┐ │ +│ │ React Frontend (TS) │ │ Rust Backend (lib.rs) │ │ +│ │ │ │ │ │ +│ │ src/App.tsx │ │ src-tauri/src/ │ │ +│ │ src/components/ │◄──►│ ├── lib.rs │ │ +│ │ ProviderList │ │ ├── main.rs │ │ +│ │ ProviderEdit │ │ ├── commands.rs │ │ +│ │ AgentSettingsPanel │ │ ├── db.rs │ │ +│ │ SettingsModal │ │ ├── kimi_code_io.rs │ │ +│ │ ProviderIcon / │ │ ├── pi_io.rs (legacy) │ │ +│ │ IconPicker │ │ ├── config_io.rs │ │ +│ │ dashboard/ │ │ ├── models.rs │ │ +│ │ sessions/ │ │ ├── validators.rs │ │ +│ │ src/hooks/ │ │ ├── dashboard.rs │ │ +│ │ useConfig / Dashboard│ │ └── profile_manager.rs │ │ +│ │ / Sessions / Theme │ │ │ │ +│ │ / UpdateCheck │ │ │ │ +│ │ src/lib/ │ │ │ │ +│ │ src/icons/ │ │ │ │ +│ │ src/i18n/{zh,en}.ts │ │ │ │ +│ │ src/types/{...} │ │ │ │ +│ └──────────────────────────┘ └──────────────────────────────┘ │ +│ │ │ │ +└──────────────────┼──────────────────────────────────┼──────────────────┘ + │ │ + ▼ ▼ + ┌──────────────────────┐ ┌──────────────────────────┐ + │ SQLite │ │ Agent native configs │ + │ ~/.kimi-switch/ │ │ ├─ ~/.kimi-code/ │ + │ kimi-switch.db │ │ │ └─ config.toml │ + │ (metadata + fallback)│ │ └─ ~/.pi/agent/ │ + │ + localStorage │ │ ├─ models.json │ + │ (theme / lang / │ │ └─ settings.json │ + │ update-check) │ │ │ + └──────────────────────┘ └──────────────────────────┘ + ▲ + │ + ┌──────────────────────────────┐ + │ models.dev snapshot (FE) │ + │ src/lib/models-dev.json │ + │ + scripts/fetch-models- │ + │ dev.mjs (optional refresh) │ + └──────────────────────────────┘ +``` + +**Key design points**: + +- **`config.toml` is the authoritative source for Kimi Code**: all providers and models are always written in full; `default_model` selects the active one (matching the CLI's native `/provider` behaviour). Switching only changes `default_model`; newly added providers are auto-promoted to the top of the list and never get overwritten +- **SQLite holds Kimi Switch-private metadata only**: notes, official URLs, per-agent remembered default model (the `settings` table), ordering. Theme / language / last-update-check live in frontend `localStorage` (WebView2), not under `~/.kimi-switch`. It acts as a fallback when `config.toml` is incomplete +- **`raw_other` passes unknown fields through untouched**, including `[oauth]` blocks — round-trips never drop fields +- **models.dev snapshot**: derived from `https://models.dev/api.json`, cached to a local JSON; `capabilitiesFromRef` derives `thinking / image_in / video_in / tool_use`, `getModelRef` derives `max_context_size / display_name` +- **Override env vars**: `KIMI_CODE_HOME` / `PI_CODING_AGENT_DIR` override the Kimi Code / Pi dirs; Kimi Switch's own data dir is fixed at `~/.kimi-switch` (no env override yet). See [Data Storage Locations](#data-storage-locations) + +## Feature Details + +### Provider priority & switching + +- **Auto-promote to top on add/activate**: when you add or switch a provider, it moves to the first position in `db.providers`; the UI renders the freshest order immediately +- **Switching only changes `default_model`**: all providers are written to `config.toml`, only the switched one becomes `default_model` — consistent with Kimi Code CLI's `/provider` +- **Never overwrites**: providers added/edited/deleted via the CLI's `/provider` are correctly reflected on load (config.toml wins), and fully persisted on next save + +### Icon system + +- Library adapted from cc-switch (`src/icons/extracted/`, 100+ first-party brand icons) +- Providers without an exact match get a deterministic **initial-letter placeholder** (e.g. `kimi-code` → `K`, `deepseek-v4` → `D`) +- `IconPicker` has brands / inference groups; the chosen icon is saved to `provider.icon` + +### Connectivity test + +- Backend `test_connectivity` command: GET `provider.base_url`; any HTTP response = reachable +- Returns `{ ok, latency_ms, status_code, error }` +- Frontend renders a **coloured bubble** (green / orange / red + ms) inline; auto-dismisses in 6 seconds +- The bubble sits before the "active / switch" button so it doesn't block the sight + +### Duplicate provider + +- Click the duplicate icon → deep-copy the provider + all its models +- `provider.name` gets a `-copy` suffix; model keys auto-suffix to `xxx-copy` +- Immediately persisted to SQLite + `config.toml`; toast confirmation + +### Auto context size & capabilities + +- On fetch, `max_context_size` priority: **API response > models.dev ref > regex fallback** +- models.dev ref → `capabilities = ["thinking","image_in","video_in","tool_use"]` (filtered by truthy fields) +- UI exposes **only the `thinking` checkbox**. Other capabilities are still auto-written but not manually editable — this matches Kimi Code's semantics: capabilities can only be added, never removed +- `always_thinking` can only be set manually (models.dev can't derive it). The UI hides it; edit `config.toml` directly when needed + +### Capability vs thinking switch + +- **Model capability** (`capabilities`) = "can it": declares whether the model supports thinking. Without `thinking`, the global switch has no effect on this model +- **Global `[thinking]`** (settings panel) = "do we want": new sessions default on/off, effort level (low/medium/high/max), and whether to keep thinking content +- `always_thinking` forces thinking on, ignoring the global switch +- Global settings apply to Kimi Code only + +### Usage dashboard + +- 8 KPIs, per-model stacked daily trend (**bottom-aligned layout**), full-year heatmap (5-level token colouring), paginated recent requests (30/page) +- Double-click a daily bar → `DailyDetailModal` with per-model breakdown +- Data source: `src-tauri/src/dashboard.rs` + `src/hooks/useDashboard.ts` + +### Session manager + +- Per-workspace Kimi Code session browsing, active / archived / all filters +- Streaming line-by-line preview (20MB cap, 500-char collapse with expand) +- Archive / unarchive / bulk delete +- Earlier "instant crash" issues fixed via streaming reads + size limits + +### Settings modal + +- **Theme**: dark / light / follow-system (`useTheme`, persisted to frontend `localStorage`) +- **Language**: Simplified Chinese / English +- **Version**: current version + last-checked timestamp +- **Update check**: launch auto + every 8h periodic + manual; download with progress bar; guided install after completion + +### Window & tray + +- **Minimize**: keeps the taskbar button (no longer hijacked to tray) +- **Close (X)**: `prevent_close` + `hide` → hides to tray instead of quitting +- **Tray menu**: Show / Quit +- **Tray left-click**: always `show + unminimize + focus` (no toggle hide) +- **Second launch**: `single_instance` plugin catches it, brings window to front and focuses + +## Supported Provider Types + +| Type | Identifier | Default base_url | Model discovery | Credentials | +| --- | --- | --- | --- | --- | +| Kimi | `kimi` | `https://api.openai.com/v1` | ✅ OpenAI protocol | `KIMI_API_KEY` or `env.KIMI_API_KEY` | +| Anthropic | `anthropic` | — | ✅ `/v1/models` | `ANTHROPIC_API_KEY` or `env` | +| OpenAI | `openai` | `https://api.openai.com/v1` | ✅ `/models` | `OPENAI_API_KEY` or `env` | +| OpenAI Responses | `openai_responses` | `https://api.openai.com/v1` | ✅ `/models` | `OPENAI_API_KEY` or `env` | +| Google GenAI | `google-genai` | `https://generativelanguage.googleapis.com` | ✅ `/v1beta/models` | `GOOGLE_API_KEY` or `env` | +| Vertex AI | `vertexai` | — | ⚠️ Not yet implemented | `VERTEXAI_API_KEY` + `GOOGLE_CLOUD_PROJECT` + `GOOGLE_CLOUD_LOCATION` | + +Credential precedence: `api_key` field > same-named key in `env` table. + +## Data Storage Locations + +| File | Purpose | Backup | +| --- | --- | --- | +| `%USERPROFILE%\.kimi-switch\kimi-switch.db` | Kimi Switch's own SQLite — metadata (notes / official URLs / remembered default model + ordering) + fallback | — | +| `%USERPROFILE%\.kimi-code\config.toml` | Kimi Code CLI TOML config (**authoritative source, written on switch/save**) | `backups/config.toml.bak.{YYYYMMDD_HHMMSS}` next to it, kept 7 days | +| `%USERPROFILE%\.pi\agent\models.json` | Pi provider + model config (**written on switch**) | `backups/models.json.bak.{YYYYMMDD_HHMMSS}` next to it, kept 7 days | +| `%USERPROFILE%\.pi\agent\settings.json` | Pi default provider/model (**written on switch**) | `backups/settings.json.bak.{YYYYMMDD_HHMMSS}` next to it, kept 7 days | +| WebView2 `localStorage` | Frontend state: `kimi-switch-theme` / `kimi-switch-lang` / `kimi-switch-last-update-check` / `kimi-switch-agent` / `kimi-switch-dashboard-range` (theme / language / last-update-check / last-selected agent / dashboard range) | — | +| `src/lib/models-dev.json` | models.dev `api.json` snapshot (bundled with the frontend) | — | + +Environment-variable overrides: + +- `KIMI_CODE_HOME` overrides the Kimi Code config dir (default `~/.kimi-code`) +- `PI_CODING_AGENT_DIR` overrides the Pi agent config dir (default `~/.pi/agent`) +- Kimi Switch's own data dir is fixed at `~/.kimi-switch` (**no env override yet**) + +## Getting Started + +### Prerequisites + +| Tool | Version | Purpose | +| --- | --- | --- | +| Node.js | ≥ 18 | Frontend build | +| Rust | Latest stable (edition 2021) | Tauri backend compilation | +| WebView2 Runtime | Bundled with Windows 10/11 | Tauri v2 runtime | +| Microsoft C++ Build Tools | Latest | Rust compile dependency | +| WiX Toolset 3.14 | `src-tauri/wix314-binaries/` | MSI bundling (auto-downloaded on first build) | + +### Install + +```bash +npm install +``` + +### Dev mode (hot reload) + +```bash +npm run tauri-dev +``` + +Starts both the Vite dev server (port 1420) and the Tauri window. Frontend edits hot-reload; Rust edits trigger a recompile. + +### Frontend-only dev (no Tauri window) + +```bash +npm run dev +``` + +Good for pure UI debugging. + +## Development Guide + +### Project structure + +``` +. +├── src/ # React frontend +│ ├── App.tsx # Root component routing provider list / edit / dashboard / sessions +│ ├── main.tsx # React entry + ErrorBoundary + I18nProvider +│ ├── components/ +│ │ ├── ProviderList.tsx # Provider list + switch / duplicate / test / edit / delete +│ │ ├── ProviderEdit.tsx # Edit provider + model mapping + raw JSON + capabilities +│ │ ├── AgentSettingsPanel.tsx # Kimi Code global settings (thinking / loop / permissions / hooks) +│ │ ├── SettingsModal.tsx # Settings modal (theme / language / version / update check) +│ │ ├── ProviderIcon.tsx # Provider brand icons (with initial-letter fallback) +│ │ ├── IconPicker.tsx # Icon picker (brands / inference groups) +│ │ ├── dashboard/ # Usage dashboard +│ │ │ ├── DashboardPage.tsx +│ │ │ ├── DailyBars.tsx +│ │ │ ├── DailyDetailModal.tsx +│ │ │ └── Heatmap.tsx +│ │ └── sessions/ # Session manager +│ │ └── SessionsPage.tsx +│ ├── hooks/ +│ │ ├── useConfig.ts # Config load/save +│ │ ├── useDashboard.ts # Dashboard data +│ │ ├── useSessions.ts # Session data +│ │ ├── useTheme.ts # Theme switch +│ │ └── useUpdateCheck.ts # Update check + download +│ ├── lib/ +│ │ ├── agent-settings.ts # AgentSettings parse/serialize +│ │ ├── model-defaults.ts # Default model context sizes +│ │ ├── models-dev.ts # models.dev snapshot lookup + capability mapping +│ │ ├── models-dev.json # Bundled snapshot +│ │ └── dashboard-format.ts # Dashboard formatting +│ ├── icons/ +│ │ ├── brands.ts # Brand icon entry +│ │ ├── inference.ts # Inference-service icons +│ │ └── extracted/ # Icon library adapted from cc-switch +│ │ ├── index.ts +│ │ └── metadata.ts +│ ├── types/ +│ │ ├── index.ts # Provider / Model / Config +│ │ ├── dashboard.ts +│ │ ├── sessions.ts +│ │ └── icon.ts +│ ├── i18n/ +│ │ ├── zh.ts # Chinese translations (source) +│ │ ├── en.ts # English translations +│ │ └── index.tsx # useTranslation hook + Provider +│ └── index.css # Tailwind entry +│ +├── src-tauri/ # Rust backend +│ ├── src/ +│ │ ├── lib.rs # Tauri Builder + tray + window events + invoke_handler +│ │ ├── main.rs # Binary entry +│ │ ├── commands.rs # ~14 Tauri commands +│ │ ├── db.rs # SQLite persistence +│ │ ├── kimi_code_io.rs # ~/.kimi-code/config.toml read/write +│ │ ├── pi_io.rs # ~/.pi/agent/*.json read/write (preserved) +│ │ ├── config_io.rs # File backup utilities +│ │ ├── models.rs # Config / Provider / Model data structures +│ │ ├── profile_manager.rs # Multi-profile management (stub) +│ │ ├── validators.rs # Config validation +│ │ └── dashboard.rs # Session / usage data aggregation +│ ├── capabilities/ # Tauri permission declarations +│ ├── icons/ # App icons (script-generated) +│ └── tauri.conf.json # Tauri config (window / bundle / CSP) +│ +├── scripts/ +│ ├── fetch-models-dev.mjs # Refresh models-dev.json snapshot +│ └── generate-icons.py # Generate all icon sizes from SVG +├── public/kimi.svg # App icon source (blue-purple gradient π) +└── docs/ + ├── screenshots/ # Screenshots referenced by the README + └── superpowers/ # Design specs & implementation plans +``` + +### Tauri commands (frontend ↔ backend) + +| Command | Description | +| --- | --- | +| `load_agent_config_command(agent)` | Load config: Kimi Code reads `config.toml` as authoritative + SQLite metadata; Pi reads SQLite first | +| `save_agent_config_command(agent, config)` | Save to SQLite; for Kimi Code also writes `config.toml` | +| `activate_agent_config_command(agent)` | Write to the agent's native config (Kimi Code writes all providers — `default_model` picks the active one; Pi writes only the active provider) | +| `open_agent_config_dir(agent)` | Open the agent's config dir in the system file manager | +| `get_app_version()` | Return the `Cargo.toml` version | +| `list_provider_models(provider)` | Fetch the model list from the provider API (async, paginated) | +| `test_connectivity(provider)` | GET `base_url` connectivity test, returns `{ ok, latency_ms, status_code, error }` | +| `get_app_setting(key)` | Read an app setting (theme / language / last-update-check) | +| `set_app_setting(key, value)` | Write an app setting | +| `check_for_update()` | Check GitHub releases; returns version + asset URL | +| `download_update(url, path)` | Stream-download the update; emits `download-progress` / `download-complete` events | +| `open_installer(path)` | Open the downloaded installer via the system shell | +| `dashboard::get_paths()` / `get_prices()` / `get_summary()` / `list_sessions()` / `archive_session()` / `unarchive_session()` / `delete_session()` / `delete_workspace()` / `get_session_preview()` | Dashboard & session commands | +| `debug_log(message)` | Forward frontend logs to stderr (dev use) | + +### Adding a new provider type + +1. Add a new variant to the `ProviderType` enum in `src-tauri/src/models.rs` +2. Add a default in `default_base_url()` +3. Add a dispatch arm in `commands.rs::list_provider_models` +4. Add a mapping in `kimi_code_io.rs::provider_type_for_kimi_type` +5. Add an option to the API-format dropdown in `src/components/ProviderEdit.tsx` +6. Add new i18n keys in `src/i18n/{zh,en}.ts` + +### Adding a new Tauri command + +1. Add a `#[tauri::command]` in `src-tauri/src/commands.rs` +2. Register it in `tauri::generate_handler![...]` in `src-tauri/src/lib.rs` +3. Call it from the frontend with `import { invoke } from "@tauri-apps/api/core"` +4. Add a permission in `src-tauri/capabilities/default.json` (if filesystem/network is needed) + +### Adding a new model capability + +1. Add a new key to `KNOWN_CAPABILITIES` in `src/components/ProviderEdit.tsx` +2. Add an i18n mapping in `CAPABILITY_LABELS` +3. Add translations in `src/i18n/{zh,en}.ts` +4. Add derivation in `capabilitiesFromRef` in `src/lib/models-dev.ts` (if it can be derived from models.dev) + +## Keyboard Shortcuts + +| Shortcut | Action | +| --- | --- | +| `Ctrl + S` | Save current changes to SQLite + `config.toml` (Kimi Code) | +| `Ctrl + R` | Reload config (prompts if unsaved) | +| `Ctrl + O` | Open the current agent's config dir | + +## Internationalization + +- Sources: `src/i18n/zh.ts` (source) + `src/i18n/en.ts` (target) +- When adding a key, **add it to `zh.ts` first** — the `Record` type in `en.ts` will flag missing entries at compile time +- `useTranslation` hook exposes `{ t, lang, setLang }` +- Runtime switch is provided by `SettingsModal`; persisted to frontend `localStorage` (`kimi-switch-lang`) + +## Testing + +### Rust unit tests + +```bash +cd src-tauri +cargo test +``` + +Currently covered: + +- `kimi_code_io::tests` — TOML import/export round-trips +- `pi_io::tests` — JSON round-trips, including advanced fields (headers / compat / cost / extra) +- `validators::tests` — Config validation +- `dashboard::tests` — Usage aggregation and timezone handling + +### Frontend + +No automated tests yet. Suggested manual checklist: + +- [ ] Default model restores correctly after switching providers +- [ ] Newly added providers auto-promote to the top of the list +- [ ] Duplicate provider generates non-conflicting keys +- [ ] Connectivity test bubble auto-dismisses after 6 seconds +- [ ] Close X hides to tray; minimize keeps taskbar button +- [ ] Theme switch (dark / light / follow-system) takes effect immediately +- [ ] Language switch updates UI text in place +- [ ] Update check → download → guided install +- [ ] `max_context_size` auto-fills on model fetch +- [ ] New default model takes effect after `/reload` + `/model` (or after `/exit` and restart) + +## Build & Release + +```bash +npm run tauri-build +``` + +Output: + +``` +src-tauri/target/release/bundle/msi/Kimi Switch__x64_en-US.msi +``` + +A Windows installer (MSI) with the WebView2 bootstrapper embedded for auto-download. `nsis` is disabled; only MSI is produced. + +### First build + +The first run downloads: + +- WiX Toolset 3.14 binaries → `src-tauri/wix314-binaries/` +- WebView2 bootstrapper → embedded into the MSI + +### Common build issues + +- **`os error 5`** (WiX light step): kill the process first with `taskkill //F //IM kimiswitch.exe`, then retry +- **prebuild times out pulling models.dev**: expected; the local snapshot keeps the build going + +## Release History + +### v0.5.1 (latest) + +- Window / tray: minimize keeps the taskbar button; close X hides to tray; tray left-click always shows + focuses +- Usage table: model column auto-width, long names no longer truncated +- Capability editor: only `thinking` exposed; other capabilities still auto-derived from models.dev +- Model mapping table: theme-aware row divider, no more harsh line in light mode + +### v0.5.0 + +- Iconified actions (lucide-react): activate / edit / duplicate / test / delete +- Duplicate provider (deep-copy a provider + all its models) +- Connectivity test (green / orange / red bubble + 6s auto-dismiss) +- Daily trend bars anchored to the panel bottom + +### v0.4.1 + +- Model discovery pagination (OpenAI / Anthropic / Google), fewer large-list limits hit + +### v0.4.0 + +- Polished icon system (library adapted from cc-switch) +- Settings modal (theme / language) +- Launch-time update check + +### v0.3.0 + +Ported from [kimicode-dashboard](https://github.com/JochenYang/kimicode-dashboard): + +- Dashboard: 8 KPIs, daily trend, full-year heatmap, paginated recent requests +- Session manager: per-workspace browsing, preview, archive, bulk delete +- Timezone fix: `today` / heatmap / `day_key` now use local calendar day +- Pi option hidden from the UI + +## FAQ + +**Q: Switching providers didn't take effect in Kimi Code?** +A: Two steps are required: +1. Run `/reload` inside the Kimi Code session so the CLI re-reads `~/.kimi-code/config.toml` (this refreshes the model **dropdown list**); +2. **Then run `/model` to pick the new default**, or simply `/exit` and restart the session. + +Running `/reload` alone **does not** apply the new `default_model` to the current session — this is a known Kimi Code behaviour, see [Known Limitations & Next Steps](#known-limitations--next-steps) below. The app surfaces both steps in its UI. + +**Q: Does switching overwrite other providers?** +A: No. Kimi Code's `config.toml` is always written with all providers, only `default_model` decides which one is active. This matches the CLI's native `/provider` behaviour. + +**Q: Where do new / activated providers go?** +A: They auto-promote to the top of the list; the UI reflects it immediately. + +**Q: Why does the capability editor only show "thinking"?** +A: Kimi Code's `capabilities` field can only be added to, not removed. models.dev already auto-derives the rest; exposing more manually would just invite mistakes. Edit `config.toml` directly when you need to set `always_thinking` or other special values. + +**Q: I edited the config but closed the window without saving?** +A: A native `beforeunload` prompt appears before closing, and the title bar shows a `*` prefix. + +**Q: How do I back up / migrate my config?** +A: For Kimi Code, `config.toml` itself is the authoritative full config — back it up directly. SQLite holds notes / official URLs / ordering, back it up too if you need that. Theme / language / last-update-check live in frontend `localStorage` (WebView2), not a standalone file, so they usually don't need separate handling on migration. + +**Q: Why can't Vertex AI fetch the model list?** +A: Vertex requires GCP project / location credentials. The current implementation leaves a TODO pending GCP SDK integration. + +**Q: macOS / Linux support?** +A: The code doesn't depend on Windows-only APIs, but `tauri.conf.json` only targets `msi` for bundling. In theory, changing `bundle.targets` to `["app", "dmg"]` etc. would enable cross-platform builds, but this is unverified. + +**Q: How do I change the theme / language?** +A: Top-right gear → Settings modal → Theme / Language. Saved to frontend `localStorage` (WebView2), persists across restarts. + +**Q: I closed the window — how do I get it back?** +A: The close X hides to the tray. Click the tray icon (menu bar / system tray) to bring it back; clicking the taskbar icon toggles minimize/restore normally. + +**Q: How is the update check triggered?** +A: It silently checks once on launch, then automatically every 8 hours (silent failure with no network, no error popup). You can also trigger it manually: Settings modal → Version → Update check. Downloads have a progress bar; once done, an "Open installer" button appears. + +## Known Limitations & Next Steps + +### `/reload` does not switch the active session's default model (Kimi Code upstream limitation) + +**Symptom**: After switching a provider in Kimi Switch, `default_model` at the top level of `config.toml` is correctly updated. Back in Kimi Code CLI you run `/reload`: the model **dropdown** now lists the new default, but the **current session still uses the old model** (status-bar model and the actual outgoing requests haven't changed). + +**Root cause** (verified against the Kimi Code source — **not** a Kimi Switch bug): + +- The `/reload` command (`apps/kimi-code/src/tui/commands/reload.ts`) refreshes `availableModels / availableProviders` but **never** re-applies `config.defaultModel` to the active session's agent. +- The session's current model (`agent.config.modelAlias`) comes from `options.model ?? config.defaultModel` at session-create time (`packages/agent-core/src/rpc/core-impl.ts:438-440`) and is persisted to the session log via `records.logRecord`. `Agent.resume()` then calls `records.replay()`, which **replays those records** — so the model snaps back to "what was last picked", not to `config.defaultModel`. +- The unit test (`apps/kimi-code/test/tui/commands/reload.test.ts:87-89`) only asserts "the model list refreshed"; there is **no** assertion that "the active session switched to the new default" — because the implementation never does that step. +- A repo-wide search for `FOLLOW_DEFAULT / reloadDefault / KIMI_CODE_RELOAD` yields **zero** hits, so Kimi Code has **no** "follow default on reload" toggle today. + +**Workaround (works today)**: + +1. After `/reload`, run `/model` and pick the new default manually; or +2. `/exit` and restart the session (the `createSession` path re-applies `config.defaultModel` to the fresh session). + +**Suggested upstream fix for Kimi Code**: + +- File: `apps/kimi-code/src/tui/commands/reload.ts`, in `handleReloadCommand`. +- Spot: right after `applyRuntimeConfig(host, config)`, add a sync of `config.defaultModel` to the active session's agent, e.g.: + ```ts + const newDefault = config.defaultModel; + const current = /* host.session's current agent modelAlias */; + if (newDefault && newDefault !== current) { + await host.session.mainAgent.config.update({ modelAlias: newDefault }); + } + ``` +- Matching test: add an assertion in `reload.test.ts` that "after reload, the session's active model == new `defaultModel`" to guard against regressions. +- Optional enhancement: expose a session-level "follow default on reload" toggle, or auto-fall-back to `default_model` when the session's previous alias has been deleted. + +> Note: this suggestion has **not** been filed upstream yet; it's recorded here for follow-up. Kimi Switch can only guarantee `config.toml` is written correctly — it cannot work around Kimi Code's own `/reload` semantics. + +### Other next-step suggestions (by priority) + +- **P1 — Model alias convention**: early bare-name or `-1/-2` suffixed aliases (e.g. `kimi-k3`, `glm-5-2-1`) have been bulk-renamed to the `provider/model` form; when adding a new provider, **force** this convention to prevent re-introducing suffixed fallbacks. Add a save-time lint. +- **P1 — Multi-dimensional usage trends**: the usage trend already supports two tabs ("model trend" / "provider×model trend"); a future third dimension (per-workspace, or switch the Y axis between tokens and cost) is worth considering. +- **P2 — Periodic update check**: currently launch + every 8h; consider making the cadence configurable (set period / disable in the settings modal). +- **P2 — Cross-platform**: only MSI is produced today, but the code has no Windows-only dependencies; once Tauri v2 macOS/Linux bundling is configured, it can be verified. +- **P3 — Stronger config validation**: `validators.rs` is fairly minimal today; worth adding: `base_url` validity, `env` vs `api_key` mutual-exclusivity, `oauth` block completeness. + +## Security Notes + +- API keys are stored in plaintext in local SQLite and agent native configs — **do not store them on shared computers** +- Do not commit `kimi-switch.db`, `config.toml`, or `models.json` to Git +- The app CSP is tightened (`default-src 'self'`), but WebView2 may still cache form content — log out when finished on public machines + +--- + +## Credits + +The usage dashboard and session manager are ported from [kimicode-dashboard](https://github.com/JochenYang/kimicode-dashboard) (MIT License, © JochenYang). The Rust backend (`src-tauri/src/dashboard.rs`), dashboard UI (`src/components/dashboard/`), and sessions page (`src/components/sessions/`) in this project are derived from that work. Many thanks to the original author. + +The provider brand icon library (`src/icons/extracted/`) and the icon picker (`src/components/IconPicker.tsx`) are adapted from [cc-switch](https://github.com/farion1231/cc-switch) (MIT License, © Jason Young). Many thanks to the original author. + +The provider presets structure (`src/config/providerPresets.ts`) and the balance/plan usage query implementation (`src-tauri/src/services/`) are likewise adapted from [cc-switch](https://github.com/farion1231/cc-switch) (MIT License, © Jason Young). + +--- + +License: MIT, see [LICENSE](./LICENSE). Copyright (c) 2026 CodingPlan.site diff --git a/docs/PROPOSAL-presets-and-usage.md b/docs/PROPOSAL-presets-and-usage.md new file mode 100644 index 0000000..4f62fc2 --- /dev/null +++ b/docs/PROPOSAL-presets-and-usage.md @@ -0,0 +1,625 @@ +# 提案:预设供应商 + 供应商账单查询 + +> **状态**:v0.4-draft(v0.3-draft 已实施;P0 全部落地,新增 v0.4 修订) +> **作者**:Kimi Switch 内部 +> **目标版本**:0.6.0 +> **参考**:cc-switch(MIT,© Jason Young)— 仅借鉴字段结构与 Rust 用量查询层模式 +> **Kimi Code 官方文档**:[providers 配置](https://www.kimi.com/code/docs/kimi-code-cli/configuration/providers.html)、[配置文件](https://www.kimi.com/code/docs/kimi-code-cli/configuration/config-files.html) + +--- + +## 一、背景与目标 + +当前 Kimi Switch 已有"添加供应商"入口(`src/App.tsx:141 handleAddProvider`)直接凭空造一个 `provider-N`,用户需要手填 base_url、provider_type、模型映射等十几个字段。新用户面对一份空白表单门槛很高。 + +同时,用量仪表盘(`src/components/dashboard/`)展示的是**本地会话日志**聚合出来的 token 消耗,与"供应商账户里还剩多少钱/套餐还剩多少"完全无关——后者才能告诉用户"今天是不是该充值了"。 + +**目标**: + +1. **预设供应商**:用户点"添加"时弹出预设选择面板,选中后表单字段自动填好(保留任意编辑权);支持至少 12 条主流国内/海外供应商。 +2. **供应商账单查询**:在 `ProviderList` 卡片底部展示余额/套餐剩余,调用供应商鉴权 API 实时拉取。 + +**非目标**: + +- 不做 OAuth 流程(Claude/Codex 官方订阅)—— 我们不接管 CLI 登录态。 +- 不做"用户自定义 JS 脚本"查询(rquickjs 依赖过重)。 +- 不做用量费用看板(已有本地仪表盘)。 + +--- + +## 二、参考:cc-switch 关键事实 + +### 2.1 预设供应商 + +- **位置**:纯前端 TS 数组,`src/config/*ProviderPresets.ts`,按目标 CLI 工具拆 8+1 个文件,每文件 22–73 条。 +- **数据契约**(`claudeProviderPresets.ts:25-74`): + - `name / nameKey(i18n) / websiteUrl / apiKeyUrl` + - `settingsConfig`(真正写进配置的对象,**结构因工具而异**:Claude 用 `ANTHROPIC_*` env、Codex 用 auth.json + TOML) + - `category`: `official | cn_official | third_party | aggregator` + - `isOfficial / isPartner / primePartner` + - `apiKeyField`(Claude 特有)、`endpointCandidates[]`、modelsUrl + - `theme / icon / iconColor / apiFormat` +- **触发**:`ProviderForm` 表单顶部内嵌 `ProviderPresetSelector`(grid 按钮 + 搜索 + 排序);选中即 `form.reset` 灌字段。 +- **模型清单来源**:硬编码写进 `settingsConfig.env` 或 `modelCatalog[]`;**不**走 models.dev,**不**在预设里调 API。动态拉模型是另一个独立按钮。 +- **许可证**:MIT,可直接借鉴字段结构与预设数据。 + +### 2.2 账单/用量查询 + +cc-switch 在 Rust 端实现了 6 套并行的查询层(详见 `src-tauri/src/services/`),与本项目相关的有 2 套: + +| 机制 | 文件 | 覆盖 | 关键端点 | +|---|---|---|---| +| **A. 余额查询** | `balance.rs` | DeepSeek / StepFun / SiliconFlow / OpenRouter / Novita | `GET {base_url}/user/balance` 等 | +| **B. Token Plan 套餐** | `coding_plan.rs` | **Kimi For Coding** / 智谱 GLM / MiniMax / 火山 / ZenMux | `GET https://api.kimi.com/coding/v1/usages` | + +**统一返回契约**(`balance.rs:1-10` 注释): + +```rust +struct UsageResult { + success: bool, + data: Option>, + error: Option, +} +struct UsageData { + plan_name: Option, + remaining: Option, + total: Option, + used: Option, + unit: Option, + is_valid: Option, + resets_at: Option, +} +``` + +**错误通道语义**(这套设计很成熟,建议照搬): + +- `Err(_)` = 瞬时失败(网络/超时/读体中断)→ 前端 retry + keep-last-good。 +- `Ok(success:false)` = 确定性失败(空 key / 401 / 非 2xx / JSON 解析失败)→ 直接透出错误文案。 + +**鉴权**:统一 `Authorization: Bearer `。 + +**路由**:`detect_provider(base_url)` 按子串匹配 → 调对应 `query_xxx`。 + +--- + +## 三、KimiSwitch 落点 + +| 现有结构 | 落点 | +|---|---| +| `src/App.tsx:141 handleAddProvider`(直接造空 `provider-N`)| 改为先打开 `PresetPickerModal` | +| `src/components/ProviderEdit.tsx` basic tab | 顶部加"切换预设"下拉(v1 不做) | +| `src-tauri/src/models.rs Provider` | 不动;预设转 Provider 由前端完成 | +| `src-tauri/src/commands.rs` | 新增 `query_provider_usage(base_url, api_key, usage_kind)` | +| `src-tauri/src/`(无 `services/`)| 新建 `services/{mod.rs, usage_types.rs, balance.rs, coding_plan.rs}` | +| `src/components/ProviderList.tsx` 卡片 | 卡片底部新增 `` | +| `src/i18n/{zh,en}.ts` | 加 i18n key(preset 名 / 账单相关) | +| `src/lib/models-dev.ts` | 复用:补全预设 model 的 `maxContextSize` / `capabilities` | + +**已有依赖**(已满足): + +- `Cargo.toml`: `reqwest = { version = "0.12", features = ["json","rustls-tls","stream"] }` ✅ +- `Cargo.toml`: `chrono = { version = "0.4", features = ["serde"] }` ✅ + +**私有字段存储约定**(**重要变更:v0.3 修订**): + +早前提议把 `usageKinds` 写进 `provider.raw_other["kimi-switch.usageKinds"]` 通过 `config.toml` 透传。**此方案否决**——`kimi-switch.` 前缀不是 Kimi Code 注册的 namespace,"安全"只是 Zod 非 strict + raw 透传的实现现状,无版本兼容承诺。 + +**v0.3 改为:** + +- `usageKinds` 存进 **SQLite** 的 `settings` 表(已有 per-agent 设置表,key 形式 `usage_kinds:`,值为 JSON 数组字符串,如 `["balance:deepseek"]` 或 `["plan:kimi_coding","balance:custom"]`) +- `load_agent_config_command` 在返回 config 时**自动合并** SQLite settings 中的 usageKinds 到每个 provider(前端消费时是无感的) +- Rust 端导出 `config.toml` 时**完全不写** `usageKinds` 字段 +- 卸载 / 退出登录 / 备份 `config.toml` 都不影响 `usageKinds`(独立存储) + +**优势**: + +1. 不污染用户的 Kimi Code 配置(终审 A8 解决) +2. 用户改 `provider_type` / `base_url` 后,Rust 端 `detect_provider(base_url)` 自动重新决定 `usageKinds`(B-D1 解决) +3. 旧用户升级 0.6.0 时,所有匹配 `detect_provider` host 列表的 provider **自动获得** `usageKinds`(B-D2 解决) +4. 数组形式让同一 provider 同时支持套餐 + 余额查询(如 SiliconFlow 未来加套餐) + +--- + +## 四、方案 1:预设供应商 + +### 4.1 数据模型 + +新建 `src/config/providerPresets.ts`: + +```ts +export interface ProviderPreset { + /** 唯一 key,作为默认 Provider name 使用,如 "deepseek" */ + id: string; + /** 显示名(中文/英文) */ + name: string; + /** i18n key,可选;存在时优先用 t(nameKey) */ + nameKey?: string; + /** 官网/拿 key 的链接 */ + websiteUrl?: string; + apiKeyUrl?: string; + /** 分类:排序 & 标签 */ + category: "official" | "cn_official" | "third_party" | "aggregator" | "custom"; + /** 真正写进 Provider 的字段 */ + providerType: ProviderType; + /** 预设 base_url;`null` 表示无默认值(如 Anthropic、CodingPlan.site)。 + * 当选中的 provider.type 对应的 `defaultBaseUrl()` 返回非空时, + * PresetPicker 会**用预设值覆盖它**,保存后以 `provider.base_url` 为唯一真相。 */ + baseUrl: string | null; + /** 复用现有 IconPicker 命名(无品牌图标时按 base_url 走首字母兜底) */ + icon?: string; + iconColor?: string; + /** 预填的模型映射(alias → model id)。 + * 数组第一个 ⇒ 设 default_model。 + * 数组为空时,default_model 留空,由用户通过「拉取模型」补齐。 + * + * **alias 强制规范**:`provider/${modelId}` 形式 —— 例如 `deepseek/deepseek-chat`。 + * 不能用裸 alias(如 `chat`),否则会破坏 `handleDuplicateProvider` 的 + * `alias.slice(name.length)` 推导:若 provider=`copy`,会得到空串, + * 复制时所有模型都写到 `copy` 上,后者覆盖前者。 */ + models: Array<{ + alias: string; + model: string; + /** 可选:覆写显示名;缺省走 models.dev 推导 */ + displayName?: string; + /** 可选:覆写 context;缺省走 models.dev [API→models.dev→正则] 三级 */ + maxContextSize?: number; + /** 默认 ["thinking"] */ + capabilities?: string[]; + }>; + /** 关联方案 2:账单查询类型;缺省时不查。 + * **v0.3 改为数组**:同一个供应商可能同时支持套餐 + 余额(如 SiliconFlow + * 既有平台余额又有套餐),数组形式让两端都查、UI 都展示。 + * 注意:`plan:volcengine` **不在 v1 union** —— Volcengine 套餐需独立 AK/SK + * (不是推理 api_key),需要额外的 `access_key_id / secret_access_key` 参数, + * v1 不支持,留待 P2。 */ + usageKinds?: ReadonlyArray< + | "balance:deepseek" | "balance:siliconflow" | "balance:openrouter" + | "balance:stepfun" | "balance:novita" + | "plan:kimi_coding" | "plan:zhipu" | "plan:minimax" + >; +} +export const providerPresets: ProviderPreset[] = [ /* … */ ]; +``` + +**`presetToProviderAndModels()` 显式转换契约**(v0.3 重要补充): + +```ts +export function presetToProviderAndModels(preset: ProviderPreset, options: { + existingProviderNames: Set; + existingModelAliases: Set; +}): { + provider: Provider; + models: Model[]; + defaultModel: string; // 用于写入 config.default_model + usageKinds: ReadonlyArray | undefined; // 用于写入 SQLite settings +} { + // Provider.name 冲突 → 追加 -2 / -3 + let name = preset.id; + let n = 2; + while (options.existingProviderNames.has(name)) name = `${preset.id}-${n++}`; + + // 模型 alias 冲突 → 追加 -2 / -3 + const models: Model[] = preset.models.map((m, i) => { + let alias = `${name}/${m.model}`; // 强制规范 + let k = 2; + while (options.existingModelAliases.has(alias)) alias = `${name}/${m.model}-${k++}`; + + // max_context_size 4 级优先级 + const ref = modelsDevRef(m.model); + const maxContextSize = + m.maxContextSize + ?? ref?.maxContextSize + ?? parseContextFromName(m.model) + ?? DEFAULT_MAX_CONTEXT_SIZE; // 256000 + + return { + alias, + provider: name, + model: m.model, + max_context_size: maxContextSize, + display_name: m.displayName ?? ref?.displayName ?? alias, + capabilities: m.capabilities ?? ref?.capabilities ?? ["thinking"], + supports_1m: maxContextSize >= 1_000_000, + raw_other: {}, + }; + }); + + const provider: Provider = { + name, + provider_type: preset.providerType, + base_url: preset.baseUrl, + api_key: null, // 用户补 + env: {}, + note: null, + official_url: preset.websiteUrl ?? null, + managed: false, + enabled: true, + icon: preset.icon ?? null, + icon_color: preset.iconColor ?? null, + raw_other: {}, + }; + + return { + provider, + models, + defaultModel: models[0]?.alias ?? "", + usageKinds: preset.usageKinds, + }; +} +``` + +**字段映射表**(明确每条字段从哪来): + +| Preset 字段 | Provider/Model 字段 | 备注 | +|---|---|---| +| `providerType` | `provider.provider_type` | 直透 | +| `baseUrl` | `provider.base_url` | `null` → 留空 | +| `websiteUrl` | `provider.official_url` | | +| `icon` | `provider.icon` | `null` → 走首字母兜底 | +| `iconColor` | `provider.icon_color` | | +| `models[].alias` | `models[].alias` | **强制 `${uniqueProviderName}/${modelId}`** | +| `models[].model` | `models[].model` | 直透 | +| `models[].displayName` | `models[].display_name` | 缺省走 models.dev | +| `models[].maxContextSize` | `models[].max_context_size` | 优先级链 | +| `models[].capabilities` | `models[].capabilities` | 缺省 `["thinking"]` | +| `id` | `provider.name` | 冲突 → `-2` | +| `usageKinds` | SQLite `settings` 表 | **不**写 `config.toml`;数组形式支持同一供应商多种查询 | + +### 4.2 v1 预设清单(15 条) + +| id | 供应商 | category | provider_type | base_url | icon | iconColor | usageKinds | 备注 | +|---|---|---|---|---|---|---|---|---| +| `anthropic` | Anthropic 官方 | official | anthropic | `null` | `anthropic` | — | — | 无默认 base_url;Kimi Code 自动走 `api.anthropic.com` | +| `kimi-coding` | Kimi Code 托管服务(会员订阅)| official | **kimi** | `https://api.kimi.com/coding/v1` | `kimi` | — | `[plan:kimi_coding]` | Kimi Code 托管服务走 `kimi` 类型(OpenAI 兼容 + Kimi identity headers + 视频上传)。Base URL 同时兼容 Anthropic 协议 `https://api.kimi.com/coding/`,但 preset 用 OpenAI 兼容路径 | +| `moonshot` | Moonshot Platform API(按量付费)| cn_official | **kimi** | `https://api.moonshot.ai/v1` | `kimi` | — | — | Moonshot 平台 API 密钥用户;官方文档明确 `kimi` 类型默认 base_url 即此(与 `.cn` 互为别名) | +| `deepseek` | DeepSeek | cn_official | openai | `https://api.deepseek.com/v1` | `deepseek` | — | `[balance:deepseek]` | — | +| `zhipu` | 智谱 GLM | cn_official | openai | `https://open.bigmodel.cn/api/paas/v4` | `zhipu` | — | `[plan:zhipu]` | — | +| `zai` | z.ai(智谱海外)| third_party | openai | `https://api.z.ai/api/paas/v4` | `zhipu` | — | `[plan:zhipu]` | — | +| `bailian` | 阿里百炼 | cn_official | openai | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `bailian` | — | — | — | +| `minimax` | MiniMax | cn_official | openai | `https://api.minimaxi.com/v1` | `minimax` | — | `[plan:minimax]` | — | +| `stepfun` | StepFun(阶跃星辰)| cn_official | openai | `https://api.stepfun.com/v1` | `stepfun` | — | `[balance:stepfun]` | — | +| `siliconflow` | 硅基流动 | cn_official | openai | `https://api.siliconflow.cn/v1` | `siliconflow` | 显式补色(默认灰色)| `[balance:siliconflow]` | `siliconflow` 在 `src/icons/extracted/index.ts:93` 有 SVG,但 `metadata.ts` 没有;需显式 `iconColor` | +| `novita` | Novita AI | third_party | openai | `https://api.novita.ai/v3` | `novita` | — | `[balance:novita]` | 注意 `/v3`(非 `/v1`) | +| `openrouter` | OpenRouter | third_party | openai | `https://openrouter.ai/api/v1` | `openrouter` | — | `[balance:openrouter]` | — | +| `openai` | OpenAI 官方 | official | openai | `https://api.openai.com/v1` | `openai` | — | — | — | +| `google-genai` | Google AI Studio | official | google-genai | `https://generativelanguage.googleapis.com` | `google` | — | — | — | +| `volcengine` | 火山方舟(仅推理)| cn_official | openai | `https://ark.cn-beijing.volces.com/api/v3` | `huoshan` | — | — | **v1 不支持套餐查询**(需 AK/SK),仅作推理 fallback | + +**`codingplan`(自定义中转)v1 不做 preset**——价值低(无 base_url、无 usageKinds),与预设"一键填表"目标冲突。改在 `PresetPickerModal` 底部保留独立的"**+ 自定义配置**"按钮(来自空表单),与 cc-switch `ProviderPresetSelector.tsx:392-404` 一致。 + +**预估 icon 字段值校核**:上面表的 `icon` 值均来自 `src/icons/extracted/index.ts` 真实存在的 key;不能使用 `brands.ts` 推断 key 作显式 icon(两者命名空间不同)。P0 实现时若发现某个 key 不存在,需在 `src/icons/extracted/index.ts` 补 entry。 + +**模型清单里的 `maxContextSize` / `capabilities` 优先级**(与拉取模型同一体系): + +1. 预设内置值(`ProviderPreset.models[].maxContextSize / capabilities / displayName` — 若有) +2. `src/lib/models-dev.ts` 快照查找(`getModelRef`) +3. 正则兜底(`src/lib/model-defaults.ts`) +4. 全部 miss → **`DEFAULT_MAX_CONTEXT_SIZE`**(`src/lib/model-defaults.ts:1,83-89` 实际为 **256000**,不是 128000) + +> v0.3 修正:早期文档误写 128000;统一用 `DEFAULT_MAX_CONTEXT_SIZE` 常量,避免双源真相。 + +**预设里绝大多数模型只需放 `alias` + `model id`**;仅对 models-dev 快照无覆盖的新发模型(如国产首发)才填覆写字段。 + +### 4.3 UI 流程 + +**入口 A(v1 必做)`PresetPickerModal`**: + +``` +┌────────────────────────────────────────────────┐ +│ 选择预设供应商 [X] │ +├────────────────────────────────────────────────┤ +│ 🔍[搜索] [排序:原始│A-Z] │ +├────────────────────────────────────────────────┤ +│ [K] Kimi Coding [官方] │ +│ [A] Anthropic [官方] │ +│ [D] DeepSeek [国产] │ +│ [Z] 智谱 GLM [国产] │ +│ …(grid 150px×n) │ +│ [+自定义配置] │ +├────────────────────────────────────────────────┤ +│ 预设只是一键填表,后续可继续修改所有字段。 │ +└────────────────────────────────────────────────┘ +``` + +- 触发:① "添加"按钮 ② 顶部"+ 添加供应商" +- 选中预设后: + 1. 用预设 `id` 作为默认 `Provider.name`(冲突时尾部追加 `-2`、`-3`…) + 2. 把 `models[]` 转成 `Model` 加进 `config.models`,**每个 alias 逐一检测冲突**:alias 已存在 → 尾部追加 `-2` / `-3`;**第一个 model** 设为 `default_model`(经过去重后的 alias) + 3. 写入 `provider.raw_other["kimi-switch.usageKinds"] = usageKinds`(沿用 raw_other 私有字段约定) + 4. 跳到 `ProviderEdit` 让用户填 `api_key`(其余字段已预填) +- 分类徽章:官方 = 蓝、国产 = 绿、第三方 = 灰、聚合 = 紫 +- 网格自适应:`repeat(auto-fill, minmax(140px, 1fr))`,参考 cc-switch `ProviderPresetSelector.tsx:392` + +**入口 B(v2,可选)`ProviderEdit` 顶部"切换预设"下拉**:对已存在但想换预设的供应商也能用。 + +### 4.4 持久化约定 + +- **name 由预设 id 派生**,用户可后续在表单里改名(不要锁)。 +- **`usageKinds` 存进 SQLite settings 表**(key: `usage_kinds:`,值为 JSON 数组),不写进 `config.toml`。详见 §三"私有字段存储约定"。 +- **不使用** `raw_other` 存 usageKinds(v0.3 修订:避免污染 Kimi Code 配置 + 支持 Rust 端自动 host detect)。 + +--- + +## 五、方案 2:供应商账单查询 + +### 5.1 Rust 端 + +``` +src-tauri/src/ +├── services/ +│ ├── mod.rs // pub use + 统一入口 +│ ├── usage_types.rs // UsageResult / UsageData +│ ├── balance.rs // 余额类(5 家) +│ └── coding_plan.rs // 套餐类(5 家) +└── commands.rs // +#[tauri::command] query_provider_usage +``` + +**usage_types.rs**(**注意必须加 camelCase** —— 缺它前端 `resetsAt/planName/isValid` 全部错位): + +```rust +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UsageData { + pub plan_name: Option, + pub remaining: Option, + pub total: Option, + pub used: Option, + pub unit: Option, + pub is_valid: Option, + pub resets_at: Option, +} +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UsageResult { + pub success: bool, + pub data: Option>, + pub error: Option, +} +``` + +> **与 cc-switch 真实结构的差异**(重要):cc-switch 的 `UsageData` 在 `provider.rs:282-314` 还含 `extra: Option` / `invalid_message: Option`,**没有** `resets_at`(该字段在 `SubscriptionQuota.tiers[].resets_at`)。本方案**不"照抄"**,而是把套餐的 `tiers` 展平成数组 + 每项带 `resets_at` 字段,统一前端消费面。`SubscriptionQuota → UsageData[]` 的转换规则实现时需明确(每个 tier 一条 `UsageData`,`plan_name = tier.name`,`used/total/remaining/unit` 视情况映射)。 + +**balance.rs / coding_plan.rs**:直接 port cc-switch 实现的 5 家 + 5 家(~670 行 + ~2200 行)。重点实现: + +- `detect_provider(base_url) -> Option<...>`:按子串匹配 +- `query_xxx(api_key) -> Result`:分别处理 401、读体失败、解析失败 +- **读体失败和解析失败要区分**:先 `bytes().await` 拿到完整字节再 `serde_json::from_slice`(见 balance.rs:99-108 的注释,reqwest `.json()` 把读体错也包成 decode,会丢信息) + +**command.rs 新增**(**v0.3 修订 + v0.4 落实**:传 `provider_name` 让 Rust 端从 config 加载 key 与 usageKinds,避免 IPC 序列化 key): + +```rust +#[tauri::command] +pub async fn query_provider_usage( + agent: Agent, // v0.4 落实:项目 config 按 agent 隔离,必须传 + provider_name: String, + force_refresh: Option, // true = 跳过 5min 缓存 +) -> Result { + // 1. 从 config / SQLite 加载该 provider + // 2. 如果 api_key 为空 → 自动 fallback 到 provider.env 里的 + // expected_api_key_key(provider_type) 对应 key(v0.4 落实) + // 3. 读取 usageKinds: 优先 SQLite 显式设置(JSON 数组)→ fallback detect_provider(base_url) → success:false "unknown provider" + // 4. 对每个 kind 路由到对应 query_xxx,合并所有结果到一个 UsageData[] + // 5. 返回 UsageResult(data 含全部 kind 的条目) +} +``` + +**v0.4 重要**:Rust 端除 `provider.api_key` 外,**会兜底读取 `provider.env` 里的 `expected_api_key_key(provider_type)`**(如 Kimi 平台期望 `KIMI_API_KEY`)。这只读 env 用于实际查询,**不**写回 config.toml,给只配 env 不配 api_key 的用户兜底。 + +**重要**:前端**不**传 `api_key` / `usage_kind` / `base_url`。Rust 端统一从配置加载,杜绝: +- API key 经 IPC 序列化(终审 D4) +- 前端绕过 host 校验乱发请求(终审 D1) +- Rust detect 与前端规则矛盾(终审 D2) + +**Tauri 配置文件注册**(**v0.3 修订**:Rust reqwest 不走 Tauri HTTP plugin,不需 capability): + +1. `commands.rs` 加 `#[tauri::command]` ✅ +2. `lib.rs` 的 `tauri::generate_handler![…]` 追加 `query_provider_usage` +3. `capabilities/default.json` **无需改动**(现有 `test_connectivity` 同样用 Rust reqwest,不需 HTTP scope) + +**Tauri config**:`reqwest::get` 是 Rust 端发出,**不**走 WebView 网络栈,**不**需要改 CSP。 + +### 5.2 前端 + +**ProviderList 卡片底部的 ``**: + +``` +┌────────────────────────────────────────┐ +│ Kimi For Coding [使用中] │ +│ 默认: kimi-k2.7-code 3 models │ +├────────────────────────────────────────┤ +│ ⚡ 35% 已使用 · 5h 后重置 [刷新] │ ← 套餐类(green/orange/red) +└────────────────────────────────────────┘ + +┌────────────────────────────────────────┐ +│ DeepSeek [使用中] │ +│ 默认: deepseek-chat 2 models │ +├────────────────────────────────────────┤ +│ 💰 余额 ¥12.34 [刷新] │ ← 余额类 +└────────────────────────────────────────┘ +``` + +- 配色:<70% 绿 / 70-89% 橙 / ≥90% 红(对齐 cc-switch) +- **多 kind 渲染**:同一 provider 的 `usageKinds` 数组可能含多种(如 `[plan:kimi_coding, balance:custom]`);`UsageData[]` 每条独立渲染为一行,余额类显金额、套餐类显百分比+倒计时 +- 倒计时:前端 JS 算 `new Date(resetsAt) - Date.now()`,按区间显示"5h 后重置 / 3d 后重置"(绝对时间,Rust 端已转 ISO 8601,不依赖时区) +- 缓存:**5min stale TTL**(`force_refresh` 跳过)+ Manual 刷新(**v0.3 修订**:与 cc-switch `queries.ts:245-277` 对齐)。进入 ProviderList 时对有 usageKinds 的供应商并发查一次,**最多同时 3 个请求**(`Promise.allSettled` + 简易信号量),单请求超时 8 秒 +- 错误:网络错"网络异常"、401"API Key 无效"、其他"查询失败" —— 不阻塞主界面 +- 没 `usageKinds` 字段(undefined 或空数组)→ **完全不显示 footer,不发起任何请求** + +**状态机**(每个 provider 独立维护): + +| 当前状态 | 触发 | 行为 | +|---|---|---| +| `idle` | 首次进入 ProviderList | → `loading`(并发 ≤3) | +| `loading` | 响应到达 | `ok` → `success`;`err` → `error` | +| `success` | 用户点「刷新」 | → `loading`,保留上一次 `data` 做 ghost 展示 | +| `error` | 用户点「重试」 | → `loading` | +| `success`/`error` | 缓存超 30s,重新进入列表 | 自动 → `loading` | +| 任何 | 用户清空 `api_key` | → `idle`(不发起请求,见 §5.1 命令层检查) | + +**渲染**: + +- `loading`:骨架屏或上次数据的半透明 ghost +- `success`:余额/百分比 + 配色 + 重置倒计时 +- `error`:红色错误提示 + 「重试」按钮 +- `idle`:无(不渲染 UsageFooter) + +**触发与权限**(**v0.3 修订**): + +- 启动时**不**预查 +- 进入 ProviderList 视图时并发查 1 次(≤3 并发,8s 超时) +- 用户点"刷新"按钮才再次查(5min stale TTL) +- 全局开关:设置面板加"启动账单查询"开关,关闭后 Rust 端拒绝查询(默认开启) +- **前端不做 usageKinds 决策**:单纯 `invoke("query_provider_usage", { provider_name: "deepseek" })`,Rust 端返回 `success: false` 时客户端仅展示错误,不做 host 校验 + +### 5.3 与现有"用量仪表盘"的关系 + +- **现有仪表盘**:本地 SQLite 聚合的 token 计数 + 费用估算(基于 `models.dev` 价格) +- **新增账单查询**:供应商侧的余额/套餐 +- **不重复**:仪表盘 chip 区域不显示余额,余额只在 ProviderList 卡片底部 +- **联动**:可选地(v2)在仪表盘 KPI 加一个"供应商侧配额"卡,但 v1 不做 + +--- + +## 六、里程碑 + +| 阶段 | 内容 | 优先级 | +|---|---|---| +| **P0** | 方案 1:15 条预设 + `PresetPickerModal` + App 入口接入 | P0 | +| **P0** | 方案 2 骨架:`services/{usage_types,mod}.rs` + `query_provider_usage` 命令 + `UsageResult` + **camelCase 序列化** | P0 | +| **P0** | `query_provider_usage` 两层注册(commands.rs + lib.rs;**无 capabilities 变更**) + Rust 端 mock 测试 | P0 | +| **P0** | SQLite 改造:`usage_kinds:` 存 settings 表(JSON 数组);`load_agent_config_command` 合并回 config;export 时不写 usageKinds | P0 | +| **P0** | `detect_provider(base_url)` 启发式:迁移期旧用户自动获得 usageKinds | P0 | +| **P0** | 方案 2 前端:`` + 卡片接入 + 错误处理 + 配色 + 状态机 + 5min stale TTL | P0 | +| **P0** | **套餐类 v1:Kimi For Coding(`api.kimi.com/coding/v1/usages`)**(项目主题,优先) | P0 | +| **P0** | 余额类 v1:DeepSeek / SiliconFlow / OpenRouter(3 家) | P0 | +| **P0** | 前端手工验证 checklist(见 §十一 #20) | P0 | +| **P1** | 套餐类补全:智谱 GLM / MiniMax | P1 | +| **P1** | 余额类补全:StepFun / Novita | P1 | +| **P2** | `ProviderEdit` 顶部"切换预设"下拉(入口 B) | P2 | +| **P2** | 设置面板"启动账单查询"开关 | P2 | +| **P2** | 倒计时 i18n 友好格式化("5小时后" / "in 5h") | P2 | +| **P2** | Volcengine 套餐查询(独立 AK/SK 支持 + UI) | P2 | +| **P3** | 自定义 JS 脚本兜底(usage_script.rs + rquickjs) | P3 | +| **P3** | 仪表盘 KPI 加"供应商侧配额"卡 | P3 | + +--- + +## 七、许可证与引用 + +cc-switch 是 MIT(`D:/AIGC/cc-switch/LICENSE`),Rust 实现(`balance.rs` / `coding_plan.rs`)可直接移植;预设数据(base_url、模型 id 等公开信息)不构成版权客体。 + +**文件头添加**: + +```rust +// Adapted from cc-switch (MIT, © Jason Young) +// https://github.com/farion1231/cc-switch +``` + +**README 致谢段补一条**: + +> 供应商预设结构与余额/套餐查询实现参考自 [cc-switch](https://github.com/farion1231/cc-switch)(MIT,© Jason Young)。 + +**配置文件同步**:preset 名 i18n key 同样放 `src/i18n/{zh,en}.ts`。 + +--- + +## 八、风险与待确认 + +| 风险 | 缓解措施 | 状态 | +|---|---|---| +| 预设的 `id` 与用户已存在的 `provider.name` 冲突 | `presetToProviderAndModels()` 内 `while` 循环追加 `-2` / `-3` | ✅ | +| 模型 alias 冲突 | 强制 alias = `${name}/${modelId}`,冲突追加 `-2` / `-3` | ✅ | +| 裸 alias 破坏 `handleDuplicateProvider` | **强制规范**(§4.1 转换契约);`src/App.tsx:271` `alias.slice` 改造为 `startsWith` 显式判断 + 完整 re-prefix fallback(v0.4 落实) | ✅ | +| `usageKinds` 漂移(TS 端枚举与 Rust 端 enum 各自维护) | TS 端 enum + Rust 端 enum 各自声明;TS 端在 dev 模式加载 `providerPresets.ts` 时对每个 preset 的 usageKinds 做运行时断言(v0.4 新增 `SUPPORTED_USAGE_KINDS` Set + `import.meta.env.DEV` guard) | ✅(dev-only 断言)| +| Zhipu/MiniMax 的套餐端点可能限频 | 5min stale TTL + 失败 keep-last-good;网络错误自动 retry | ✅ | +| Kimi For Coding 套餐鉴权:官方文档已确认 `kimi` 类型 + `Authorization: Bearer ` 即可,与 cc-switch 实现一致 | ✅ 已核验([官方 providers 文档](https://www.kimi.com/code/docs/kimi-code-cli/configuration/providers.html))| ✅ | +| StepFun / Novita 的 base_url 匹配与现有 OpenAI 兼容克隆冲突 | `detect_provider` 在 `detect_provider_openai` 之前调用 | ✅ | +| `defaultBaseUrl` 与预设 base_url 双方有值 | 预设的 baseUrl **覆盖** `default_base_url()`;保存时以 `provider.base_url` 为唯一真相 | ✅ | +| provider 改 `base_url` / `provider_type` 后旧 usageKinds 错路由 | usageKinds 不显式存 provider 字段,Rust 端每次 `query_provider_usage` 重新 `detect_provider(base_url)` | ✅ | +| 旧用户升级 0.6.0 后无 usageKinds | Rust 端 `detect_provider(base_url)` 启发式作为 fallback,自动识别 DeepSeek / SiliconFlow / OpenRouter / StepFun / Novita / Kimi Coding / 智谱 / MiniMax | ✅ | +| API key 暴露:当前 edit UI 已持有明文 key,IPC invoke 序列化 key 是"再次"暴露面 | command 改为 `provider_name`,Rust 端从 config 加载 key,**不**经 IPC 传 key。错误/调试日志中严禁输出 key | ✅ | +| Tauri capability 是否需要 | Rust reqwest 不走 Tauri HTTP plugin,无 capability 变更需求 | ✅ | +| `usageKinds` 写进 config.toml 污染 Kimi Code 配置 | 存 SQLite settings 表(JSON 数组),export 时不写 | ✅ | +| 限额与套餐 UI 区分 | 按 `plan_name` 判断:`five_hour` / `weekly_limit` → 百分比+倒计时;其他 → 余额数值 | ✅ | +| Pi 与 KimiCode 同名 provider 共享 UsageFooter 缓存造成串扰 | v0.4 落实:模块级 cache key 改为 `${agent}:${providerName}`(UsageFooter.tsx:36) | ✅ | +| 用户用 env 配 key 但 `api_key` 字段为空 | v0.4 落实:Rust 端 `query_provider_usage` 兜底读 `provider.env[expected_api_key_key(provider_type)]` | ✅ | + +--- + +## 九、需要在开始前确认的决策点 + +**状态标签说明**: +- ✅ `代码已核验` —— 已对照源码确认可行 +- 🟡 `产品决策` —— 已选,但落地细节随实现调整 +- 🟠 `待真实 API 实测` —— 需真实 key 实测后才能标完成 +- ⚪ `待用户确认` —— 文档中给了倾向,但需用户最终决定 + +1. ✅ **预设触发方式**:先做入口 A(独立弹窗)。入口 B 推迟到 P2。 +2. 🟡 **预设的模型 maxContextSize / capabilities**:靠 models.dev 推导 + 预设可选覆写字段(§4.1 已明确 4 级优先级)。 +3. 🟡 **账单查询触发**:首次进入自动查(≤3 并发)+ 5min stale TTL + 刷新/重试按钮。 +4. 🟡 **Kimi For Coding 套餐**:P1(先 P0 跑通后再做)。 +5. ✅ **自定义 JS 脚本**:不做(rquickjs 过重)。 +6. 🟡 **预设 v1 清单**:15 条(不含 volcengine 套餐 + codingplan),够用。 +7. ✅ **usageKinds 持久化**:SQLite settings 表(JSON 数组,v0.3 修订),不写 config.toml。 +8. ✅ **kimi-coding preset 的 `provider_type`**:用 **`type: "kimi"`** + `base_url: "https://api.kimi.com/coding/v1"`。**官方文档已确认**:`kimi` 类型 = "对接 Moonshot AI 的 OpenAI 兼容接口,包括 Kimi Code 托管服务 + Kimi Platform API 密钥"。`moonshot` preset 同样用 `kimi` 类型(之前误写 `openai`,已修)。 +9. ⚪ **Volcengine 套餐 v1 不做的取舍**:v1 仅保留 volcengine 推理预设,去掉套餐查询。是否可接受? + +**用户实标 ✅ 决议后的项目**(待用户回复 8/9 后): + +--- + +## 十、附录:cc-switch 借鉴到的关键代码位置 + +- `src/config/claudeProviderPresets.ts:25-74` — `ProviderPreset` 接口 +- `src/config/codexProviderPresets.ts:13-46` — `CodexProviderPreset` 接口(带 modelCatalog) +- `src/components/providers/forms/ProviderPresetSelector.tsx:142-500` — Reference UI +- `src/components/providers/forms/ProviderForm.tsx:1794-1899` — `handlePresetChange` 字段注入逻辑 +- `src-tauri/src/services/balance.rs:1-454` — 余额查询完整实现(5 家) +- `src-tauri/src/services/coding_plan.rs:100-206` — Kimi For Coding 单家实现 +- `src-tauri/src/provider.rs` — `UsageResult` / `UsageData` 类型定义 +- `docs/user-manual/en/2-providers/2.5-usage-query.md` — 设计语义文档(中文版同路径) + +--- + +## 十一、自审发现的问题(v0.1 → v0.2 已修正) + +> 本节是第一轮自审发现的问题。v0.2 已全部修正或记录到正文 / §八风险表 / 对应章节。 +> +> 第三轮审核额外发现 20 项(6 阻塞 + 7 类型/语义 + 7 逻辑/边界),已直接修入正文,不在此重复。关键修正: +> - B1 「baseUrl: string → string | null」已修正(§4.1) +> - B2 「category 缺少 custom」已补(§4.1) +> - B3 「model alias 冲突检测」已补(§4.3 步骤 2) +> - B5 「三层注册清单」已补(§5.1) +> - B6 「并发控制 ≤3」已补(§5.2) +> - L1 「icon 补齐」已在 §4.2 表为预设加 `icon` 字段(值由 P0 实现时从现有 IconPicker 名映射表中读取) +> - L2 「models-dev 缺失回退 4 级优先级」已补(§4.2 底部) +> - L3 「状态机」已补(§5.2) +> - L5 「空 api_key 拒绝」已补(§5.1 命令层) +> - S4 「models 数组为空」已补(§4.1 注释) + +| # | 问题 | 状态 | +|---|---|---| +| 1 | **预设 `id` 与已有 provider.name 冲突** | ✅ §4.4 已解决 | +| 2 | **usageKinds 写进 raw_other 会进 config.toml** | ✅ §三 私有字段持久化约定已明确 `kimi-switch.` 命名空间 | +| 3 | **默认 base_url 来源双源** | ✅ §八·风险 #7 + §4.1 baseUrl 注释 | +| 4 | **TS 端 usageKinds 与 Rust 端 enum 漂移** | ✅ §八·风险 #2 | +| 5 | **Kimi For Coding 鉴权格式未实测** | ✅ §八·风险 #5:P1 前 curl 验证 | +| 6 | **StepFun/Novita base_url 与 OpenAI clone 冲突** | ✅ §八·风险 #6 | +| 7 | **倒计时 i18n 友好格式化** | → §六 P2 | +| 8 | **账单查询全局开关** | → §六 P2 | +| 9 | **入口 B 切换预设** | → §六 P2 | +| 10 | **model 字段名 vs Kimi Code 识别** | 核验后确认:`[models.alias]` 块与 `[providers.name]` 块独立,无冲突风险 ✅ | +| 11 | **图标名 → IconPicker 映射表** | → P0 实现时补 | +| 12 | **设置面板账单查询开关分组** | → §六 P2:放"设置"Tab 同一分组 | +| 13 | **模型 alias 命名规范** | ✅ §4.1 已明确 `provider/model` 形式 | +| 14 | **Moonshot 无余额 API → 不显示 footer** | ✅ §5.2:无 usageKinds = 不渲染 | +| 15 | **OpenAI 协议选择 `openai`** | ✅ 不暴露 `openai_responses` 让用户困惑 | +| 16 | **多端点候选 v1 不做** | ✅ | +| 17 | **extra_usage 字段简化** | ✅ §5.1 只留 `UsageData[]` | +| 18 | **is_valid: false 透出** | ✅ §5.1 类型已含 | +| 19 | **跨会话缓存 v1 不做** | ✅ 30s TTL 够用 | +| 20 | **测试覆盖 P0 必须** | ✅ 已加回 §六里程碑 | + +--- + +*文档结束 — v0.4-draft(v0.3 已实施 + v0.4 落实 4 项收尾:handleDuplicateProvider 兼容老 alias / UsageFooter cache 加 agent / dev 模式跨端断言 / env 兜底读 key)。配套人工回归清单见 [VERIFICATION-CHECKLIST.md](./VERIFICATION-CHECKLIST.md)。* diff --git a/docs/VERIFICATION-CHECKLIST.md b/docs/VERIFICATION-CHECKLIST.md new file mode 100644 index 0000000..0a87739 --- /dev/null +++ b/docs/VERIFICATION-CHECKLIST.md @@ -0,0 +1,154 @@ +# v0.6.0 手动验证 Checklist + +> 本文档是 [PROPOSAL-presets-and-usage.md](./PROPOSAL-presets-and-usage.md) P0 实施后的回归清单。 +> 覆盖 v0.6.0 新增的「预设供应商 + 供应商账单查询」功能,附带核心旧功能的回归项。 +> **不替代自动化测试**(项目尚无单测体系),仅作为每次发版前的人工冒烟与回归依据。 + +## 0. 测试环境准备 + +- [ ] 干净 config 目录(备份并删除 `%USERPROFILE%/.kimi-code/config.toml` 与 `%USERPROFILE%/.kimi-code/kimi-switch.db`) +- [ ] 至少 2 个真实 API Key:DeepSeek(余额)、Kimi For Coding(套餐,登录 kimi.com/code 拿 `sk-...` key);可选 OpenRouter(余额) +- [ ] 至少 1 个 OpenAI/Anthropic 兼容 key 作为非账单 provider 验证「不支持时不显示 footer」 + +--- + +## A. 预设供应商 + +### A1. 弹窗与入口 +- [ ] 主页点「+」按钮 → 弹出 `PresetPickerModal`,含 15 条预设 +- [ ] 搜索框输入「dee」/「kimi」/「glm」→ 实时过滤 +- [ ] 排序切换「原始 / A-Z」→ 顺序变化 +- [ ] 分类徽章配色:官方蓝 / 国产绿 / 第三方灰 / 聚合紫 +- [ ] 底部「+ 自定义配置」按钮 → 关闭弹窗,走原空表单流(生成 `provider-N`) +- [ ] Esc 键关闭弹窗 +- [ ] 点击遮罩关闭弹窗 +- [ ] 弹窗打开时背景滚动被锁 + +### A2. 选中预设 +- [ ] 选 kimi-coding → 卡片写入「kimi-coding」provider + `kimi-for-coding`、`kimi-k2.7-code` 模型,第一个设为 `default_model` +- [ ] provider 写入 `provider_type=kimi`,`base_url=https://api.kimi.com/coding/v1`,`api_key=null` +- [ ] usageKinds 持久化到 SQLite(验证方式:保存后重启 app,Kimi Coding 卡片底部仍出现账单区) +- [ ] 选 anthropic → `base_url` 为空(不写默认值) +- [ ] 选 google-genai → `provider_type=google-genai`,`base_url=https://generativelanguage.googleapis.com` +- [ ] 选 moonshot → `provider_type=kimi`,`base_url=https://api.moonshot.ai/v1`(Kimi 平台 API,按量付费) +- [ ] 选 zai → `provider_type=openai`,`base_url=https://api.z.ai/api/paas/v4` +- [ ] 选 volcengine → `provider_type=openai`,`base_url=https://ark.cn-beijing.volces.com/api/v3`,**无** usageKinds +- [ ] 选 bailian → 无 usageKinds(不显示账单区) + +### A3. 冲突处理 +- [ ] 已存在 `deepseek` provider → 选 deepseek preset 再次 → 新 provider 命名为 `deepseek-2`,模型 alias 全部带 `-2` +- [ ] 同 provider 内 alias 冲突 → 尾追加 `-2`/`-3` +- [ ] 切换不同预设(含同名模型 id)→ alias 强制 `${name}/${modelId}` 形式(**不**允许裸 alias) + +### A4. 预填字段展示 +- [ ] 选完预设后自动跳到 `ProviderEdit` 页 +- [ ] `api_key` 留空、其它字段已预填 +- [ ] 填入真实 key → Ctrl+S 保存 +- [ ] 返回列表 → 新 provider 卡片可见,可点「使用中」/「切换使用」按钮激活 + +### A5. config.toml 污染检查 +- [ ] 保存后打开 `%USERPROFILE%/.kimi-code/config.toml` → **不**含 `usageKinds` 字段 +- [ ] 修改 provider 的 `base_url` 触发保存 → config.toml 仍是新 `base_url`,**无** `usage_kinds` 键 + +### A6. 旧数据迁移 +- [ ] 用 v0.5.2 之前的 config(含裸 alias 如 `kimi-k3`、`glm-5-2-1`)启动 → app 正常加载 +- [ ] 触发「复制 provider」→ 新 provider 的 alias 重命名正确(**不**再出现 `kimi-coding-k3` 这种缺 `/` 的形式) + +--- + +## B. 供应商账单查询 + +### B1. 不支持时不渲染 +- [ ] Moonshot / Bailian / OpenAI / Google / Anthropic 卡片底部**无** UsageFooter +- [ ] 自定义空表单(`provider-N`)卡片底部**无** UsageFooter +- [ ] 后端 `query_provider_usage` 不会为这些 provider 触发 + +### B2. 支持时自动查询 +- [ ] 选 kimi-coding 预设、填 key、保存 → 卡片底部出现「⚡ N% 已使用 · 5h 后重置」 +- [ ] 选 deepseek 预设、填 key、保存 → 卡片底部出现「💰 余额 ¥X.XX」 +- [ ] 首次进入 ProviderList → 卡片并发查询,最多 3 个同时(DevTools network 可见) +- [ ] 单个 provider 查询超过 8 秒 → 卡片显示「网络异常 · 重试」,**不**阻塞其他 provider + +### B3. 5min 缓存 +- [ ] 查询成功后 5 分钟内重进 ProviderList → 不再发新请求(DevTools network 确认) +- [ ] 缓存过期后重进 → 自动重新查询(先 ghost 旧数据再后台拉新) +- [ ] 进程重启后 → 缓存清空(Rust 端内存 + 前端 module Map 都丢),首次进列表重新查 + +### B4. 手动刷新 +- [ ] 点卡片底部「刷新」图标 → 跳过缓存直接查 +- [ ] 刷新按钮 click 不会触发卡片「进入编辑」onClick(stopPropagation 验证) +- [ ] 刷新中按钮变转圈、禁用防重入 + +### B5. 错误处理 +- [ ] api_key 为空 → 卡片显示「API Key 无效」红字 + 重试按钮 +- [ ] api_key 错误(401)→ 卡片显示「API Key 无效」 +- [ ] 网络断开 / DNS 失败 → 卡片显示「网络异常」 + 重试按钮 +- [ ] 提供商返回非预期 JSON → 卡片显示「查询失败 · <错误文本>」 +- [ ] 错误状态保留旧数据作 ghost(半透明)+ 缓存仍写入错误 entry,TTL 内不重发 + +### B6. 多 kind 渲染 +- [ ] Kimi For Coding 有两层(`five_hour` + `weekly_limit`)→ 两行独立显示,前缀 `five_hour · ` / `weekly_limit · ` +- [ ] 套餐行配色:< 70% 已用 绿 / 70-89 橙 / ≥ 90 红 +- [ ] 倒计时:> 24h `Nd` / < 24h `Nh` / < 1h `Nm`;已过期显示「已重置」 +- [ ] 余额行:CNY→¥ / USD→$ / 其他原样 +- [ ] 同一 provider 同时有套餐 + 余额(v1 不存在这种组合,但 detect 启发式已支持)→ 两行都显示 + +### B7. SQLite 持久化 +- [ ] 保存预设后查 SQLite `settings` 表 → 存在 `usage_kinds:kimi-coding` 键,值为 `["plan:kimi_coding"]` +- [ ] 删除 provider → 下次 save 时对应 key 被删(避免脏数据) +- [ ] 卸载 app → 卸载时同步删除(验证方式:卸载后 `%USERPROFILE%/.kimi-code/` 不残留 `kimi-switch.db`) + +### B8. 真实 API 验证(**需真实 key**,按用户实有 key 勾选) +- [ ] DeepSeek `user/balance` → 返回 CNY 余额 +- [ ] Kimi For Coding `coding/v1/usages` → 返回 `five_hour` + `weekly_limit` 两层 +- [ ] OpenRouter `credits` → 返回 USD 余额 +- [ ] SiliconFlow `user/info` → 返回余额 +- [ ] StepFun `accounts` → 返回余额(已加在 P0 实施中,但任务里程碑归 P1) +- [ ] Novita `user/balance` → 注意 `/10000` 单位换算 +- [ ] Zhipu `glm/bigmodel` 套餐 → 返回百分比 + 重置时间 +- [ ] MiniMax 套餐 → 返回周桶用量 + +### B9. 跨 agent 隔离 +- [ ] KimiCode 与 Pi(虽然 UI 屏蔽)同名 provider 各自独立缓存不串 + +--- + +## C. 核心旧功能回归(v0.6.0 没动但要确认没破) + +- [ ] 切换使用按钮:点「使用中」→ 卡片高亮 + config.toml 写入 + `/reload` 提示 +- [ ] 复制 provider:alias 重命名正确(包括老数据非规范 alias) +- [ ] 删除 provider:关联模型一并删除 +- [ ] 编辑 provider:改名后关联模型 `m.provider` 同步更新 +- [ ] 测速按钮:弹气泡,6 秒后自动消失 +- [ ] 拉取模型:填入 base_url + key → 拉到的模型自动建 alias,max_context 走 models.dev +- [ ] 设置面板:主题切换、语言切换、检查更新、打开配置目录 +- [ ] 检查更新:启动时静默 + 8 小时一次 +- [ ] 用量仪表盘:模型用量趋势 / 供应商模型用量趋势 双 Tab、热力图、会话管理 +- [ ] 关闭按钮(X)隐藏到托盘 +- [ ] Ctrl+S / Ctrl+R / Ctrl+O 快捷键 + +--- + +## D. 构建与发布 + +- [ ] `npx tsc --noEmit` 0 error +- [ ] `cd src-tauri && cargo check` 0 error +- [ ] `cd src-tauri && cargo test` 全过(当前 26 用例) +- [ ] `npm run tauri-build` MSI 产物 `src-tauri/target/release/bundle/msi/Kimi Switch__x64_en-US.msi` +- [ ] MSI 静默安装后启动正常、config 迁移正常 +- [ ] 启动空白超时 5 秒有橙色提示 + +--- + +## E. 已知不验证项(设计层面注定做不到) + +- ⚪ Volcengine 套餐查询(v1 不支持,需独立 AK/SK,留 P2) +- ⚪ ZenMux(v1 不做) +- ⚪ `ProviderEdit` 顶部「切换预设」下拉(P2) +- ⚪ 仪表盘 KPI「供应商侧配额」卡(P3) +- ⚪ `/reload` 不会自动套用新 `default_model`(Kimi Code 上游限制,详见 README「已知限制」段) +- ⚪ 切换预设后自动激活新 provider(产品决策未定) + +--- + +*完成所有 ✓ 后再发版。任意一条 ✗ 都需定位并修复后重测。* diff --git a/docs/screenshots/dashboard.png b/docs/screenshots/dashboard.png new file mode 100644 index 0000000..958030a Binary files /dev/null and b/docs/screenshots/dashboard.png differ diff --git a/docs/screenshots/provider-basic-info.png b/docs/screenshots/provider-basic-info.png new file mode 100644 index 0000000..558c0bb Binary files /dev/null and b/docs/screenshots/provider-basic-info.png differ diff --git a/docs/screenshots/provider-model-mapping.png b/docs/screenshots/provider-model-mapping.png new file mode 100644 index 0000000..4013bea Binary files /dev/null and b/docs/screenshots/provider-model-mapping.png differ diff --git a/docs/screenshots/providers.png b/docs/screenshots/providers.png new file mode 100644 index 0000000..8db158e Binary files /dev/null and b/docs/screenshots/providers.png differ diff --git a/docs/screenshots/sessions.png b/docs/screenshots/sessions.png new file mode 100644 index 0000000..93aa93c Binary files /dev/null and b/docs/screenshots/sessions.png differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..f04854c --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + Kimi Switch + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..97c95cf --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2993 @@ +{ + "name": "kimiswitch", + "version": "0.4.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kimiswitch", + "version": "0.4.1", + "dependencies": { + "@tauri-apps/api": "^2.0.0", + "@tauri-apps/plugin-opener": "^2.5.0", + "@tauri-apps/plugin-shell": "^2.0.0", + "lucide-react": "^1.27.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.0.0", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.41", + "tailwindcss": "^3.4.10", + "typescript": "^5.5.4", + "vite": "^5.4.2" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tauri-apps/api": { + "version": "2.11.1", + "resolved": "https://registry.npmmirror.com/@tauri-apps/api/-/api-2.11.1.tgz", + "integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.11.4", + "resolved": "https://registry.npmmirror.com/@tauri-apps/cli/-/cli-2.11.4.tgz", + "integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.11.4", + "@tauri-apps/cli-darwin-x64": "2.11.4", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", + "@tauri-apps/cli-linux-arm64-musl": "2.11.4", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-musl": "2.11.4", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", + "@tauri-apps/cli-win32-x64-msvc": "2.11.4" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.11.4", + "resolved": "https://registry.npmmirror.com/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz", + "integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.11.4", + "resolved": "https://registry.npmmirror.com/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz", + "integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.11.4", + "resolved": "https://registry.npmmirror.com/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz", + "integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmmirror.com/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz", + "integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmmirror.com/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz", + "integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmmirror.com/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz", + "integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmmirror.com/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz", + "integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmmirror.com/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz", + "integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmmirror.com/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz", + "integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmmirror.com/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz", + "integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmmirror.com/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz", + "integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/plugin-opener": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.0.tgz", + "integrity": "sha512-B0LShOYae4CZjN8leiNDbnfjSrTwoZakqKaWpfoH6nXiJwt6Rgj6RnVIffG3DoJiKsffRhMkjmBV9VeilSb4TA==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@tauri-apps/plugin-shell": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz", + "integrity": "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.10.1" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmmirror.com/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.2", + "resolved": "https://registry.npmmirror.com/autoprefixer/-/autoprefixer-10.5.2.tgz", + "integrity": "sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.4", + "caniuse-lite": "^1.0.30001799", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.42", + "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", + "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.4", + "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001802", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001802.tgz", + "integrity": "sha512-vmv8ub2xwTNmljSKf82mtCk5JH7hC+YgzLj3P5zotvA0tPQ9016tdNNOG8WRca1IxOnhSsivB+J0z5FeE5LOUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.387", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz", + "integrity": "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmmirror.com/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.27.0.tgz", + "integrity": "sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.50", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmmirror.com/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmmirror.com/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmmirror.com/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmmirror.com/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmmirror.com/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmmirror.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..49ad049 --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "kimiswitch", + "private": true, + "version": "0.6.0", + "type": "module", + "scripts": { + "dev": "vite", + "prebuild": "node scripts/fetch-models-dev.mjs || echo fetch-models-dev failed, using existing snapshot", + "build": "tsc && vite build", + "fetch-models-dev": "node scripts/fetch-models-dev.mjs", + "preview": "vite preview", + "tauri": "tauri", + "tauri-dev": "tauri dev", + "tauri-build": "tauri build" + }, + "dependencies": { + "@tauri-apps/api": "^2.0.0", + "@tauri-apps/plugin-opener": "^2.5.0", + "@tauri-apps/plugin-shell": "^2.0.0", + "lucide-react": "^1.27.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.0.0", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.41", + "tailwindcss": "^3.4.10", + "typescript": "^5.5.4", + "vite": "^5.4.2" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/public/kimi.svg b/public/kimi.svg new file mode 100644 index 0000000..19f6884 --- /dev/null +++ b/public/kimi.svg @@ -0,0 +1,13 @@ + + + + + + + + + K + + + \ No newline at end of file diff --git a/scripts/fetch-models-dev.mjs b/scripts/fetch-models-dev.mjs new file mode 100644 index 0000000..2e41f4a --- /dev/null +++ b/scripts/fetch-models-dev.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +/** + * Fetch the latest model reference data from models.dev and write a compact + * snapshot to src/lib/models-dev.json. + * + * Run manually (`npm run fetch-models-dev`) or automatically before builds + * (`prebuild`). Requires network access to models.dev. + */ + +import { writeFile, mkdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const SOURCE_URL = "https://models.dev/models.json"; +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const OUTPUT = join(ROOT, "src", "lib", "models-dev.json"); + +const res = await fetch(SOURCE_URL, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(30_000), +}); +if (!res.ok) { + throw new Error(`GET ${SOURCE_URL} returned HTTP ${res.status}`); +} + +/** @type {Record} */ +const raw = await res.json(); + +// Keep only the fields the app needs; the raw file carries benchmarks, +// descriptions, etc. that would bloat the bundle. +const snapshot = {}; +for (const [key, m] of Object.entries(raw)) { + if (!m || typeof m !== "object") continue; + const entry = {}; + if (typeof m.name === "string") entry.name = m.name; + // context 0 means "not applicable" (image/audio models) — treat as missing + // so callers fall back to regex defaults instead of storing 0. + if (typeof m.limit?.context === "number" && m.limit.context > 0) { + entry.context = m.limit.context; + } + if (m.reasoning === true) entry.reasoning = true; + if (m.tool_call === true) entry.tool_call = true; + if (m.structured_output === true) entry.structured_output = true; + const input = m.modalities?.input; + if (Array.isArray(input)) { + if (input.includes("image")) entry.image = true; + if (input.includes("video")) entry.video = true; + } + snapshot[key] = entry; +} + +await mkdir(dirname(OUTPUT), { recursive: true }); +await writeFile(OUTPUT, JSON.stringify(snapshot, null, 2) + "\n", "utf8"); + +console.log( + `models-dev snapshot: ${Object.keys(snapshot).length} models -> ${OUTPUT}`, +); diff --git a/scripts/generate-icons.py b/scripts/generate-icons.py new file mode 100644 index 0000000..80e530e --- /dev/null +++ b/scripts/generate-icons.py @@ -0,0 +1,110 @@ +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", "kimi.svg") + +os.makedirs(OUT_DIR, exist_ok=True) +os.makedirs(os.path.dirname(SVG_PATH), exist_ok=True) + +# Brand colors: blue -> purple diagonal gradient +C_TOP = (59, 130, 246) # #3b82f6 +C_BOT = (139, 92, 246) # #8b5cf6 +DOT = (255, 255, 255, 230) + + +def lerp(a, b, t): + return int(a + (b - a) * t) + + +def rounded_mask(size, radius): + m = Image.new("L", (size, size), 0) + d = ImageDraw.Draw(m) + d.rounded_rectangle((0, 0, size - 1, size - 1), radius=radius, fill=255) + return m + + +def draw_icon(size): + # Render at 4x then downscale for smooth edges + S = size * 4 + img = Image.new("RGBA", (S, S), (0, 0, 0, 0)) + + # Diagonal gradient background + grad = Image.new("RGBA", (S, S)) + gd = ImageDraw.Draw(grad) + for y in range(S): + for x in range(0, S, 4): # step to speed up; visually identical after downscale + t = (x + y) / (2 * S) + gd.line([(x, y), (min(x + 3, S - 1), y)], + fill=(lerp(C_TOP[0], C_BOT[0], t), + lerp(C_TOP[1], C_BOT[1], t), + lerp(C_TOP[2], C_BOT[2], t), 255)) + img.paste(grad, (0, 0), rounded_mask(S, int(S * 0.22))) + + draw = ImageDraw.Draw(img) + + # Bold "K" + font_size = int(S * 0.52) + font = None + for name in ("segoeuib.ttf", "arialbd.ttf", "DejaVuSans-Bold.ttf"): + try: + font = ImageFont.truetype(name, font_size) + break + except Exception: + continue + if font is None: + font = ImageFont.load_default() + + bbox = draw.textbbox((0, 0), "K", font=font) + tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1] + # Optical center: shift slightly left/down to balance the toggle dot + x = (S - tw) // 2 - bbox[0] - int(S * 0.02) + y = (S - th) // 2 - bbox[1] + int(S * 0.02) + draw.text((x, y), "K", font=font, fill=(255, 255, 255, 255)) + + # Toggle dot (top-right) — the "switch" cue + cx, cy = int(S * 0.74), int(S * 0.26) + r = int(S * 0.075) + ring = int(S * 0.018) + draw.ellipse((cx - r - ring, cy - r - ring, cx + r + ring, cy + r + ring), + fill=(255, 255, 255, 90)) + draw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=DOT) + + return img.resize((size, size), Image.LANCZOS) + + +# --- Tauri bundle icons --- +for s in (32, 128, 256): + img = draw_icon(s) + if s == 32: + img.save(os.path.join(OUT_DIR, "32x32.png")) + elif s == 128: + img.save(os.path.join(OUT_DIR, "128x128.png")) + draw_icon(256).save(os.path.join(OUT_DIR, "128x128@2x.png")) + else: + img.save(os.path.join(OUT_DIR, "icon.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) + +# --- Web favicon / app SVG --- +svg = ''' + + + + + + + + K + + +''' +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 new file mode 100644 index 0000000..e8d0152 --- /dev/null +++ b/src-tauri/Cargo.lock @@ -0,0 +1,5530 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.2.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.0", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.0", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.118", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.118", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.2+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "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]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "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 = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +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]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.0", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.0", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "kimiswitch" +version = "0.6.0" +dependencies = [ + "anyhow", + "chrono", + "dirs 5.0.1", + "fs_extra", + "futures-util", + "indexmap 2.14.0", + "regex", + "reqwest 0.12.28", + "rusqlite", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-opener", + "tauri-plugin-single-instance", + "toml 0.8.23", + "walkdir", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.0", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd8d3b65c44123a56e0133d2cd06ce4361bd3ca99d41198b2f25e3c3db9b8b4a" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.0", + "crc32fast", + "fdeflate", + "flate2", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8366a6159044a37876a2b9817124296703c586a5c92e2c53751fa06d8d43e8" +dependencies = [ + "toml_edit 0.20.7", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.12+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +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", + "futures-util", + "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", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "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 = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.13.0", + "chrono", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.118", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.0", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.23", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.13.0", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs 6.0.0", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest 0.13.4", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs 6.0.0", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.118", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "url", + "windows", + "zbus", +] + +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3214becf9ef5783c0ae99a3bb25adf5353a7a16ebf53e74b909e29205735c6c" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.18", + "tokio", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.3", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.11", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.11", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.0", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" +dependencies = [ + "crossbeam-channel", + "dirs 6.0.0", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.118", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +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_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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs 6.0.0", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "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 = "zerocopy" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75726053136156d419e285b9b7eddaaea9e3fea6ce32eed44a89901f0bd98de1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4714fd92cf900833d49538023a9b3915155210801d1c1169eba513b2addefd71" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +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 new file mode 100644 index 0000000..a2ce5a9 --- /dev/null +++ b/src-tauri/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "kimiswitch" +version = "0.6.0" +description = "Kimi Switch - model config manager" +authors = ["you"] +edition = "2021" + +[build-dependencies] +tauri-build = { version = "2.0.0", features = [] } + +[dependencies] +tauri = { version = "2.0.0", features = ["tray-icon"] } +tauri-plugin-opener = "2.0.0" +tauri-plugin-single-instance = "2.0.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +indexmap = { version = "2.2", features = ["serde"] } +chrono = { version = "0.4", features = ["serde"] } +anyhow = "1.0" +regex = "1" +walkdir = "2" +fs_extra = "1" +reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"], default-features = false } +futures-util = "0.3" +dirs = "5.0" +toml = "0.8" +rusqlite = { version = "0.32", features = ["bundled", "chrono"] } + +[lib] +name = "kimiswitch_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[features] +default = ["custom-protocol"] +custom-protocol = ["tauri/custom-protocol"] 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..7519dc8 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..b6d95f1 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..7b05c84 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..d0b55ea 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..b6d95f1 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 new file mode 100644 index 0000000..d41b85e --- /dev/null +++ b/src-tauri/src/commands.rs @@ -0,0 +1,859 @@ +use crate::db; +use crate::models::{Agent, Config, DiscoveredModel, Model, Provider, ProviderType}; +use crate::pi_io; +use crate::services::{self, UsageKind, UsageResult}; +use indexmap::IndexMap; +use serde::Serialize; +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; +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 debug_log(message: String) { + eprintln!("[frontend] {}", message); +} + +/// SQLite settings key for a provider's billing/usage query kinds +/// (JSON array of kind strings, e.g. `["balance:deepseek"]`). +fn usage_kinds_key(provider_name: &str) -> String { + format!("usage_kinds:{provider_name}") +} + +/// Merge per-provider `usage_kinds` into a loaded config: explicit SQLite +/// settings first, host-based detection as fallback so existing installs get +/// billing support automatically. The field never enters config.toml. +fn merge_usage_kinds(config: &mut Config) { + for p in config.providers.values_mut() { + let from_settings = db::get_setting_pub(&usage_kinds_key(&p.name)) + .ok() + .flatten() + .and_then(|s| serde_json::from_str::>(&s).ok()) + .filter(|v| !v.is_empty()); + p.usage_kinds = from_settings.or_else(|| { + let kinds = services::detect_provider(&resolve_base_url(p)); + if kinds.is_empty() { + None + } else { + Some(kinds.iter().map(|k| k.as_str().to_string()).collect()) + } + }); + } +} + +fn load_pi_native_config() -> Result { + let file = pi_io::load_pi_models().map_err(fmt_anyhow)?; + let mut config = pi_io::pi_file_to_config(&file); + if config.default_model.is_none() { + if let Ok(settings) = pi_io::load_pi_settings() { + if let (Some(provider), Some(model_id)) = + (settings.default_provider, settings.default_model) + { + if let Some(alias) = config + .models + .values() + .find(|m| m.provider == provider && m.model == model_id) + { + config.default_model = Some(alias.alias.clone()); + } + } + } + } + Ok(config) +} + +#[tauri::command] +pub fn load_agent_config_command(agent: Agent) -> Result { + // Load Kimi Switch's own SQLite database (metadata + migration fallback). + let db_config = db::load_config(&agent).ok(); + + let mut config = match agent { + Agent::KimiCode => { + // config.toml is the authoritative source for provider/model data + // because the user can add or edit providers at any time via the + // CLI's /provider command. SQLite only enriches with Kimi + // Switch-private metadata (note, official_url, remembered default + // model) and fills gaps when config.toml is incomplete. + let mut config = crate::kimi_code_io::load_kimi_code_config_as_config() + .map_err(fmt_anyhow)?; + + if let Some(db) = &db_config { + // Enrich config.toml providers with SQLite metadata. + for (name, p) in config.providers.iter_mut() { + if let Some(db_p) = db.providers.get(name) { + p.note = db_p.note.clone(); + p.official_url = db_p.official_url.clone(); + // Restore Kimi Switch metadata that does not live in + // the agent's config.toml. + p.icon = db_p.icon.clone(); + p.icon_color = db_p.icon_color.clone(); + // Restore the remembered per-provider default model + // (Kimi-Switch-private, stored in raw_other). + if let Some(dm) = db_p.raw_other.get("default_model") { + match &mut p.raw_other { + serde_json::Value::Object(obj) => { + obj.insert("default_model".to_string(), dm.clone()); + } + _ => { + let mut obj = serde_json::Map::new(); + obj.insert("default_model".to_string(), dm.clone()); + p.raw_other = serde_json::Value::Object(obj); + } + } + } + } + } + // Migration safety: include providers/models that exist in + // SQLite but not in config.toml (e.g. after upgrading from the + // old single-provider-write behaviour). + for (name, p) in &db.providers { + config.providers.entry(name.clone()).or_insert_with(|| p.clone()); + } + for (alias, m) in &db.models { + config.models.entry(alias.clone()).or_insert_with(|| m.clone()); + } + } + + config + } + Agent::Pi => { + // Pi: SQLite first, fall back to native config on first use. + match db_config { + Some(config) if !config.providers.is_empty() => config, + _ => load_pi_native_config()?, + } + } + }; + + merge_usage_kinds(&mut config); + Ok(config) +} + +#[tauri::command] +pub fn save_agent_config_command(agent: Agent, config: Config) -> Result<(), String> { + // Save the full Kimi Switch configuration to local SQLite. + db::save_config(&agent, &config).map_err(fmt_anyhow)?; + // For Kimi Code, config.toml is the authoritative provider store, so + // persist changes there immediately — not only on activation. This + // ensures edits (Ctrl+S) survive a restart even without switching. + if matches!(agent, Agent::KimiCode) { + crate::kimi_code_io::save_config_as_kimi_code(&config).map_err(fmt_anyhow)?; + } + // Persist usage_kinds to the SQLite settings table (never config.toml; + // the field is skip_serializing and the TOML export is hand-built). + // None / empty array → delete the key. + for provider in config.providers.values() { + let key = usage_kinds_key(&provider.name); + match &provider.usage_kinds { + Some(kinds) if !kinds.is_empty() => { + let json = serde_json::to_string(kinds).map_err(|e| e.to_string())?; + db::set_setting_pub(&key, &json).map_err(fmt_anyhow)?; + } + _ => db::delete_setting_pub(&key).map_err(fmt_anyhow)?, + } + } + Ok(()) +} + +#[tauri::command] +pub fn activate_agent_config_command(agent: Agent) -> Result<(), String> { + match agent { + Agent::KimiCode => { + // No-op: save_agent_config_command already writes config.toml for + // Kimi Code. Avoiding a second write here prevents a redundant disk + // write + backup on every switch. + Ok(()) + } + Agent::Pi => { + // Load the full config from SQLite and write only the active + // provider to Pi's native config files. + let config = db::load_config(&agent).map_err(fmt_anyhow)?; + let active_config = build_active_config(&config); + let file = pi_io::config_to_pi_file(&active_config); + pi_io::save_pi_models(&file).map_err(fmt_anyhow)?; + + // Keep Pi's own default provider / model in sync so the switch is + // actually picked up on the next `pi` run. + let mut settings = pi_io::load_pi_settings().map_err(fmt_anyhow)?; + if let Some((provider_name, model_id)) = active_provider_and_model(&active_config) { + settings.default_provider = Some(provider_name); + settings.default_model = Some(model_id); + } + pi_io::save_pi_settings(&settings).map_err(fmt_anyhow) + } + } +} + +fn active_provider_and_model(config: &Config) -> Option<(String, String)> { + let alias = config.default_model.as_ref()?; + let model = config.models.get(alias)?; + Some((model.provider.clone(), model.model.clone())) +} + +fn build_active_config(config: &Config) -> Config { + // Used only by Pi: writes only the provider explicitly marked as active + // to Pi's native config so Pi follows Kimi Switch's selection instead of + // falling back to another provider. Kimi Code does not use this — it + // writes all providers and selects via default_model. + let providers: IndexMap = config + .providers + .iter() + .filter(|(_, p)| p.active) + .map(|(k, p)| (k.clone(), p.clone())) + .collect(); + + let active_provider_names: std::collections::HashSet<&str> = providers + .values() + .map(|p| p.name.as_str()) + .collect(); + + let models: IndexMap = config + .models + .iter() + .filter(|(_, m)| active_provider_names.contains(m.provider.as_str())) + .map(|(k, m)| (k.clone(), m.clone())) + .collect(); + + Config { + default_model: config.default_model.clone(), + providers, + models, + raw_other: config.raw_other.clone(), + } +} + +#[tauri::command] +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()), + } +} + +/// Test reachability of a provider's base URL (cc-switch semantics): +/// any HTTP response counts as reachable; only network-layer errors fail. +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ConnectivityResult { + pub ok: bool, + pub latency_ms: u64, + pub status_code: Option, + pub error: Option, +} + +#[tauri::command] +pub async fn test_connectivity(provider: Provider) -> Result { + let base = resolve_base_url(&provider); + if base.trim().is_empty() { + return Ok(ConnectivityResult { + ok: false, + latency_ms: 0, + status_code: None, + error: Some("no base URL configured".to_string()), + }); + } + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(8)) + .build() + .map_err(|e| format!("failed to build HTTP client: {e}"))?; + let start = std::time::Instant::now(); + match client.get(base.as_str()).send().await { + Ok(resp) => Ok(ConnectivityResult { + ok: true, + latency_ms: start.elapsed().as_millis() as u64, + status_code: Some(resp.status().as_u16()), + error: None, + }), + Err(e) => Ok(ConnectivityResult { + ok: false, + latency_ms: start.elapsed().as_millis() as u64, + status_code: None, + error: Some(if e.is_connect() { + "connection refused / DNS failed".to_string() + } else if e.is_timeout() { + "request timed out".to_string() + } else { + e.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 (with pagination) ───────────── + +async fn fetch_openai_models(base: &str, api_key: &str) -> Result, String> { + #[derive(serde::Deserialize)] + struct OaiModel { + id: String, + } + #[derive(serde::Deserialize)] + struct OaiList { + data: Vec, + #[serde(default)] + has_more: Option, + #[serde(default)] + last_id: Option, + #[serde(default)] + next_page_token: Option, + } + + let client = reqwest::Client::new(); + let root = base.trim_end_matches('/').to_string(); + let mut url = format!("{}/models", root); + let mut all: Vec = Vec::new(); + const MAX_PAGES: usize = 50; + + for _ in 0..MAX_PAGES { + let resp = client + .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())); + } + let body: OaiList = resp + .json() + .await + .map_err(|e| format!("failed to parse response from {}: {e}", url))?; + all.extend(body.data); + + // OpenAI cursor pagination: has_more + last_id → ?after= + if body.has_more.unwrap_or(false) { + if let Some(last_id) = body.last_id.clone() { + url = format!("{}/models?after={}", root, last_id); + continue; + } + } + // Token-based pagination: next_page_token → ?page_token= + if let Some(token) = body.next_page_token.clone() { + if !token.is_empty() { + url = format!("{}/models?page_token={}", root, token); + continue; + } + } + break; + } + + let mut seen = std::collections::HashSet::new(); + Ok(all + .into_iter() + .filter(|m| seen.insert(m.id.clone())) + .map(|m| DiscoveredModel { + id: m.id, + display_name: None, + max_context_size: None, + }) + .collect()) +} + +// ── Anthropic /v1/models endpoint (with pagination) ────────────────── + +async fn fetch_anthropic_models(base: &str, api_key: &str) -> Result, String> { + #[derive(serde::Deserialize)] + struct AntModel { + id: String, + display_name: Option, + } + #[derive(serde::Deserialize)] + struct AntList { + data: Vec, + #[serde(default)] + has_more: Option, + #[serde(default)] + last_id: Option, + } + + let client = reqwest::Client::new(); + let root = base.trim_end_matches('/').to_string(); + let mut url = format!("{}/v1/models?limit=1000", root); + let mut all: Vec = Vec::new(); + const MAX_PAGES: usize = 20; + + for _ in 0..MAX_PAGES { + let resp = client + .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())); + } + let body: AntList = resp + .json() + .await + .map_err(|e| format!("failed to parse response from {}: {e}", url))?; + let more = body.has_more.unwrap_or(false); + let cursor = body.last_id.clone(); + all.extend(body.data); + if !(more && cursor.is_some()) { + break; + } + url = format!("{}/v1/models?limit=1000&after_id={}", root, cursor.unwrap()); + } + + let mut seen = std::collections::HashSet::new(); + Ok(all + .into_iter() + .filter(|m| seen.insert(m.id.clone())) + .map(|m| DiscoveredModel { + id: m.id, + display_name: m.display_name, + max_context_size: None, + }) + .collect()) +} + +// ── Google GenAI /v1beta/models endpoint (with pagination) ─────────── + +async fn fetch_google_genai_models( + base: &str, + api_key: &str, +) -> Result, String> { + #[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, + #[serde(rename = "nextPageToken", default)] + next_page_token: Option, + } + + let client = reqwest::Client::new(); + let root = base.trim_end_matches('/').to_string(); + let mut url = format!("{}/v1beta/models?key={}&pageSize=1000", root, api_key); + let mut all: Vec = Vec::new(); + const MAX_PAGES: usize = 50; + + for _ in 0..MAX_PAGES { + let resp = client + .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())); + } + let body: GglList = resp + .json() + .await + .map_err(|e| format!("failed to parse response from {}: {e}", url))?; + let cursor = body.next_page_token.clone(); + all.extend(body.models); + match cursor { + Some(t) if !t.is_empty() => { + url = format!( + "{}/v1beta/models?key={}&pageSize=1000&pageToken={}", + root, api_key, t + ); + } + _ => break, + } + } + + let mut seen = std::collections::HashSet::new(); + Ok(all + .into_iter() + .filter(|m| seen.insert(m.name.clone())) + .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()) +} + +// --------------------------------------------------------------------------- +// App settings (generic key/value via SQLite) +// --------------------------------------------------------------------------- + +#[tauri::command] +pub fn get_app_setting(key: String) -> Option { + db::get_setting_pub(&key).ok().flatten() +} + +#[tauri::command] +pub fn set_app_setting(key: String, value: String) -> Result<(), String> { + db::set_setting_pub(&key, &value).map_err(|e| e.to_string()) +} + +// --------------------------------------------------------------------------- +// Provider billing / usage query (cc-switch semantics) +// --------------------------------------------------------------------------- + +/// 5-minute in-memory cache keyed by (agent, provider_name). +/// Only successful results are cached; failures are always re-queryable. +const USAGE_CACHE_TTL: Duration = Duration::from_secs(300); + +type UsageCache = Mutex>; + +fn usage_cache() -> &'static UsageCache { + static CACHE: OnceLock = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Query a provider's balance / plan quota. The frontend passes only the +/// provider name — base_url, api_key and usage kinds are all resolved here, +/// so the API key never crosses IPC and the host routing cannot be spoofed. +/// +/// Error channel semantics (cc-switch): +/// - `Err(_)` = transient failure (network/timeout/body read) → frontend +/// retries and keeps the last good value. +/// - `Ok(success:false)` = deterministic failure (no key / auth / non-2xx / +/// bad JSON / unsupported provider) → show the error text directly. +#[tauri::command] +pub async fn query_provider_usage( + agent: Agent, + provider_name: String, + force_refresh: Option, +) -> Result { + let cache_key = (agent.as_str().to_string(), provider_name.clone()); + + if !force_refresh.unwrap_or(false) { + let cached = usage_cache() + .lock() + .unwrap() + .get(&cache_key) + .and_then(|(ts, result)| (ts.elapsed() < USAGE_CACHE_TTL).then(|| result.clone())); + if let Some(result) = cached { + return Ok(result); + } + } + + // Load via the same path as load_agent_config_command so usage_kinds + // (SQLite merge + host-detect fallback) is already resolved. + let config = load_agent_config_command(agent)?; + let Some(provider) = config.providers.get(&provider_name) else { + return Ok(UsageResult::failure(format!( + "provider '{provider_name}' not found" + ))); + }; + + // The api_key only ever goes into request headers — never into logs, + // error messages, or the cache key. + let api_key = provider + .api_key + .clone() + .filter(|s| !s.trim().is_empty()) + .or_else(|| { + provider + .env + .get(expected_api_key_key(&provider.provider_type)) + .cloned() + .filter(|s| !s.is_empty()) + }); + let Some(api_key) = api_key else { + return Ok(UsageResult::failure(if provider.managed { + "provider uses managed OAuth; usage query requires an API key".to_string() + } else { + "no API key configured".to_string() + })); + }; + + let base_url = resolve_base_url(provider); + let kinds: Vec = provider + .usage_kinds + .as_ref() + .filter(|v| !v.is_empty()) + .map(|v| { + v.iter() + .filter_map(|s| s.parse::().ok()) + .collect::>() + }) + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| services::detect_provider(&base_url)); + if kinds.is_empty() { + return Ok(UsageResult::failure( + "unsupported provider: no usage query available for this base URL".to_string(), + )); + } + + // A failing kind must not take down the others: collect successes, + // deterministic failures and transient failures separately. + let mut data: Vec = Vec::new(); + let mut errors: Vec = Vec::new(); + let mut transient: Vec = Vec::new(); + let mut any_success = false; + for kind in kinds { + match services::query_kind(kind, &base_url, &api_key).await { + Ok(result) if result.success => { + any_success = true; + if let Some(d) = result.data { + data.extend(d); + } + } + Ok(result) => { + if let Some(e) = result.error { + errors.push(format!("{}: {e}", kind.as_str())); + } + } + Err(e) => transient.push(format!("{}: {e}", kind.as_str())), + } + } + + if any_success { + let result = UsageResult { + success: true, + data: if data.is_empty() { None } else { Some(data) }, + error: if errors.is_empty() { + None + } else { + Some(errors.join("; ")) + }, + }; + usage_cache() + .lock() + .unwrap() + .insert(cache_key, (Instant::now(), result.clone())); + Ok(result) + } else if !transient.is_empty() { + // All kinds failed transiently → propagate Err so the frontend + // rejects and retries (keep-last-good). + Err(transient.join("; ")) + } else { + Ok(UsageResult::failure(errors.join("; "))) + } +} + +// --------------------------------------------------------------------------- +// Version check (lightweight: GET Gitea releases API, compare tag_name) +// --------------------------------------------------------------------------- + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct UpdateInfo { + pub current: String, + pub latest: String, + pub update_available: bool, + pub release_url: String, + pub download_url: Option, +} + +fn parse_version(s: &str) -> Vec { + s.trim_start_matches('v') + .split('.') + .filter_map(|p| p.parse::().ok()) + .collect() +} + +fn version_lt(current: &str, latest: &str) -> bool { + let c = parse_version(current); + let l = parse_version(latest); + for i in 0..c.len().max(l.len()) { + let cv = *c.get(i).unwrap_or(&0); + let lv = *l.get(i).unwrap_or(&0); + if cv < lv { + return true; + } + if cv > lv { + return false; + } + } + false +} + +#[tauri::command] +pub async fn check_for_update() -> Result { + let current = env!("CARGO_PKG_VERSION").to_string(); + let url = "https://git.codingplan.site/api/v1/repos/admin/KimiCodeSwitch/releases?limit=1"; + + let resp = reqwest::get(url) + .await + .map_err(|e| format!("request failed: {e}"))?; + + let releases: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("parse failed: {e}"))?; + + let first = releases + .as_array() + .and_then(|a| a.first()) + .ok_or("no releases found")?; + + let latest = first + .get("tag_name") + .and_then(|v| v.as_str()) + .unwrap_or("0.0.0") + .to_string(); + + let release_url = first + .get("html_url") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let download_url = first + .get("assets") + .and_then(|a| a.as_array()) + .and_then(|a| a.first()) + .and_then(|a| a.get("browser_download_url")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let update_available = version_lt(¤t, &latest); + + Ok(UpdateInfo { + current, + latest, + update_available, + release_url, + download_url, + }) +} + +// --------------------------------------------------------------------------- +// Silent download with progress events +// --------------------------------------------------------------------------- + +#[tauri::command] +pub async fn download_update( + app: tauri::AppHandle, + url: String, +) -> Result { + use futures_util::StreamExt; + use std::io::Write; + use tauri::Emitter; + + let resp = reqwest::get(&url) + .await + .map_err(|e| format!("download request failed: {e}"))?; + + let total = resp.content_length().unwrap_or(0); + + let temp_dir = std::env::temp_dir(); + let file_path = temp_dir.join("KimiSwitch_update.msi"); + + let mut file = std::fs::File::create(&file_path) + .map_err(|e| format!("create temp file failed: {e}"))?; + + let mut downloaded: u64 = 0; + let mut stream = resp.bytes_stream(); + + while let Some(chunk_result) = stream.next().await { + let chunk = chunk_result.map_err(|e| format!("read chunk failed: {e}"))?; + file.write_all(&chunk) + .map_err(|e| format!("write failed: {e}"))?; + downloaded += chunk.len() as u64; + + let progress = if total > 0 { + ((downloaded as f64 / total as f64) * 100.0).min(100.0) as u32 + } else { + 0 + }; + + let _ = app.emit( + "download-progress", + serde_json::json!({ + "downloaded": downloaded, + "total": total, + "progress": progress, + }), + ); + } + + drop(file); + + let path_str = file_path.to_string_lossy().to_string(); + + let _ = app.emit( + "download-complete", + serde_json::json!({ "path": &path_str }), + ); + + Ok(path_str) +} + +/// Open the downloaded MSI installer using the system default handler. +#[tauri::command] +pub fn open_installer(app: tauri::AppHandle, path: String) -> Result<(), String> { + use tauri_plugin_opener::OpenerExt; + app.opener() + .open_path(&path, None::<&str>) + .map_err(|e| e.to_string()) +} \ No newline at end of file diff --git a/src-tauri/src/config_io.rs b/src-tauri/src/config_io.rs new file mode 100644 index 0000000..4699c86 --- /dev/null +++ b/src-tauri/src/config_io.rs @@ -0,0 +1,84 @@ +//! Configuration file I/O utilities for KimiSwitch. +//! Reads and writes the global config file and profile files. + +use std::path::{Path, PathBuf}; +use anyhow::{Context, Result}; +use chrono::{Duration, Local}; + +/// Creates a backup of `path` inside a `backups/` subdirectory next to the +/// original file, then removes backup files older than `retention_days`. +pub fn backup_file(path: &Path, retention_days: i64) -> Result { + if !path.exists() { + return Ok(PathBuf::new()); + } + + let parent = path.parent().context("path has no parent directory")?; + let backups_dir = parent.join("backups"); + std::fs::create_dir_all(&backups_dir) + .with_context(|| format!("failed to create backups directory {}", backups_dir.display()))?; + + let file_name = path.file_name().context("path has no file name")?; + let timestamp = Local::now().format("%Y%m%d_%H%M%S_%.3f").to_string(); + let backup_name = format!("{}.bak.{}", file_name.to_string_lossy(), timestamp); + let mut backup_path = backups_dir.join(&backup_name); + + if backup_path.exists() { + for n in 1..1000 { + let candidate = backups_dir.join(format!( + "{}.bak.{}.{:03}", + file_name.to_string_lossy(), + timestamp, + n + )); + if !candidate.exists() { + backup_path = candidate; + break; + } + } + } + + std::fs::copy(path, &backup_path).with_context(|| { + format!( + "failed to back up {} to {}", + path.display(), + backup_path.display() + ) + })?; + + cleanup_old_backups(&backups_dir, retention_days)?; + + Ok(backup_path) +} + +/// Removes backup files in `dir` older than `retention_days`, based on file +/// modification time. Only files whose names contain `.bak.` are deleted. +fn cleanup_old_backups(dir: &Path, retention_days: i64) -> Result<()> { + let cutoff = Local::now() - Duration::days(retention_days); + let entries = std::fs::read_dir(dir) + .with_context(|| format!("failed to read backups directory {}", dir.display()))?; + + for entry in entries { + let entry = entry.context("failed to read directory entry")?; + let path = entry.path(); + if !path.is_file() { + continue; + } + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if !name.contains(".bak.") { + continue; + } + let metadata = entry + .metadata() + .with_context(|| format!("failed to read metadata for {}", path.display()))?; + let modified = metadata + .modified() + .with_context(|| format!("failed to read modified time for {}", path.display()))?; + let modified: chrono::DateTime = modified.into(); + if modified < cutoff { + std::fs::remove_file(&path) + .with_context(|| format!("failed to remove old backup {}", path.display()))?; + } + } + + Ok(()) +} diff --git a/src-tauri/src/dashboard.rs b/src-tauri/src/dashboard.rs new file mode 100644 index 0000000..930f67a --- /dev/null +++ b/src-tauri/src/dashboard.rs @@ -0,0 +1,1419 @@ +use chrono::{DateTime, Datelike, Local, TimeZone}; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use walkdir::WalkDir; + +// --------------------------------------------------------------------------- +// Types – match the Node API JSON shape exactly +// --------------------------------------------------------------------------- + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PathsResult { + pub current: String, + pub valid: bool, + pub candidates: Vec, + pub env: EnvInfo, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PathCandidate { + pub path: String, + pub valid: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct EnvInfo { + #[serde(rename = "KIMI_CODE_HOME")] + pub kimi_code_home: Option, + #[serde(rename = "KIMI_MODEL_NAME")] + pub kimi_model_name: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PriceRow { + pub id: String, + pub cache_hit: f64, + pub input: f64, + pub output: f64, + pub context: u64, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PricesResult { + pub prices: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SummaryResult { + pub home: String, + pub valid: bool, + pub scanned_at: u64, + pub meta: ScanMeta, + pub model_map: ModelMapInfo, + pub range: String, + pub stats: RangeStats, + pub heatmap: HeatmapData, + pub all_models: Vec, + pub all_model_count: usize, + pub range_totals: HashMap, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ScanMeta { + pub files_scanned: usize, + pub lines_seen: usize, + pub record_count: usize, + pub home: String, + pub sessions_root: String, + pub errors: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ModelMapInfo { + pub default_model: Option, + pub env_model: Option, + pub alias_count: usize, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct EnvModelInfo { + pub name: String, + pub provider: Option, + pub model: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct RangeStats { + pub range: String, + pub totals: TotalsRow, + pub daily: Vec, + pub models: Vec, + pub recent: Vec, + pub recent_total: usize, + pub recent_limit: usize, +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct TotalsRow { + pub requests: usize, + pub input_other: u64, + pub output: u64, + pub input_cache_read: u64, + pub input_cache_creation: u64, + pub cost_usd: f64, + pub total_tokens: u64, + pub cache_hit_rate: f64, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct DailyRow { + pub date: String, + pub requests: usize, + pub input_other: u64, + pub output: u64, + pub input_cache_read: u64, + pub input_cache_creation: u64, + pub cost_usd: f64, + pub total_tokens: u64, + pub cache_hit_rate: f64, + /// Per-model token breakdown for stacked-bar rendering + pub by_model: HashMap, + /// Per-provider token breakdown for stacked-bar rendering + pub by_provider: HashMap, + /// Per-provider, per-model nested token breakdown (provider → model → tokens), + /// used by the provider-tab drill-down detail. + pub by_provider_model: HashMap>, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ModelRow { + pub model: String, + pub model_display: String, + pub model_resolved: String, + pub price_id: String, + pub cost_estimated: bool, + pub requests: usize, + pub input_other: u64, + pub output: u64, + pub input_cache_read: u64, + pub input_cache_creation: u64, + pub cost_usd: f64, + pub total_tokens: u64, + pub cache_hit_rate: f64, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct RecentRow { + pub time: u64, + pub model: String, + pub model_display: String, + pub model_resolved: String, + pub input_other: u64, + pub output: u64, + pub input_cache_read: u64, + pub input_cache_creation: u64, + pub total_tokens: u64, + pub cost_usd: f64, + pub cost_estimated: bool, + pub price_id: String, + pub from_env: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct AllModelRow { + pub model: String, + pub model_display: String, + pub requests: usize, + pub total_tokens: u64, + pub cost_usd: f64, + pub cost_estimated: bool, + pub cache_hit_rate: f64, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct HeatmapData { + pub weeks: usize, + pub start: String, + pub end: String, + pub max_tokens: u64, + pub cells: Vec, + pub month_labels: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct HeatmapCell { + pub date: String, + pub dow: usize, + pub week_index: usize, + pub requests: usize, + pub total_tokens: u64, + pub cost_usd: f64, + pub cache_hit_rate: f64, + pub level: usize, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct MonthLabel { + pub week_index: usize, + pub label: String, +} + +// Sessions types +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionsResult { + pub home: String, + pub archive_root: String, + pub workspaces: Vec, + pub sessions: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceRow { + pub id: String, + pub name: String, + pub root: Option, + pub created_at: Option, + pub last_opened_at: Option, + pub active_count: usize, + pub archived_count: usize, + pub empty: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionRow { + pub id: String, + pub workspace_id: String, + pub status: String, + pub title: Option, + pub work_dir: Option, + pub created_at: Option, + pub updated_at: Option, + pub bytes: u64, + pub files: usize, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ActionResponse { + pub ok: bool, + pub workspace_id: String, + pub session_id: String, + pub status: Option, + pub path: Option, + pub deleted: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PreviewResult { + pub workspace_id: String, + pub session_id: String, + pub status: String, + pub title: Option, + pub work_dir: Option, + pub created_at: Option, + pub updated_at: Option, + pub message_count: usize, + pub truncated: bool, + pub messages: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PreviewMessage { + pub role: String, + pub time: Option, + pub text: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceDeleteBody { + pub workspace_id: String, + pub confirm: Option, + pub force: Option, +} + +// --------------------------------------------------------------------------- +// App state +// --------------------------------------------------------------------------- +pub struct AppState { + pub scan_cache: Mutex, +} + +#[derive(Clone)] +pub struct ScanCache { + pub home: String, + pub scanned_at: u64, + pub records: Vec, + pub meta: ScanMeta, + pub model_map: ModelMapInfo, +} + +#[derive(Debug, Clone)] +pub struct UsageRecord { + pub time: u64, + pub model: String, + pub input_other: u64, + pub output: u64, + pub input_cache_read: u64, + pub input_cache_creation: u64, + pub cost_usd: f64, + pub cost_estimated: bool, + pub price_id: String, + pub model_resolved: String, + pub model_display: String, + pub provider: Option, + pub from_env: bool, +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn resolve_kimi_home(override_path: Option) -> PathBuf { + if let Some(p) = override_path { + if !p.trim().is_empty() { + return PathBuf::from(p); + } + } + if let Ok(env_home) = std::env::var("KIMI_CODE_HOME") { + if !env_home.trim().is_empty() { + return PathBuf::from(env_home); + } + } + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + home.join(".kimi-code") +} + +fn is_kimi_home(dir: &Path) -> bool { + if !dir.exists() || !dir.is_dir() { + return false; + } + dir.join("config.toml").exists() || dir.join("sessions").exists() +} + +/// Build a legacy alias→provider map by merging the current config.toml with every +/// `config.toml.bak.*` snapshot (home root + backups/). Historical usage records may +/// reference model aliases that no longer exist in the current config (old bare or +/// `-N`-suffixed aliases); their provider is recovered from these snapshots. +fn build_alias_provider_map(home: &Path) -> HashMap { + let mut map: HashMap = HashMap::new(); + let mut candidates: Vec = Vec::new(); + let current = home.join("config.toml"); + if current.is_file() { + candidates.push(current); + } + for dir in [home.to_path_buf(), home.join("backups")] { + if let Ok(rd) = std::fs::read_dir(&dir) { + for e in rd.flatten() { + let name = e.file_name().to_string_lossy().to_string(); + if name.starts_with("config.toml.bak.") { + candidates.push(e.path()); + } + } + } + } + for path in candidates { + let content = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(_) => continue, + }; + let parsed: toml::Value = match toml::from_str(&content) { + Ok(v) => v, + Err(_) => continue, + }; + if let Some(models) = parsed.get("models").and_then(|m| m.as_table()) { + for (alias, m) in models { + if let Some(p) = m.get("provider").and_then(|p| p.as_str()) { + // Current config is parsed first and wins on conflict. + map.entry(alias.clone()).or_insert_with(|| p.to_string()); + } + } + } + } + map +} + +/// Resolve the provider for a usage record's raw model key: prefer the `provider/` +/// prefix, then fall back to the legacy alias→provider map (config + backups). +fn resolve_provider(model_raw: &str, map: &HashMap) -> Option { + let p = model_raw + .rsplit_once('/') + .map(|(prov, _)| prov.to_string()) + .or_else(|| map.get(model_raw).cloned()); + p.map(|x| normalize_provider(&x)) +} + +/// Normalize historical provider-name variants to the canonical name. +fn normalize_provider(p: &str) -> String { + match p { + "CodingPlanSite" => "CodingPlan.site".to_string(), + other => other.to_string(), + } +} + +fn sessions_root(home: &Path) -> PathBuf { + home.join("sessions") +} + +fn workspace_re() -> Regex { + Regex::new(r"^wd_[A-Za-z0-9._-]+$").unwrap() +} + +fn session_re() -> Regex { + Regex::new(r"^session_[0-9a-fA-F-]{8,}$").unwrap() +} + +fn safe_read_dir(path: &Path) -> Vec { + let mut entries = Vec::new(); + if let Ok(rd) = fs::read_dir(path) { + for e in rd.flatten() { + entries.push(e); + } + } + entries +} + +fn file_size_approx(path: &Path) -> (u64, usize) { + let mut total_bytes = 0u64; + let mut total_files = 0usize; + for entry in WalkDir::new(path).into_iter().filter_map(|e| e.ok()) { + if entry.file_type().is_file() { + if let Ok(meta) = entry.metadata() { + total_bytes += meta.len(); + total_files += 1; + } + } + } + (total_bytes, total_files) +} + +fn day_key(ts_ms: u64) -> String { + let secs = if ts_ms > 1e12 as u64 { ts_ms / 1000 } else { ts_ms }; + // Bucket by LOCAL calendar date (not UTC) so records around midnight stay on the correct day. + let local = Local + .timestamp_opt(secs as i64, 0) + .single() + .unwrap_or_else(|| DateTime::from_timestamp(secs as i64, 0).unwrap_or_default().with_timezone(&Local)); + format!("{:04}-{:02}-{:02}", local.year(), local.month(), local.day()) +} + +// --------------------------------------------------------------------------- +// Pricing +// --------------------------------------------------------------------------- + +fn list_prices() -> Vec { + vec![ + PriceRow { id: "kimi-k3".into(), cache_hit: 0.30, input: 3.00, output: 15.00, context: 1_048_576 }, + PriceRow { id: "kimi-k2.7-code".into(), cache_hit: 0.19, input: 0.95, output: 4.00, context: 262_144 }, + PriceRow { id: "kimi-k2.6".into(), cache_hit: 0.16, input: 0.95, output: 4.00, context: 262_144 }, + PriceRow { id: "kimi-k2.5".into(), cache_hit: 0.10, input: 0.60, output: 3.00, context: 262_144 }, + PriceRow { id: "kimi-k2-turbo".into(), cache_hit: 0.15, input: 1.15, output: 8.00, context: 262_144 }, + PriceRow { id: "kimi-k2".into(), cache_hit: 0.15, input: 0.60, output: 2.50, context: 262_144 }, + ] +} + +fn match_price(model_name: &str) -> (String, f64, f64, f64, bool) { + let bare = match model_name.rsplit_once('/') { + Some((_, b)) => b, + None => model_name, + }; + let bare_l = bare.to_ascii_lowercase(); + for p in &list_prices() { + let id_l = p.id.to_ascii_lowercase(); + if bare_l == id_l || bare_l.contains(&id_l) || id_l.contains(&bare_l) { + return (p.id.clone(), p.cache_hit, p.input, p.output, false); + } + } + ("kimi-k2.6".into(), 0.16, 0.95, 4.00, true) +} + +fn cost_for_usage(input_other: u64, output: u64, cache_read: u64, cache_create: u64, model: &str) -> (f64, bool) { + let (_price_id, cache_hit, input_price, output_price, est) = match_price(model); + let cost = (input_other as f64 / 1e6) * input_price + + (cache_read as f64 / 1e6) * cache_hit + + (cache_create as f64 / 1e6) * input_price + + (output as f64 / 1e6) * output_price; + (cost, est) +} + +// --------------------------------------------------------------------------- +// Scanner +// --------------------------------------------------------------------------- + +fn scan_usage(home: &Path) -> (Vec, ScanMeta) { + let root = sessions_root(home); + let alias2prov = build_alias_provider_map(home); + let mut records = Vec::new(); + let mut files_scanned = 0; + let mut lines_seen = 0; + let errors = Vec::new(); + + if !root.exists() { + return (records, ScanMeta { + files_scanned: 0, lines_seen: 0, record_count: 0, + home: home.to_string_lossy().to_string(), + sessions_root: root.to_string_lossy().to_string(), + errors: vec!["sessions directory not found".into()], + }); + } + + for entry in WalkDir::new(&root).into_iter().filter_map(|e| e.ok()) { + if entry.file_name() != "wire.jsonl" { continue; } + if !entry.file_type().is_file() { continue; } + // Skip blob/task directories + let p = entry.path(); + if p.to_string_lossy().contains("blobs") || p.to_string_lossy().contains("tasks") { + continue; + } + files_scanned += 1; + let content = match fs::read_to_string(entry.path()) { + Ok(c) => c, + Err(_) => continue, + }; + for line in content.lines() { + lines_seen += 1; + if !line.contains("\"usage.record\"") { continue; } + let obj: serde_json::Value = match serde_json::from_str(line) { + Ok(v) => v, + Err(_) => continue, + }; + if obj.get("type").and_then(|v| v.as_str()) != Some("usage.record") { continue; } + let scope = obj.get("usageScope").and_then(|v| v.as_str()).unwrap_or("turn"); + if scope != "turn" { continue; } + + let model_raw = obj.get("model").and_then(|v| v.as_str()).unwrap_or("unknown").to_string(); + let time = obj.get("time").and_then(|v| v.as_u64()).unwrap_or(0); + let usage = &obj["usage"]; + let input_other = usage.get("inputOther").and_then(|v| v.as_u64()).unwrap_or(0); + let output = usage.get("output").and_then(|v| v.as_u64()).unwrap_or(0); + let cache_read = usage.get("inputCacheRead").and_then(|v| v.as_u64()).unwrap_or(0); + let cache_create = usage.get("inputCacheCreation").and_then(|v| v.as_u64()).unwrap_or(0); + + // Resolve model display + let bare = model_raw.rsplit_once('/').map(|x| x.1).unwrap_or(&model_raw); + let (cost, est) = cost_for_usage(input_other, output, cache_read, cache_create, &model_raw); + let (pid, _ch, _ip, _op, _) = match_price(&model_raw); + + records.push(UsageRecord { + time, + model: model_raw.clone(), + model_resolved: bare.to_string(), + model_display: model_raw.clone(), + provider: resolve_provider(&model_raw, &alias2prov), + from_env: model_raw == "__kimi_env_model__", + input_other, + output, + input_cache_read: cache_read, + input_cache_creation: cache_create, + cost_usd: cost, + cost_estimated: est, + price_id: pid, + }); + } + } + + records.sort_by(|a, b| b.time.cmp(&a.time)); + let count = records.len(); + (records, ScanMeta { + files_scanned, lines_seen, record_count: count, + home: home.to_string_lossy().to_string(), + sessions_root: root.to_string_lossy().to_string(), + errors: errors.into_iter().take(20).collect(), + }) +} + +// --------------------------------------------------------------------------- +// Aggregate +// --------------------------------------------------------------------------- + +fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats { + let filtered: Vec<&UsageRecord> = filter_by_range(records, range, now_ms); + let range_label = range.to_string(); + + if filtered.is_empty() { + return RangeStats { + range: range_label, + totals: TotalsRow::default(), + daily: vec![], + models: vec![], + recent: vec![], + recent_total: 0, + recent_limit: 500, + }; + } + + let mut totals = TotalsRow::default(); + let mut by_day: HashMap, HashMap>)> = HashMap::new(); + let mut by_model: HashMap = HashMap::new(); + + for r in &filtered { + totals.add(r); + let dk = day_key(r.time); + let (day_totals, day_models, day_prov_models) = by_day.entry(dk.clone()).or_default(); + day_totals.add(r); + *day_models.entry(r.model.clone()).or_insert(0) += + r.input_other + r.output + r.input_cache_read + r.input_cache_creation; + let provider_key = r.provider.clone().unwrap_or_else(|| "unknown".to_string()); + *day_prov_models + .entry(provider_key) + .or_default() + .entry(r.model.clone()) + .or_insert(0) += r.input_other + r.output + r.input_cache_read + r.input_cache_creation; + + let mk = r.model.clone(); + let entry = by_model.entry(mk.clone()).or_insert_with(|| { + let m = ModelRow { + model: mk.clone(), + model_display: r.model_display.clone(), + model_resolved: r.model_resolved.clone(), + price_id: r.price_id.clone(), + cost_estimated: r.cost_estimated, + requests: 0, input_other: 0, output: 0, + input_cache_read: 0, input_cache_creation: 0, + cost_usd: 0.0, total_tokens: 0, cache_hit_rate: 0.0, + }; + (m, TotalsRow::default()) + }); + entry.1.add(r); + entry.0.cost_estimated = entry.0.cost_estimated || r.cost_estimated; + } + + let total_input = totals.input_other + totals.input_cache_read + totals.input_cache_creation; + totals.cache_hit_rate = if total_input > 0 { totals.input_cache_read as f64 / total_input as f64 } else { 0.0 }; + + let mut daily: Vec = by_day.into_iter() + .map(|(date, (t, by_model, by_provider_model))| { + let ti = t.input_other + t.input_cache_read + t.input_cache_creation; + let ch = if ti > 0 { t.input_cache_read as f64 / ti as f64 } else { 0.0 }; + DailyRow { + date, requests: t.requests, + input_other: t.input_other, output: t.output, + input_cache_read: t.input_cache_read, input_cache_creation: t.input_cache_creation, + cost_usd: t.cost_usd, total_tokens: t.total_tokens, cache_hit_rate: ch, + by_model, + by_provider: by_provider_model + .iter() + .map(|(p, m)| (p.clone(), m.values().sum::())) + .collect(), + by_provider_model, + } + }) + .collect(); + daily.sort_by(|a, b| a.date.cmp(&b.date)); + + let mut models: Vec = by_model.into_iter().map(|(_, (mut m, t))| { + let ti = t.input_other + t.input_cache_read + t.input_cache_creation; + m.cache_hit_rate = if ti > 0 { t.input_cache_read as f64 / ti as f64 } else { 0.0 }; + m.requests = t.requests; + m.input_other = t.input_other; m.output = t.output; + m.input_cache_read = t.input_cache_read; m.input_cache_creation = t.input_cache_creation; + m.cost_usd = t.cost_usd; m.total_tokens = t.total_tokens; + m + }).collect(); + models.sort_by(|a, b| b.total_tokens.cmp(&a.total_tokens)); + + let recent_total = filtered.len(); + let recent_limit = 500; + let recent: Vec = filtered.iter().take(recent_limit).map(|r| RecentRow { + time: r.time, model: r.model.clone(), model_display: r.model_display.clone(), + model_resolved: r.model_resolved.clone(), + input_other: r.input_other, output: r.output, + input_cache_read: r.input_cache_read, input_cache_creation: r.input_cache_creation, + total_tokens: r.input_other + r.output + r.input_cache_read + r.input_cache_creation, + cost_usd: r.cost_usd, cost_estimated: r.cost_estimated, + price_id: r.price_id.clone(), from_env: r.from_env, + }).collect(); + + RangeStats { + range: range_label, + totals, + daily, + models, + recent, + recent_total, + recent_limit, + } +} + +impl TotalsRow { + fn default() -> Self { + TotalsRow { + requests: 0, input_other: 0, output: 0, + input_cache_read: 0, input_cache_creation: 0, + cost_usd: 0.0, total_tokens: 0, cache_hit_rate: 0.0, + } + } + fn add(&mut self, r: &UsageRecord) { + self.requests += 1; + self.input_other += r.input_other; + self.output += r.output; + self.input_cache_read += r.input_cache_read; + self.input_cache_creation += r.input_cache_creation; + self.cost_usd += r.cost_usd; + self.total_tokens = self.input_other + self.output + self.input_cache_read + self.input_cache_creation; + } +} + +fn filter_by_range<'a>(records: &'a [UsageRecord], range: &str, now_ms: u64) -> Vec<&'a UsageRecord> { + if range == "all" { return records.iter().collect(); } + let start = range_start(range, now_ms); + records.iter().filter(|r| r.time >= start).collect() +} + +fn range_start(range: &str, now_ms: u64) -> u64 { + match range { + "today" => { + // LOCAL midnight today — not UTC midnight. Without this, users east of UTC see + // "today" begin at the wrong hour (e.g. UTC+8 users get cutoff at local 08:00). + let now_utc = DateTime::from_timestamp((now_ms / 1000) as i64, 0).unwrap_or_default(); + let now_local = now_utc.with_timezone(&Local); + let today_date = now_local.date_naive(); + let today_start = today_date.and_hms_opt(0, 0, 0).unwrap_or_default(); + Local + .from_local_datetime(&today_start) + .single() + .map(|dt| dt.timestamp() as u64 * 1000) + .unwrap_or_else(|| today_start.and_utc().timestamp() as u64 * 1000) + } + "7d" => now_ms - 7 * 24 * 3600 * 1000, + "30d" => now_ms - 30 * 24 * 3600 * 1000, + _ => now_ms - 30 * 24 * 3600 * 1000, + } +} + +fn build_heatmap(records: &[UsageRecord], now_ms: u64) -> HeatmapData { + let weeks = 53; + // Use LOCAL today for the heatmap's right edge so the current day's cell lights up correctly. + let now_utc = DateTime::from_timestamp((now_ms / 1000) as i64, 0).unwrap_or_default(); + let now_local = now_utc.with_timezone(&Local); + let end_date = now_local.date_naive(); + let start_date = end_date - chrono::Duration::days(weeks * 7 - 1); + let start_date = start_date - chrono::Duration::days(start_date.weekday().num_days_from_sunday() as i64); + + let mut by_day: HashMap = HashMap::new(); + for r in records { + let dk = day_key(r.time); + let t = by_day.entry(dk).or_default(); + t.add(r); + } + + let mut cursor = start_date; + let mut cells = Vec::new(); + let mut max_tokens = 0u64; + + while cursor <= end_date { + let key = format!("{:04}-{:02}-{:02}", cursor.year(), cursor.month(), cursor.day()); + let t = by_day.get(&key).cloned().unwrap_or_else(TotalsRow::default); + let tok = t.total_tokens; + if tok > max_tokens { max_tokens = tok; } + let dow = cursor.weekday().num_days_from_sunday() as usize; + let week_idx = ((cursor - start_date).num_days() / 7) as usize; + let ti = t.input_other + t.input_cache_read + t.input_cache_creation; + let ch = if ti > 0 { t.input_cache_read as f64 / ti as f64 } else { 0.0 }; + cells.push(HeatmapCell { + date: key, dow, week_index: week_idx, + requests: t.requests, total_tokens: tok, cost_usd: t.cost_usd, + cache_hit_rate: ch, level: 0, + }); + cursor += chrono::Duration::days(1); + if cells.len() > (weeks * 7 + 7) as usize { break; } + } + for c in &mut cells { + c.level = if max_tokens == 0 || c.total_tokens == 0 { + 0 + } else { + let r = c.total_tokens as f64 / max_tokens as f64; + if r > 0.75 { 4 } else if r > 0.5 { 3 } else if r > 0.25 { 2 } else { 1 } + }; + } + + let mut month_labels = Vec::new(); + let mut last_month = String::new(); + for c in &cells { + let m = c.date[..7].to_string(); + if m != last_month { + month_labels.push(MonthLabel { week_index: c.week_index, label: c.date[5..7].to_string() }); + last_month = m; + } + } + + HeatmapData { + weeks: weeks as usize, + start: start_date.format("%Y-%m-%d").to_string(), + end: end_date.format("%Y-%m-%d").to_string(), + max_tokens, + cells, + month_labels, + } +} + +// --------------------------------------------------------------------------- +// Sessions +// --------------------------------------------------------------------------- + +fn list_workspace_dirs(home: &Path) -> Vec { + let root = sessions_root(home); + if !root.exists() { return vec![]; } + let mut out = Vec::new(); + let re = workspace_re(); + for entry in safe_read_dir(&root) { + if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { continue; } + let name = entry.file_name().to_string_lossy().to_string(); + if name == ".kcd-archive" { continue; } + if !re.is_match(&name) { continue; } + out.push(name); + } + out.sort(); + out +} + +fn read_state_safe(session_dir: &Path) -> (Option, Option, Option, Option) { + let sp = session_dir.join("state.json"); + if !sp.exists() { return (None, None, None, None); } + if let Ok(content) = fs::read_to_string(&sp) { + if let Ok(obj) = serde_json::from_str::(&content) { + let title = obj.get("title").and_then(|v| v.as_str().map(|s| s.to_string())); + let work_dir = obj.get("workDir").and_then(|v| v.as_str().map(|s| s.to_string())); + let created_at = obj.get("createdAt").and_then(|v| v.as_str().map(|s| s.to_string())); + let updated_at = obj.get("updatedAt").and_then(|v| v.as_str().map(|s| s.to_string())); + return (title, work_dir, created_at, updated_at); + } + } + (None, None, None, None) +} + +fn humanize_workspace(id: &str) -> String { + let re = Regex::new(r"^wd_(.+)_[0-9a-fA-F]{8,}$").unwrap(); + if let Some(caps) = re.captures(id) { + caps.get(1).map(|m| m.as_str().to_string()).unwrap_or_else(|| id.to_string()) + } else { + id.to_string() + } +} + +fn list_sessions_in_dir(dir: &Path, workspace_id: &str, status: &str) -> Vec { + let mut sessions = Vec::new(); + if !dir.exists() { return sessions; } + let re = session_re(); + for entry in safe_read_dir(dir) { + if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { continue; } + let name = entry.file_name().to_string_lossy().to_string(); + if !re.is_match(&name) { continue; } + let (title, work_dir, created_at, updated_at) = read_state_safe(&entry.path()); + let (bytes, files) = file_size_approx(&entry.path()); + let mtime = entry.path().metadata().ok().and_then(|m| m.modified().ok()) + .map(|t| t.duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_millis() as u64)).flatten(); + sessions.push(SessionRow { + id: name, workspace_id: workspace_id.to_string(), status: status.to_string(), + title, work_dir, created_at, + updated_at: updated_at.or_else(|| mtime.map(|m| { + let secs = if m > 1e12 as u64 { m / 1000 } else { m }; + let dt = DateTime::from_timestamp(secs as i64, 0).unwrap_or_default().naive_utc(); + dt.to_string() + })), + bytes, files, + }); + } + sessions +} + +fn list_sessions_cmd(home: &Path, status: &str, workspace_filter: Option) -> SessionsResult { + let root = sessions_root(home); + let archive_root = root.join(".kcd-archive"); + let mut ws_ids: Vec = list_workspace_dirs(home); + // Also include archive-only workspaces + if archive_root.exists() { + for entry in safe_read_dir(&archive_root) { + let name = entry.file_name().to_string_lossy().to_string(); + if workspace_re().is_match(&name) && !ws_ids.contains(&name) { + ws_ids.push(name); + } + } + } + ws_ids.sort(); + + // Load workspaces.json + let wp = home.join("workspaces.json"); + let mut ws_meta: HashMap = HashMap::new(); + if let Ok(content) = fs::read_to_string(&wp) { + if let Ok(obj) = serde_json::from_str::(&content) { + if let Some(ws) = obj.get("workspaces").and_then(|v| v.as_object()) { + for (k, v) in ws { + ws_meta.insert(k.clone(), v.clone()); + } + } + } + } + + let mut workspaces = Vec::new(); + let mut all_sessions = Vec::new(); + + for wid in &ws_ids { + if let Some(ref filter) = workspace_filter { + if wid != filter { continue; } + } + let meta = ws_meta.get(wid); + let name = meta.and_then(|m| m.get("name").and_then(|v| v.as_str())) + .map(|s| s.to_string()).unwrap_or_else(|| humanize_workspace(wid)); + let root_path = meta.and_then(|m| m.get("root").and_then(|v| v.as_str()).map(|s| s.to_string())); + + let active_dir = root.join(wid); + let arch_dir = archive_root.join(wid); + let active_all = list_sessions_in_dir(&active_dir, wid, "active"); + let arch_all = list_sessions_in_dir(&arch_dir, wid, "archived"); + let active_list = if status == "archived" { vec![] } else { active_all.clone() }; + let arch_list = if status == "active" { vec![] } else { arch_all.clone() }; + + let empty = active_all.is_empty() && arch_all.is_empty(); + workspaces.push(WorkspaceRow { + id: wid.clone(), name, root: root_path, + created_at: None, last_opened_at: None, + active_count: active_all.len(), archived_count: arch_all.len(), empty, + }); + all_sessions.extend(active_list); + all_sessions.extend(arch_list); + } + + all_sessions.sort_by(|a, b| { + let ta = a.updated_at.as_deref().or(a.created_at.as_deref()).unwrap_or("").to_string(); + let tb = b.updated_at.as_deref().or(b.created_at.as_deref()).unwrap_or("").to_string(); + tb.cmp(&ta) + }); + + SessionsResult { + home: home.to_string_lossy().to_string(), + archive_root: ".kcd-archive".into(), + workspaces, + sessions: all_sessions, + } +} + +fn assert_safe_path(home: &Path, workspace_id: &str, session_id: &str) -> Result { + if !workspace_re().is_match(workspace_id) { + return Err("invalid workspace id".into()); + } + if !session_re().is_match(session_id) { + return Err("invalid session id".into()); + } + let root = sessions_root(home).canonicalize().unwrap_or_else(|_| sessions_root(home)); + let candidate = root.join(workspace_id).join(session_id).canonicalize().unwrap_or_else(|_| root.join(workspace_id).join(session_id)); + if !candidate.starts_with(&root) { + return Err("path escape blocked".into()); + } + Ok(candidate) +} + +fn archive_session_cmd(home: &Path, workspace_id: &str, session_id: &str) -> Result { + let src = assert_safe_path(home, workspace_id, session_id)?; + let dest = sessions_root(home).join(".kcd-archive").join(workspace_id).join(session_id); + if !src.exists() { return Err("session not found".into()); } + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent).map_err(|e| format!("mkdir: {}", e))?; + } + fs::rename(&src, &dest).or_else(|_| { + // cross-device fallback + fs_extra::dir::copy(&src, dest.parent().unwrap(), &Default::default()).ok(); + fs::remove_dir_all(&src).ok(); + Ok::<(), String>(()) + }).map_err(|e: String| format!("move: {}", e))?; + scrub_session_index(home, session_id); + Ok(ActionResponse { + ok: true, workspace_id: workspace_id.to_string(), + session_id: session_id.to_string(), status: Some("archived".into()), + path: Some(dest.to_string_lossy().to_string()), deleted: None, + }) +} + +fn unarchive_session_cmd(home: &Path, workspace_id: &str, session_id: &str) -> Result { + let src = sessions_root(home).join(".kcd-archive").join(workspace_id).join(session_id); + let dest = assert_safe_path(home, workspace_id, session_id)?; + if !src.exists() { return Err("archived session not found".into()); } + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent).map_err(|e| format!("mkdir: {}", e))?; + } + fs::rename(&src, &dest).or_else(|_| { + fs_extra::dir::copy(&src, dest.parent().unwrap(), &Default::default()).ok(); + fs::remove_dir_all(&src).ok(); + Ok::<(), String>(()) + }).map_err(|e: String| format!("move: {}", e))?; + Ok(ActionResponse { + ok: true, workspace_id: workspace_id.to_string(), + session_id: session_id.to_string(), status: Some("active".into()), + path: Some(dest.to_string_lossy().to_string()), deleted: None, + }) +} + +fn delete_session_cmd(home: &Path, workspace_id: &str, session_id: &str, status_hint: Option<&str>) -> Result { + let active = assert_safe_path(home, workspace_id, session_id)?; + let archived = sessions_root(home).join(".kcd-archive").join(workspace_id).join(session_id); + let target = match status_hint { + Some("archived") if archived.exists() => archived, + Some("active") if active.exists() => active, + _ => { + if active.exists() { active } else if archived.exists() { archived } + else { return Err("session not found".into()); } + } + }; + fs::remove_dir_all(&target).map_err(|e| format!("delete: {}", e))?; + scrub_session_index(home, session_id); + Ok(ActionResponse { + ok: true, workspace_id: workspace_id.to_string(), + session_id: session_id.to_string(), status: None, + path: Some(target.to_string_lossy().to_string()), deleted: Some(true), + }) +} + +fn scrub_session_index(home: &Path, session_id: &str) { + let ip = home.join("session_index.jsonl"); + if !ip.exists() { return; } + if let Ok(content) = fs::read_to_string(&ip) { + let lines: Vec<&str> = content.lines().filter(|l| { + let sid = format!("\"sessionId\":\"{}\"", session_id); + let sid2 = format!("\"sessionId\": \"{}\"", session_id); + let path_match = format!("/{}\"", session_id); + !l.contains(&sid) && !l.contains(&sid2) && !l.contains(&path_match) + }).collect(); + if lines.len() < content.lines().count() { + let _ = fs::write(&ip, lines.join("\n") + "\n"); + } + } +} + +fn delete_workspace_cmd(home: &Path, workspace_id: &str, _confirm: bool, _force: bool) -> Result { + if !workspace_re().is_match(workspace_id) { + return Err("invalid workspace id".into()); + } + let root = sessions_root(home); + let active_dir = root.join(workspace_id); + let arch_dir = root.join(".kcd-archive").join(workspace_id); + + if active_dir.exists() { + let active_list = list_sessions_in_dir(&active_dir, workspace_id, "active"); + if !active_list.is_empty() { return Err("workspace is not empty; archive/delete sessions first".into()); } + } + if arch_dir.exists() { + let arch_list = list_sessions_in_dir(&arch_dir, workspace_id, "archived"); + if !arch_list.is_empty() { return Err("workspace is not empty; archive/delete sessions first".into()); } + } + + if active_dir.exists() { fs::remove_dir_all(&active_dir).ok(); } + if arch_dir.exists() { fs::remove_dir_all(&arch_dir).ok(); } + + // Update workspaces.json + let wp = home.join("workspaces.json"); + if let Ok(content) = fs::read_to_string(&wp) { + if let Ok(mut obj) = serde_json::from_str::(&content) { + if let Some(ws) = obj.get_mut("workspaces").and_then(|v| v.as_object_mut()) { + ws.remove(workspace_id); + } + let deleted = obj.get_mut("deleted_workspace_ids") + .and_then(|v| v.as_array_mut()); + if let Some(arr) = deleted { + if !arr.iter().any(|v| v.as_str() == Some(workspace_id)) { + arr.push(serde_json::Value::String(workspace_id.to_string())); + } + } + let _ = fs::write(&wp, serde_json::to_string_pretty(&obj).unwrap() + "\n"); + } + } + + Ok(ActionResponse { + ok: true, workspace_id: workspace_id.to_string(), + session_id: String::new(), status: None, path: None, deleted: Some(true), + }) +} + +// --------------------------------------------------------------------------- +// Preview +// --------------------------------------------------------------------------- + +fn clip_text(s: &str, max: usize) -> String { + // Only walk the first `max` bytes — earlier version filtered the whole string, + // which was fine for short text but blew up when previewing big turns. + let (end, trunc) = if s.len() <= max { + (s.len(), false) + } else { + let mut e = max; + while e < s.len() && !s.is_char_boundary(e) { e += 1; } + (e, true) + }; + let head = &s[..end]; + let cleaned: String = head.chars().filter(|&c| c != '\0').collect(); + if trunc { format!("{}…", cleaned) } else { cleaned } +} + +fn extract_text_parts(content: &serde_json::Value) -> String { + if let Some(s) = content.as_str() { return s.to_string(); } + if let Some(arr) = content.as_array() { + let mut parts = Vec::new(); + for p in arr { + if let Some(obj) = p.as_object() { + if let Some(t) = obj.get("text").and_then(|v| v.as_str()) { + parts.push(t.to_string()); + } + } + } + return parts.join("\n"); + } + String::new() +} + +fn push_user_msg(msgs: &mut Vec, role: &str, text: &str, time: u64) { + let norm: String = text.split_whitespace().collect::>().join(" "); + if norm.is_empty() { return; } + if let Some(last) = msgs.last() { + if last.role == role { + let last_norm: String = last.text.split_whitespace().collect::>().join(" "); + if last_norm == norm { return; } + } + } + let text = if looks_like_secret(text) { "[redacted: possible secret content]".into() } else { clip_text(text, 2500) }; + msgs.push(PreviewMessage { role: role.into(), time: Some(time), text }); +} + +fn flush_assistant_msg(msgs: &mut Vec, bucket: &Option<(Vec, u64)>) { + if let Some((texts, _)) = bucket { + let combined = texts.join(""); + if !combined.trim().is_empty() { + let text = if looks_like_secret(&combined) { "[redacted: possible secret content]".into() } else { clip_text(&combined, 2500) }; + if let Some(last) = msgs.last() { + if last.role == "assistant" { + let last_norm: String = last.text.split_whitespace().collect::>().join(" "); + let this_norm: String = text.split_whitespace().collect::>().join(" "); + if last_norm == this_norm { return; } + } + } + msgs.push(PreviewMessage { role: "assistant".into(), time: None, text }); + } + } +} + +fn get_session_preview_cmd(home: &Path, workspace_id: &str, session_id: &str, status_hint: Option<&str>) -> Result { + let root = sessions_root(home); + let active = assert_safe_path(home, workspace_id, session_id)?; + let archived = root.join(".kcd-archive").join(workspace_id).join(session_id); + let session_dir = match status_hint { + Some("archived") if archived.exists() => archived, + _ => if active.exists() { active } else if archived.exists() { archived } + else { return Err("session not found".into()); } + }; + + let (title, work_dir, created_at, updated_at) = read_state_safe(&session_dir); + let api_dir = session_dir.join("agents").join("main"); + let mut wire_path = api_dir.join("wire.jsonl"); + if !wire_path.exists() { + wire_path = session_dir.join("wire.jsonl"); + } + if !wire_path.exists() { + // try first agent wire + let agents_dir = session_dir.join("agents"); + if agents_dir.exists() { + for entry in safe_read_dir(&agents_dir) { + let w = entry.path().join("wire.jsonl"); + if w.exists() { wire_path = w; break; } + } + } + } + + let mut messages: Vec = Vec::new(); + let mut truncated = false; + let max_msgs = 80; + let _max_chars = 2500usize; + // Hard byte cap: refuse to stream more than this from a single wire.jsonl. + // Typical heavy sessions are 5–30 MB; anything larger risks OOM in the WebView. + const MAX_WIRE_BYTES: usize = 20 * 1024 * 1024; // 20 MB + + if wire_path.exists() { + let file = match fs::File::open(&wire_path) { + Ok(f) => f, + Err(e) => return Err(format!("open wire: {e}")), + }; + let reader = BufReader::new(file); + let mut bytes_read = 0usize; + let mut line_count = 0usize; + let mut current_assistant: Option<(Vec, u64)> = None; + let mut current_step_key: Option = None; + + for line_res in reader.lines() { + let line = match line_res { + Ok(l) => l, + Err(_) => break, + }; + bytes_read += line.len() + 1; // +1 for the stripped newline + line_count += 1; + if bytes_read > MAX_WIRE_BYTES { truncated = true; break; } + if messages.len() >= max_msgs { truncated = true; break; } + if !line.contains("\"context.append_message\"") && !line.contains("\"turn.steer\"") + && !line.contains("\"turn.prompt\"") && !line.contains("\"content.part\"") + && !line.contains("\"step.end\"") { continue; } + let obj: serde_json::Value = match serde_json::from_str(&line) { + Ok(v) => v, Err(_) => continue, + }; + let typ = obj.get("type").and_then(|v| v.as_str()).unwrap_or(""); + + if typ == "context.append_message" { + flush_assistant_msg(&mut messages, ¤t_assistant); + current_assistant = None; + current_step_key = None; + let msg = &obj["message"]; + let role = msg.get("role").and_then(|v| v.as_str()).unwrap_or("unknown"); + if role == "tool" { continue; } + let raw = extract_text_parts(&msg["content"]); + if raw.trim().is_empty() { continue; } + push_user_msg(&mut messages, role, &raw, obj.get("time").and_then(|v| v.as_u64()).unwrap_or(0)); + continue; + } + + if typ == "turn.steer" || typ == "turn.prompt" { + let input = &obj["input"]; + let raw = if let Some(s) = input.as_str() { s.to_string() } + else if let Some(arr) = input.as_array() { + arr.iter().filter_map(|x| x.get("text").and_then(|v| v.as_str())).collect::>().join("\n") + } else { String::new() }; + if raw.trim().is_empty() { continue; } + push_user_msg(&mut messages, "user", &raw, obj.get("time").and_then(|v| v.as_u64()).unwrap_or(0)); + continue; + } + + if typ == "context.append_loop_event" { + let ev = &obj["event"]; + let ev_type = ev.get("type").and_then(|v| v.as_str()).unwrap_or(""); + if ev_type == "content.part" { + let part = &ev["part"]; + if part.get("type").and_then(|v| v.as_str()) == Some("text") { + let text = part.get("text").and_then(|v| v.as_str()).unwrap_or(""); + if !text.is_empty() { + let step_key = ev.get("stepUuid").and_then(|v| v.as_str()) + .map(|s| s.to_string()).unwrap_or_else(|| format!("{}:{}", ev["turnId"], ev["step"])); + // flush if step changed + if current_step_key.as_deref() != Some(&step_key) { + flush_assistant_msg(&mut messages, ¤t_assistant); + current_assistant = Some((Vec::new(), obj.get("time").and_then(|v| v.as_u64()).unwrap_or(0))); + current_step_key = Some(step_key.clone()); + } else if current_assistant.is_none() { + current_assistant = Some((Vec::new(), obj.get("time").and_then(|v| v.as_u64()).unwrap_or(0))); + } + current_assistant.as_mut().unwrap().0.push(text.to_string()); + } + } + continue; + } + if ev_type == "step.end" { + flush_assistant_msg(&mut messages, ¤t_assistant); + current_assistant = None; + current_step_key = None; + } + } + } + flush_assistant_msg(&mut messages, ¤t_assistant); + if line_count > 8000 { truncated = true; } + if messages.len() >= max_msgs { truncated = true; } + if bytes_read > MAX_WIRE_BYTES { truncated = true; } + } + + Ok(PreviewResult { + workspace_id: workspace_id.to_string(), + session_id: session_id.to_string(), + status: if session_dir.to_string_lossy().contains(".kcd-archive") { "archived".into() } else { "active".into() }, + title, work_dir, created_at, updated_at, + message_count: messages.len(), + truncated, + messages, + }) +} + +fn looks_like_secret(s: &str) -> bool { + let re = regex::Regex::new(r"(?i)api[_-]?key|sk-[a-zA-Z0-9]{12,}|BEGIN (RSA |OPENSSH )?PRIVATE KEY").unwrap(); + re.is_match(s) +} + +// --------------------------------------------------------------------------- +// Tauri commands +// --------------------------------------------------------------------------- + +#[tauri::command] +pub fn get_paths() -> PathsResult { + let home = resolve_kimi_home(None); + let valid = is_kimi_home(&home); + let mut candidates = Vec::new(); + if let Ok(env_home) = std::env::var("KIMI_CODE_HOME") { + let p = PathBuf::from(&env_home); + candidates.push(PathCandidate { path: env_home, valid: is_kimi_home(&p) }); + } + if let Some(hd) = dirs::home_dir() { + let p = hd.join(".kimi-code"); + candidates.push(PathCandidate { path: p.to_string_lossy().to_string(), valid: is_kimi_home(&p) }); + if let Ok(up) = std::env::var("USERPROFILE") { + let p2 = PathBuf::from(&up).join(".kimi-code"); + if !candidates.iter().any(|c| c.path == p2.to_string_lossy()) { + candidates.push(PathCandidate { path: p2.to_string_lossy().to_string(), valid: is_kimi_home(&p2) }); + } + } + } + PathsResult { + current: home.to_string_lossy().to_string(), + valid, + candidates, + env: EnvInfo { + kimi_code_home: std::env::var("KIMI_CODE_HOME").ok(), + kimi_model_name: std::env::var("KIMI_MODEL_NAME").ok(), + }, + } +} + +#[tauri::command] +pub fn get_prices() -> PricesResult { + PricesResult { prices: list_prices() } +} + +#[tauri::command] +pub fn get_summary(home_override: Option, range: Option, refresh: Option) -> SummaryResult { + let _refresh = refresh.unwrap_or(false); + let home = resolve_kimi_home(home_override); + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis() as u64; + let r = range.unwrap_or_else(|| "30d".into()); + + let (records, meta) = scan_usage(&home); + let stats = aggregate(&records, &r, now_ms); + let all_stats = aggregate(&records, "all", now_ms); + let heatmap = build_heatmap(&records, now_ms); + + let all_models: Vec = all_stats.models.into_iter().map(|m| { + AllModelRow { + model: m.model, model_display: m.model_display, requests: m.requests, + total_tokens: m.total_tokens, cost_usd: m.cost_usd, cost_estimated: m.cost_estimated, + cache_hit_rate: m.cache_hit_rate, + } + }).collect(); + + let all_model_count = all_models.len(); + let mut range_totals = HashMap::new(); + for r_k in ["today", "7d", "30d", "all"] { + let s = aggregate(&records, r_k, now_ms); + range_totals.insert(r_k.to_string(), s.totals); + } + + let default_model = None; + let env_model = std::env::var("KIMI_MODEL_NAME").ok().map(|name| EnvModelInfo { + name, provider: std::env::var("KIMI_MODEL_PROVIDER").ok(), model: std::env::var("KIMI_MODEL_ID").ok(), + }); + + SummaryResult { + home: home.to_string_lossy().to_string(), + valid: is_kimi_home(&home), + scanned_at: now_ms, + meta, + model_map: ModelMapInfo { default_model, env_model, alias_count: 0 }, + range: r, + stats, + heatmap, + all_models, + all_model_count, + range_totals, + } +} + +#[tauri::command] +pub fn list_sessions(home_override: Option, status: Option, workspace: Option) -> SessionsResult { + let home = resolve_kimi_home(home_override); + list_sessions_cmd(&home, &status.unwrap_or_else(|| "active".into()), workspace) +} + +#[tauri::command] +pub fn archive_session(home_override: Option, workspace_id: String, session_id: String) -> Result { + let home = resolve_kimi_home(home_override); + archive_session_cmd(&home, &workspace_id, &session_id) +} + +#[tauri::command] +pub fn unarchive_session(home_override: Option, workspace_id: String, session_id: String) -> Result { + let home = resolve_kimi_home(home_override); + unarchive_session_cmd(&home, &workspace_id, &session_id) +} + +#[tauri::command] +pub fn delete_session(home_override: Option, workspace_id: String, session_id: String, status: Option) -> Result { + let home = resolve_kimi_home(home_override); + delete_session_cmd(&home, &workspace_id, &session_id, status.as_deref()) +} + +#[tauri::command] +pub fn delete_workspace(home_override: Option, workspace_id: String, confirm: bool, force: Option) -> Result { + if !confirm { return Err("confirm_required: Pass confirm:true to delete an empty workspace".into()); } + let home = resolve_kimi_home(home_override); + delete_workspace_cmd(&home, &workspace_id, confirm, force.unwrap_or(false)) +} + +#[tauri::command] +pub fn get_session_preview(home_override: Option, workspace_id: String, session_id: String, status: Option) -> Result { + let home = resolve_kimi_home(home_override); + get_session_preview_cmd(&home, &workspace_id, &session_id, status.as_deref()) +} diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs new file mode 100644 index 0000000..150f27d --- /dev/null +++ b/src-tauri/src/db.rs @@ -0,0 +1,338 @@ +//! Kimi Switch local SQLite storage. +//! +//! This module stores the full Kimi Switch configuration (all providers and models +//! for both Kimi Code and Pi agents) in a local SQLite database. It is separate +//! from the agent-specific config files that are written when the user activates +//! a provider. + +use std::path::PathBuf; + +use anyhow::Context; +use indexmap::IndexMap; +use rusqlite::{params, Connection}; +use serde_json::Value; + +use crate::models::{Agent, Config, Model, Provider, ProviderType}; + +pub type DbResult = anyhow::Result; + +pub fn kimi_switch_data_dir() -> PathBuf { + dirs::home_dir() + .map(|h| h.join(".kimi-switch")) + .expect("failed to resolve home directory") +} + +pub fn db_path() -> PathBuf { + kimi_switch_data_dir().join("kimi-switch.db") +} + +/// One-time migration: if the legacy `~/.pi-switch/` data directory exists and +/// `~/.kimi-switch/` does not, move it so existing users keep their saved +/// configuration. Safe to call on every startup — it is a no-op once the new +/// directory exists. +fn migrate_legacy_data_dir() { + let new_dir = kimi_switch_data_dir(); + if new_dir.exists() { + return; + } + let old_dir = match dirs::home_dir() { + Some(h) => h.join(".pi-switch"), + None => return, + }; + if !old_dir.exists() { + return; + } + // Best-effort move; failures are silently ignored so the app can still start. + if let Some(new_parent) = new_dir.parent() { + if let Err(_) = std::fs::create_dir_all(new_parent) { + return; + } + } + let _ = std::fs::rename(&old_dir, &new_dir); +} + +pub fn init_db() -> DbResult { + migrate_legacy_data_dir(); + let path = db_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create directory {}", parent.display()))?; + } + let conn = Connection::open(&path) + .with_context(|| format!("failed to open database {}", path.display()))?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS providers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent TEXT NOT NULL, + name TEXT NOT NULL, + provider_type TEXT NOT NULL, + base_url TEXT, + api_key TEXT, + env TEXT, + note TEXT, + official_url TEXT, + managed INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + active INTEGER NOT NULL DEFAULT 0, + raw_other TEXT, + UNIQUE(agent, name) + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS models ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent TEXT NOT NULL, + alias TEXT NOT NULL, + provider_name TEXT NOT NULL, + model TEXT NOT NULL, + max_context_size INTEGER NOT NULL, + display_name TEXT, + supports_1m INTEGER NOT NULL DEFAULT 0, + capabilities TEXT, + raw_other TEXT, + UNIQUE(agent, alias) + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + )", + [], + )?; + + // Add icon columns for provider icon picker (introduced in v0.3.1). + // SQLite has no ADD COLUMN IF NOT EXISTS, so ignore duplicate-column errors. + for stmt in [ + "ALTER TABLE providers ADD COLUMN icon TEXT", + "ALTER TABLE providers ADD COLUMN icon_color TEXT", + ] { + if let Err(e) = conn.execute(stmt, []) { + let msg = e.to_string(); + if !msg.contains("duplicate column name") { + return Err(e).with_context(|| format!("failed to run migration: {}", stmt)); + } + } + } + + Ok(conn) +} + +pub fn load_config(agent: &Agent) -> DbResult { + let mut conn = init_db()?; + let tx = conn.transaction()?; + + let default_model = get_setting_tx(&tx, &default_model_key(agent))?; + + let mut providers = IndexMap::new(); + { + let mut stmt = tx.prepare( + "SELECT name, provider_type, base_url, api_key, env, note, official_url, managed, enabled, active, icon, icon_color, raw_other + FROM providers WHERE agent = ?1 ORDER BY id", + )?; + let provider_rows = stmt.query_map(params![agent.as_str()], |row| { + let provider_type: String = row.get(1)?; + let env_json: Option = row.get(4)?; + let raw_json: Option = row.get(12)?; + Ok(Provider { + name: row.get(0)?, + provider_type: provider_type_for_str(&provider_type), + base_url: row.get(2)?, + api_key: row.get(3)?, + env: env_json + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(), + note: row.get(5)?, + official_url: row.get(6)?, + managed: row.get::<_, i32>(7)? != 0, + enabled: row.get::<_, i32>(8)? != 0, + active: row.get::<_, i32>(9)? != 0, + icon: row.get(10)?, + icon_color: row.get(11)?, + raw_other: raw_json + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or(Value::Null), + // Merged from the settings table by load_agent_config_command. + usage_kinds: None, + }) + })?; + + for provider in provider_rows { + let p = provider?; + providers.insert(p.name.clone(), p); + } + } + + let mut models = IndexMap::new(); + { + let mut stmt = tx.prepare( + "SELECT alias, provider_name, model, max_context_size, display_name, supports_1m, capabilities, raw_other + FROM models WHERE agent = ?1 ORDER BY id", + )?; + let model_rows = stmt.query_map(params![agent.as_str()], |row| { + let caps_json: Option = row.get(6)?; + let raw_json: Option = row.get(7)?; + Ok(Model { + alias: row.get(0)?, + provider: row.get(1)?, + model: row.get(2)?, + max_context_size: row.get::<_, i64>(3)? as u64, + display_name: row.get(4)?, + supports_1m: row.get::<_, i32>(5)? != 0, + capabilities: caps_json + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(), + raw_other: raw_json + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or(Value::Null), + }) + })?; + + for model in model_rows { + let m = model?; + models.insert(m.alias.clone(), m); + } + } + + tx.commit()?; + + Ok(Config { + default_model, + providers, + models, + raw_other: Value::Null, + }) +} + +pub fn save_config(agent: &Agent, config: &Config) -> DbResult<()> { + let mut conn = init_db()?; + let tx = conn.transaction()?; + + tx.execute("DELETE FROM providers WHERE agent = ?1", params![agent.as_str()])?; + tx.execute("DELETE FROM models WHERE agent = ?1", params![agent.as_str()])?; + + { + let mut insert_provider = tx.prepare( + "INSERT INTO providers + (agent, name, provider_type, base_url, api_key, env, note, official_url, managed, enabled, active, icon, icon_color, raw_other) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", + )?; + + for provider in config.providers.values() { + insert_provider.execute(params![ + agent.as_str(), + provider.name, + provider.provider_type.as_str(), + provider.base_url, + provider.api_key, + serde_json::to_string(&provider.env).ok(), + provider.note, + provider.official_url, + provider.managed as i32, + provider.enabled as i32, + provider.active as i32, + provider.icon, + provider.icon_color, + serde_json::to_string(&provider.raw_other).ok(), + ])?; + } + } + + { + let mut insert_model = tx.prepare( + "INSERT INTO models + (agent, alias, provider_name, model, max_context_size, display_name, supports_1m, capabilities, raw_other) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + )?; + + for model in config.models.values() { + insert_model.execute(params![ + agent.as_str(), + model.alias, + model.provider, + model.model, + model.max_context_size as i64, + model.display_name, + model.supports_1m as i32, + serde_json::to_string(&model.capabilities).ok(), + serde_json::to_string(&model.raw_other).ok(), + ])?; + } + } + + if let Some(default_model) = &config.default_model { + set_setting_tx(&tx, &default_model_key(agent), default_model)?; + } else { + tx.execute("DELETE FROM settings WHERE key = ?1", params![default_model_key(agent)])?; + } + + tx.commit()?; + Ok(()) +} + +fn default_model_key(agent: &Agent) -> String { + format!("default_model:{}", agent.as_str()) +} + +fn get_setting_tx(tx: &rusqlite::Transaction, key: &str) -> DbResult> { + let mut stmt = tx.prepare("SELECT value FROM settings WHERE key = ?1")?; + let mut rows = stmt.query(params![key])?; + if let Some(row) = rows.next()? { + Ok(Some(row.get(0)?)) + } else { + Ok(None) + } +} + +fn set_setting_tx(tx: &rusqlite::Transaction, key: &str, value: &str) -> DbResult<()> { + tx.execute( + "INSERT INTO settings (key, value) VALUES (?1, ?2) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![key, value], + )?; + Ok(()) +} + +/// Public helper: read a single setting without an explicit transaction. +pub fn get_setting_pub(key: &str) -> DbResult> { + let conn = init_db()?; + let mut stmt = conn.prepare("SELECT value FROM settings WHERE key = ?1")?; + let mut rows = stmt.query(params![key])?; + if let Some(row) = rows.next()? { + Ok(Some(row.get(0)?)) + } else { + Ok(None) + } +} + +/// Public helper: write a single setting in its own transaction. +pub fn set_setting_pub(key: &str, value: &str) -> DbResult<()> { + let mut conn = init_db()?; + let tx = conn.transaction()?; + set_setting_tx(&tx, key, value)?; + tx.commit()?; + Ok(()) +} + +/// Public helper: delete a single setting (no-op if the key does not exist). +pub fn delete_setting_pub(key: &str) -> DbResult<()> { + let conn = init_db()?; + conn.execute("DELETE FROM settings WHERE key = ?1", params![key])?; + Ok(()) +} + +fn provider_type_for_str(s: &str) -> ProviderType { + match s { + "anthropic" => ProviderType::Anthropic, + "openai" => ProviderType::Openai, + "openai_responses" => ProviderType::OpenaiResponses, + "google-genai" => ProviderType::GoogleGenai, + "vertexai" => ProviderType::Vertexai, + _ => ProviderType::Kimi, + } +} diff --git a/src-tauri/src/kimi_code_io.rs b/src-tauri/src/kimi_code_io.rs new file mode 100644 index 0000000..7b75d23 --- /dev/null +++ b/src-tauri/src/kimi_code_io.rs @@ -0,0 +1,758 @@ +//! 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 Kimi 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::config_io::backup_file; +use crate::models::{Agent, Config, Model, Provider, ProviderType}; + +pub type KimiResult = anyhow::Result; + +pub fn kimi_code_config_dir() -> PathBuf { + std::env::var_os("KIMI_CODE_HOME") + .map(PathBuf::from) + .or_else(|| dirs::home_dir().map(|h| h.join(".kimi-code"))) + .expect("failed to resolve Kimi Code config 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() { + backup_file(&path, 7).with_context(|| { + format!("failed to back up Kimi Code config {}", 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 Kimi 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(); + + // Read models first so we can determine which provider the default_model + // belongs to. Only that provider should be marked active in the UI. + 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 = 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, + supports_1m, + capabilities, + raw_other, + }, + ); + } + } + + let active_provider_name = default_model + .as_ref() + .and_then(|alias| models.get(alias)) + .map(|m| m.provider.as_str()); + + 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 env: IndexMap = table + .get("env") + .and_then(|v| v.as_table()) + .map(|t| { + t.iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect() + }) + .unwrap_or_default(); + + let icon = table + .get("icon") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let icon_color = table + .get("icon_color") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + 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("env"); + // NOTE: `oauth` is intentionally kept in raw_other so that the + // exact `storage`/`key` block round-trips verbatim on export. + // Previously it was stripped here and regenerated on export + // with a key derived from the provider name, which corrupted + // the managed:kimi-code credential reference (e.g. + // "oauth/kimi-code" became "oauth/managed-kimi-code"). + 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, + note: None, + official_url: None, + managed, + enabled, + active: active_provider_name == Some(name.as_str()), + icon, + icon_color, + raw_other, + usage_kinds: None, + }, + ); + } + } + + 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 Kimi 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 { + // Write ALL providers to Kimi Code config. The active provider is + // selected via `default_model`, so keeping the full list matches the + // CLI's native multi-provider behavior and prevents data loss when + // switching. + 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 let Some(icon) = provider.icon.clone().filter(|s| !s.is_empty()) { + pt.insert("icon".to_string(), TomlValue::String(icon)); + } + if let Some(icon_color) = provider.icon_color.clone().filter(|s| !s.is_empty()) { + pt.insert("icon_color".to_string(), TomlValue::String(icon_color)); + } + if !provider.env.is_empty() { + let mut env_table = Table::new(); + for (k, v) in &provider.env { + env_table.insert(k.clone(), TomlValue::String(v.clone())); + } + pt.insert("env".to_string(), TomlValue::Table(env_table)); + } + 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 and env are handled explicitly above). + if let TomlValue::Table(mut extra) = json_to_toml(&provider.raw_other).unwrap_or(TomlValue::Table(Table::new())) { + extra.remove("oauth"); + extra.remove("env"); + // Strip Kimi-Switch-private field: the remembered per-provider + // default model is stored in raw_other.default_model and must NOT + // leak into the agent's config.toml. + extra.remove("default_model"); + 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)); + + // Write all models — each provider's models are kept so the CLI's + // /provider command can list and switch between them. + 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 capabilities = model.capabilities.clone(); + 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())); + // supports_1m reflects context size only, not thinking capability. + 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, + active: true, + icon: None, + icon_color: None, + raw_other: Value::Null, + usage_kinds: None, + }, + ); + 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, + 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"))); + } + + #[test] + fn kimi_code_export_does_not_inject_thinking_from_supports_1m() { + let mut providers = IndexMap::new(); + providers.insert( + "test-provider".to_string(), + Provider { + name: "test-provider".to_string(), + provider_type: ProviderType::Anthropic, + base_url: Some("https://example.com".to_string()), + api_key: Some("sk-test".to_string()), + env: IndexMap::new(), + note: None, + official_url: None, + managed: false, + enabled: true, + active: true, + icon: None, + icon_color: None, + raw_other: Value::Null, + usage_kinds: None, + }, + ); + let mut models = IndexMap::new(); + models.insert( + "big-model".to_string(), + Model { + alias: "big-model".to_string(), + provider: "test-provider".to_string(), + model: "big-model".to_string(), + max_context_size: 2_000_000, + display_name: None, + supports_1m: true, + capabilities: vec![], + raw_other: Value::Null, + }, + ); + let config = Config { + default_model: Some("big-model".to_string()), + providers, + models, + raw_other: Value::Null, + }; + + let exported = config_to_kimi_code(&config, None); + let root = exported.as_table().unwrap(); + let models_table = root.get("models").unwrap().as_table().unwrap(); + let model = models_table.get("big-model").unwrap().as_table().unwrap(); + // supports_1m=true but capabilities empty → must NOT inject "thinking". + let caps = model.get("capabilities"); + match caps { + None => {} + Some(v) => { + let arr = v.as_array().unwrap(); + assert!( + !arr.iter().any(|c| c.as_str() == Some("thinking")), + "thinking should not be injected from supports_1m" + ); + } + } + } + + #[test] + fn kimi_code_oauth_key_roundtrip() { + // Regression: importing then exporting managed:kimi-code must preserve + // the exact oauth key "oauth/kimi-code". Previously the oauth block + // was dropped on import and regenerated on export as + // "oauth/managed-kimi-code", breaking the official subscription. + let toml_str = r#" +default_model = "kimi-code/k3" + +[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/k3"] +provider = "managed:kimi-code" +model = "k3" +max_context_size = 1048576 +"#; + let value: TomlValue = toml_str.parse().unwrap(); + let config = kimi_code_to_config(&value); + + // Export back to TOML. + let exported = config_to_kimi_code(&config, None); + let root = exported.as_table().unwrap(); + let providers = root.get("providers").unwrap().as_table().unwrap(); + let managed = providers.get("managed:kimi-code").unwrap().as_table().unwrap(); + let oauth = managed.get("oauth").unwrap().as_table().unwrap(); + assert_eq!( + oauth.get("key").and_then(|v| v.as_str()), + Some("oauth/kimi-code"), + "oauth key must round-trip verbatim, not be regenerated" + ); + assert_eq!( + oauth.get("storage").and_then(|v| v.as_str()), + Some("file") + ); + } + + #[test] + fn kimi_code_export_writes_all_providers() { + // Regression: inactive providers must still be written to config.toml + // so that switching does not wipe the provider list. + let mut providers = IndexMap::new(); + providers.insert( + "active-one".to_string(), + Provider { + name: "active-one".to_string(), + provider_type: ProviderType::Anthropic, + base_url: Some("https://a.example.com".to_string()), + api_key: Some("sk-a".to_string()), + env: IndexMap::new(), + note: None, + official_url: None, + managed: false, + enabled: true, + active: true, + icon: None, + icon_color: None, + raw_other: Value::Null, + usage_kinds: None, + }, + ); + providers.insert( + "inactive-one".to_string(), + Provider { + name: "inactive-one".to_string(), + provider_type: ProviderType::Openai, + base_url: Some("https://b.example.com".to_string()), + api_key: Some("sk-b".to_string()), + env: IndexMap::new(), + note: None, + official_url: None, + managed: false, + enabled: true, + active: false, + icon: None, + icon_color: None, + raw_other: Value::Null, + usage_kinds: None, + }, + ); + let config = Config { + default_model: None, + providers, + models: IndexMap::new(), + raw_other: Value::Null, + }; + + let exported = config_to_kimi_code(&config, None); + let root = exported.as_table().unwrap(); + let providers_table = root.get("providers").unwrap().as_table().unwrap(); + assert_eq!(providers_table.len(), 2, "both providers must be written"); + assert!(providers_table.contains_key("active-one")); + assert!(providers_table.contains_key("inactive-one")); + } + + #[test] + fn kimi_code_export_strips_private_default_model() { + // The remembered per-provider default model (raw_other.default_model) + // is a Kimi-Switch-private field and must NOT leak into config.toml. + let mut providers = IndexMap::new(); + providers.insert( + "p".to_string(), + Provider { + name: "p".to_string(), + provider_type: ProviderType::Anthropic, + base_url: None, + api_key: Some("sk-x".to_string()), + env: IndexMap::new(), + note: None, + official_url: None, + managed: false, + enabled: true, + active: true, + icon: None, + icon_color: None, + raw_other: serde_json::json!({"default_model": "some-alias"}), + usage_kinds: None, + }, + ); + let config = Config { + default_model: None, + providers, + models: IndexMap::new(), + raw_other: Value::Null, + }; + + let exported = config_to_kimi_code(&config, None); + let root = exported.as_table().unwrap(); + let provider = root + .get("providers").unwrap() + .as_table().unwrap() + .get("p").unwrap() + .as_table().unwrap(); + assert!( + !provider.contains_key("default_model"), + "private default_model must not leak into config.toml" + ); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs new file mode 100644 index 0000000..7149c0e --- /dev/null +++ b/src-tauri/src/lib.rs @@ -0,0 +1,119 @@ +pub mod commands; +pub mod config_io; +pub mod dashboard; +pub mod db; +pub mod kimi_code_io; +pub mod models; +pub mod pi_io; +pub mod services; + +use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; +use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder}; +use tauri::Manager; + +pub fn run() { + tauri::Builder::default() + // Single instance: must be registered before other plugins. When a + // second process is launched, this callback runs in the existing + // instance and simply brings its window back (including from tray). + .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + if let Some(window) = app.get_webview_window("main") { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + } + })) + .plugin(tauri_plugin_opener::init()) + .setup(|app| { + println!("[Tauri] Setup started"); + let window = app.get_webview_window("main").unwrap(); + println!("[Tauri] Window label: {}", window.label()); + + // Tray menu: show / separator / quit + let show_i = MenuItem::with_id(app, "show", "显示", true, None::<&str>)?; + let quit_i = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?; + let menu = Menu::with_items( + app, + &[&show_i, &PredefinedMenuItem::separator(app)?, &quit_i], + )?; + + // Tray icon: reuse the window icon + let icon = app.default_window_icon().unwrap().clone(); + TrayIconBuilder::new() + .icon(icon) + .menu(&menu) + .show_menu_on_left_click(false) + .on_menu_event(|app, event| match event.id.as_ref() { + "show" => { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + } + } + "quit" => { + app.exit(0); + } + _ => {} + }) + .on_tray_icon_event(|tray, event| { + if let tauri::tray::TrayIconEvent::Click { + button, + button_state, + .. + } = event + { + if button == MouseButton::Left && button_state == MouseButtonState::Up { + let app = tray.app_handle(); + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + } + } + } + }) + .build(app)?; + + // Close button -> hide to tray instead of quitting. Minimize keeps + // the taskbar button (native behaviour); only an explicit close + // (window X) retreats to the tray. + let window_clone = window.clone(); + window.on_window_event(move |event| match event { + tauri::WindowEvent::CloseRequested { api, .. } => { + api.prevent_close(); + let _ = window_clone.hide(); + } + _ => {} + }); + + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + commands::load_agent_config_command, + commands::save_agent_config_command, + commands::activate_agent_config_command, + commands::open_agent_config_dir, + commands::get_app_version, + commands::list_provider_models, + commands::test_connectivity, + commands::query_provider_usage, + commands::debug_log, + commands::get_app_setting, + commands::set_app_setting, + commands::check_for_update, + commands::download_update, + commands::open_installer, + dashboard::get_paths, + dashboard::get_prices, + dashboard::get_summary, + dashboard::list_sessions, + dashboard::archive_session, + dashboard::unarchive_session, + dashboard::delete_session, + dashboard::delete_workspace, + dashboard::get_session_preview, + ]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 0000000..f3af8e8 --- /dev/null +++ b/src-tauri/src/main.rs @@ -0,0 +1,5 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + kimiswitch_lib::run(); +} diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs new file mode 100644 index 0000000..a910a9c --- /dev/null +++ b/src-tauri/src/models.rs @@ -0,0 +1,235 @@ +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// 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 { + Anthropic, + Openai, + #[serde(rename = "openai_responses")] + OpenaiResponses, + #[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::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)] +pub struct Provider { + pub name: String, + pub provider_type: ProviderType, + pub base_url: Option, + pub api_key: Option, + #[serde(default)] + pub env: IndexMap, + #[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)] + pub active: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon_color: Option, + #[serde(default, skip_serializing_if = "Value::is_null")] + pub raw_other: Value, + /// Billing/usage query kinds for this provider (e.g. "balance:deepseek", + /// "plan:kimi_coding"). Persisted in the SQLite settings table under + /// `usage_kinds:`, NOT in the agent's config.toml — both + /// export paths (kimi_code_io manual TOML, pi_io PiProvider struct) are + /// explicit and never serialize this field, while the IPC payload to the + /// frontend does carry it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usage_kinds: Option>, +} + +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.active == other.active + && self.icon == other.icon + && self.icon_color == other.icon_color + && self.raw_other == other.raw_other + && self.usage_kinds == other.usage_kinds + } +} + +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") + .field("name", &self.name) + .field("provider_type", &self.provider_type) + .field("base_url", &self.base_url) + .field("api_key", &"") + .field("env", &"") + .field("managed", &self.managed) + .field("enabled", &self.enabled) + .field("active", &self.active) + .field("icon", &self.icon) + .field("icon_color", &self.icon_color) + .field("raw_other", &"") + .finish() + } +} + +#[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(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("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.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(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.keys().collect::>(), + self.providers.len() + ), + ) + .field("models", &self.models) + .field("raw_other", &"") + .finish() + } +} + +/// 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..2f75f85 --- /dev/null +++ b/src-tauri/src/pi_io.rs @@ -0,0 +1,626 @@ +//! 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 Kimi 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::config_io::backup_file; +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 { + std::env::var_os("PI_CODING_AGENT_DIR") + .map(PathBuf::from) + .or_else(|| dirs::home_dir().map(|h| h.join(".pi").join("agent"))) + .expect("failed to resolve Pi agent config 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() { + backup_file(&path, 7).with_context(|| { + format!("failed to back up Pi models {}", 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(()) +} + +/// Returns the path to Pi's `settings.json` file. +pub fn pi_settings_path() -> PathBuf { + pi_agent_dir().join("settings.json") +} + +/// Pi's `settings.json` file (only the fields Kimi Switch manipulates). +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PiSettingsFile { + #[serde(rename = "defaultProvider", default, skip_serializing_if = "Option::is_none")] + pub default_provider: Option, + #[serde(rename = "defaultModel", default, skip_serializing_if = "Option::is_none")] + pub default_model: Option, + #[serde(flatten)] + pub extra: Value, +} + +/// Read the existing Pi `settings.json` file, or return a default empty struct +/// if the file does not exist yet. +pub fn load_pi_settings() -> PiResult { + let path = pi_settings_path(); + if !path.exists() { + return Ok(PiSettingsFile::default()); + } + let content = std::fs::read_to_string(&path) + .with_context(|| format!("failed to read {}", path.display()))?; + let file: PiSettingsFile = serde_json::from_str(&content) + .with_context(|| format!("failed to parse {}", path.display()))?; + Ok(file) +} + +/// Write the given Pi settings file, creating the parent directory if needed. +/// A backup of the previous file is created when it already exists. +pub fn save_pi_settings(file: &PiSettingsFile) -> PiResult<()> { + let path = pi_settings_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() { + backup_file(&path, 7).with_context(|| { + format!("failed to back up Pi settings {}", path.display()) + })?; + } + + let content = serde_json::to_string_pretty(file) + .with_context(|| "failed to serialize Pi settings.json")?; + std::fs::write(&path, content) + .with_context(|| format!("failed to write {}", path.display()))?; + Ok(()) +} + +/// Convert a Kimi 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 Kimi 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, + } +} + +/// Merge provider-level fields that have dedicated `PiProvider` fields into the +/// `raw_other` blob so they survive the round-trip through Kimi Switch's internal +/// `Config`. +fn merge_provider_known_into_raw(pi_provider: &PiProvider, raw: &mut Value) { + let has_known = pi_provider.headers.is_some() + || pi_provider.compat.is_some() + || pi_provider.api.is_some(); + if !has_known { + return; + } + + if raw.is_null() { + *raw = Value::Object(serde_json::Map::new()); + } + if let Some(obj) = raw.as_object_mut() { + if let Some(headers) = &pi_provider.headers { + obj.insert("headers".to_string(), headers.clone()); + } + if let Some(compat) = &pi_provider.compat { + obj.insert("compat".to_string(), compat.clone()); + } + if let Some(api) = &pi_provider.api { + obj.insert("api".to_string(), Value::String(api.clone())); + } + } +} + +/// Merge model-level fields that have dedicated `PiModel` fields into the +/// `raw_other` blob so they survive the round-trip through Kimi Switch's internal +/// `Config`. +fn merge_model_known_into_raw(pi_model: &PiModel, raw: &mut Value) { + let has_known = pi_model.cost.is_some() + || pi_model.compat.is_some() + || pi_model.max_tokens.is_some(); + if !has_known { + return; + } + + if raw.is_null() { + *raw = Value::Object(serde_json::Map::new()); + } + if let Some(obj) = raw.as_object_mut() { + if let Some(cost) = &pi_model.cost { + if let Ok(value) = serde_json::to_value(cost) { + obj.insert("cost".to_string(), value); + } + } + if let Some(compat) = &pi_model.compat { + obj.insert("compat".to_string(), compat.clone()); + } + if let Some(max_tokens) = pi_model.max_tokens { + obj.insert( + "maxTokens".to_string(), + Value::Number(serde_json::Number::from(max_tokens)), + ); + } + } +} + +/// Extract provider-level fields that PiProvider serializes explicitly from the +/// `raw_other` blob. Any extracted keys are removed from the returned extra so +/// they are not duplicated by `#[serde(flatten)]`. +fn extract_provider_fields(raw: &Value) -> (Option, Option, Option, Value) { + let mut extra = raw.clone(); + let api = extra + .as_object_mut() + .and_then(|o| o.remove("api")) + .and_then(|v| v.as_str().map(String::from)); + let headers = extra.as_object_mut().and_then(|o| o.remove("headers")); + let compat = extra.as_object_mut().and_then(|o| o.remove("compat")); + (api, headers, compat, extra) +} + +/// Extract model-level fields that PiModel serializes explicitly from the +/// `raw_other` blob. Any extracted keys are removed from the returned extra. +fn extract_model_fields(raw: &Value) -> (Option, Option, Option, Value) { + let mut extra = raw.clone(); + let cost = extra + .as_object_mut() + .and_then(|o| o.remove("cost")) + .and_then(|v| serde_json::from_value(v).ok()); + let compat = extra.as_object_mut().and_then(|o| o.remove("compat")); + let max_tokens = extra + .as_object_mut() + .and_then(|o| o.remove("maxTokens")) + .and_then(|v| v.as_u64()); + (cost, compat, max_tokens, extra) +} + +/// Import a Pi `models.json` into a Kimi 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 mut provider_raw = pi_provider.extra.clone(); + merge_provider_known_into_raw(pi_provider, &mut provider_raw); + + 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, + active: true, + icon: None, + icon_color: None, + raw_other: provider_raw, + usage_kinds: None, + }; + + 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()); + + let mut model_raw = pi_model.extra.clone(); + merge_model_known_into_raw(pi_model, &mut model_raw); + + models.insert( + alias.clone(), + Model { + alias, + provider: name.clone(), + model: pi_model.id.clone(), + max_context_size: pi_model.context_window, + display_name, + supports_1m: pi_model.reasoning || pi_model.context_window >= 1_000_000, + capabilities: vec![], + raw_other: model_raw, + }, + ); + } + + providers.insert(name.clone(), provider); + } + + Config { + default_model: file.default_model.clone(), + providers, + models, + raw_other: file.extra.clone(), + } +} + +/// Export a Kimi 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 (provider_api, provider_headers, provider_compat, provider_extra) = + extract_provider_fields(&provider.raw_other); + let api = provider_api.unwrap_or_else(|| pi_api_for_provider(&provider.provider_type).to_string()); + + 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); + let (model_cost, model_compat, model_max_tokens, model_extra) = + extract_model_fields(&model.raw_other); + + 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: model_max_tokens, + cost: model_cost, + compat: model_compat, + extra: model_extra, + }); + } + + providers.insert( + name.clone(), + PiProvider { + base_url: provider.base_url.clone().filter(|s| !s.is_empty()), + api: Some(api), + api_key: provider.api_key.clone().filter(|s| !s.is_empty()), + headers: provider_headers, + compat: provider_compat, + enabled: provider.enabled, + models: pi_models, + extra: provider_extra, + }, + ); + } + + PiModelsFile { + // Pi does not use a top-level default_model field; defaults live in + // ~/.pi/agent/settings.json (defaultProvider / defaultModel). + default_model: None, + 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); + } + + #[test] + fn pi_roundtrip_preserves_advanced_fields() { + let mut providers = IndexMap::new(); + providers.insert( + "my-proxy".to_string(), + PiProvider { + base_url: Some("https://proxy.example.com/v1".to_string()), + api: Some("anthropic-messages".to_string()), + api_key: Some("sk-test".to_string()), + headers: Some(serde_json::json!({"X-Custom": "value"})), + compat: Some(serde_json::json!({"supportsDeveloperRole": false})), + enabled: true, + models: vec![PiModel { + id: "custom-claude".to_string(), + name: Some("Custom Claude".to_string()), + reasoning: true, + input: vec!["text".to_string(), "image".to_string()], + context_window: 200_000, + max_tokens: Some(4096), + cost: Some(PiCost { + input: 1.0, + output: 2.0, + cache_read: 0.5, + cache_write: 0.75, + }), + compat: Some(serde_json::json!({"forceAdaptiveThinking": true})), + extra: serde_json::json!({ + "thinkingLevelMap": { + "off": null, + "minimal": null, + "low": "low", + "medium": "medium", + "high": "high", + "xhigh": "max" + }, + "headers": {"X-Model-Header": "model-value"}, + "api": "anthropic-messages" + }), + }], + extra: serde_json::json!({ + "name": "My Proxy", + "authHeader": true, + "modelOverrides": { + "claude-sonnet-4": {"name": "Overridden"} + } + }), + }, + ); + let file = PiModelsFile { + default_model: None, + providers, + extra: Value::Null, + }; + + let config = pi_file_to_config(&file); + let exported = config_to_pi_file(&config); + let provider = exported.providers.get("my-proxy").unwrap(); + + // Provider-level fields + assert_eq!( + provider.extra.get("name").and_then(|v| v.as_str()), + Some("My Proxy") + ); + assert_eq!( + provider.extra.get("authHeader").and_then(|v| v.as_bool()), + Some(true) + ); + assert_eq!( + provider + .extra + .get("modelOverrides") + .and_then(|v| v.as_object()) + .and_then(|o| o.get("claude-sonnet-4")) + .and_then(|v| v.get("name")), + Some(&serde_json::json!("Overridden")) + ); + assert_eq!( + provider.headers.as_ref().and_then(|h| h.get("X-Custom")), + Some(&serde_json::json!("value")) + ); + assert_eq!( + provider.compat.as_ref().and_then(|c| c.get("supportsDeveloperRole")), + Some(&serde_json::json!(false)) + ); + + // Model-level fields + let model = &provider.models[0]; + assert_eq!(model.id, "custom-claude"); + assert_eq!(model.max_tokens, Some(4096)); + assert_eq!(model.cost.as_ref().map(|c| c.output), Some(2.0)); + assert_eq!( + model.compat.as_ref().and_then(|c| c.get("forceAdaptiveThinking")), + Some(&serde_json::json!(true)) + ); + assert_eq!( + model.extra.get("thinkingLevelMap").and_then(|v| v.get("xhigh")), + Some(&serde_json::json!("max")) + ); + assert_eq!( + model.extra.get("headers").and_then(|v| v.get("X-Model-Header")), + Some(&serde_json::json!("model-value")) + ); + } + + #[test] + fn pi_settings_roundtrip() { + let file = PiSettingsFile { + default_provider: Some("my-proxy".to_string()), + default_model: Some("glm-5.2".to_string()), + extra: serde_json::json!({"theme": "dark"}), + }; + let serialized = serde_json::to_string_pretty(&file).unwrap(); + assert!(serialized.contains("defaultProvider")); + assert!(serialized.contains("defaultModel")); + + let deserialized: PiSettingsFile = serde_json::from_str(&serialized).unwrap(); + assert_eq!(deserialized.default_provider, file.default_provider); + assert_eq!(deserialized.default_model, file.default_model); + assert_eq!(deserialized.extra.get("theme"), Some(&serde_json::json!("dark"))); + } +} diff --git a/src-tauri/src/profile_manager.rs b/src-tauri/src/profile_manager.rs new file mode 100644 index 0000000..236d135 --- /dev/null +++ b/src-tauri/src/profile_manager.rs @@ -0,0 +1,2 @@ +//! Profile management for KimiSwitch. +//! Loads, saves, and switches between configuration profiles. diff --git a/src-tauri/src/services/balance.rs b/src-tauri/src/services/balance.rs new file mode 100644 index 0000000..61b3c2e --- /dev/null +++ b/src-tauri/src/services/balance.rs @@ -0,0 +1,326 @@ +// Adapted from cc-switch (MIT, © Jason Young) +// https://github.com/farion1231/cc-switch + +//! 供应商余额查询服务 +//! +//! 支持 DeepSeek、StepFun、SiliconFlow、OpenRouter、Novita AI 的账户余额查询。 +//! +//! 错误通道语义(与 cc-switch 一致): +//! - `Err(String)` = 瞬时传输失败(网络不可达/超时/读体中断)。前端 invoke reject, +//! 触发 retry 并保留上一次成功的 data(keep-last-good)。 +//! - `Ok(success:false)` = 确定性失败(空 key/鉴权失败/非 2xx/响应体非法 JSON), +//! 直接透出错误文案。 +//! +//! HTTP 调用与 JSON→UsageData 解析拆成纯函数,便于无 mock 单元测试。 + +use super::usage_types::{UsageData, UsageResult}; +use std::time::Duration; + +const REQUEST_TIMEOUT: Duration = Duration::from_secs(8); + +/// 鉴权头形式:绝大多数供应商用 `Bearer `;智谱套餐接口不加前缀(见 coding_plan)。 +pub(crate) enum AuthStyle { + Bearer, + Raw, +} + +/// GET 请求的归类结果。 +pub(crate) enum Fetched { + /// 2xx 且响应体是合法 JSON。 + Body(serde_json::Value), + /// 确定性失败(401/403/非 2xx/解析失败),调用方原样包成 Ok 返回。 + Failed(UsageResult), +} + +/// 统一的 GET + JSON 读取助手。瞬时失败(网络/超时/读体中断)返回 `Err`; +/// 确定性失败收进 `Fetched::Failed`。 +/// +/// 先 `bytes()` 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体 +/// 后解析失败才是确定性。reqwest 的 `.json()` 把读体错误也包成 decode,无法区分。 +pub(crate) async fn get_json( + url: &str, + api_key: &str, + auth: AuthStyle, +) -> Result { + let client = reqwest::Client::builder() + .timeout(REQUEST_TIMEOUT) + .build() + .map_err(|e| format!("Failed to build HTTP client: {e}"))?; + + // 注意:api_key 只允许进请求头,严禁拼进 URL / 日志 / 错误信息。 + let req = client.get(url).header("Accept", "application/json"); + let req = match auth { + AuthStyle::Bearer => req.header("Authorization", format!("Bearer {api_key}")), + AuthStyle::Raw => req.header("Authorization", api_key), + }; + + let resp = match req.send().await { + Ok(r) => r, + Err(e) => return Err(format!("Network error: {e}")), + }; + + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return Ok(Fetched::Failed(UsageResult::failure(format!( + "Authentication failed (HTTP {status})" + )))); + } + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return Ok(Fetched::Failed(UsageResult::failure(format!( + "API error (HTTP {status}): {body}" + )))); + } + + let raw = match resp.bytes().await { + Ok(b) => b, + Err(e) => return Err(format!("Failed to read response: {e}")), + }; + match serde_json::from_slice(&raw) { + Ok(v) => Ok(Fetched::Body(v)), + Err(e) => Ok(Fetched::Failed(UsageResult::failure(format!( + "Failed to parse response: {e}" + )))), + } +} + +/// 解析 JSON 字段为 f64,兼容数字和字符串格式。 +pub(crate) fn parse_f64_field(obj: &serde_json::Value, field: &str) -> Option { + obj.get(field).and_then(|v| { + v.as_f64() + .or_else(|| v.as_str().and_then(|s| s.parse().ok())) + }) +} + +// ── DeepSeek ──────────────────────────────────────────────── +// GET https://api.deepseek.com/user/balance +// Response: { balance_infos: [{ currency, total_balance, ... }], is_available } + +pub async fn query_deepseek(api_key: &str) -> Result { + match get_json("https://api.deepseek.com/user/balance", api_key, AuthStyle::Bearer).await? { + Fetched::Body(body) => Ok(UsageResult::ok(parse_deepseek(&body))), + Fetched::Failed(err) => Ok(err), + } +} + +fn parse_deepseek(body: &serde_json::Value) -> Vec { + let is_available = body + .get("is_available") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + let mut data = Vec::new(); + + if let Some(infos) = body.get("balance_infos").and_then(|v| v.as_array()) { + for info in infos { + let currency = info + .get("currency") + .and_then(|v| v.as_str()) + .unwrap_or("CNY"); + data.push(UsageData { + plan_name: Some(currency.to_string()), + remaining: parse_f64_field(info, "total_balance"), + is_valid: Some(is_available), + unit: Some(currency.to_string()), + ..Default::default() + }); + } + } + data +} + +// ── StepFun ───────────────────────────────────────────────── +// GET https://api.stepfun.com/v1/accounts +// Response: { object, type, balance, total_cash_balance, total_voucher_balance } + +pub async fn query_stepfun(api_key: &str) -> Result { + match get_json("https://api.stepfun.com/v1/accounts", api_key, AuthStyle::Bearer).await? { + Fetched::Body(body) => Ok(UsageResult::ok(parse_stepfun(&body))), + Fetched::Failed(err) => Ok(err), + } +} + +fn parse_stepfun(body: &serde_json::Value) -> Vec { + vec![UsageData { + plan_name: Some("StepFun".to_string()), + remaining: Some(parse_f64_field(body, "balance").unwrap_or(0.0)), + unit: Some("CNY".to_string()), + is_valid: Some(true), + ..Default::default() + }] +} + +// ── SiliconFlow ───────────────────────────────────────────── +// GET https://api.siliconflow.cn/v1/user/info (.cn;海外站 .com 单位 USD) +// Response: { code, data: { balance, chargeBalance, totalBalance, status } } + +pub async fn query_siliconflow(api_key: &str, is_cn: bool) -> Result { + let domain = if is_cn { + "api.siliconflow.cn" + } else { + "api.siliconflow.com" + }; + let url = format!("https://{domain}/v1/user/info"); + match get_json(&url, api_key, AuthStyle::Bearer).await? { + Fetched::Body(body) => Ok(match parse_siliconflow(&body, is_cn) { + Ok(data) => UsageResult::ok(data), + Err(err) => err, + }), + Fetched::Failed(err) => Ok(err), + } +} + +fn parse_siliconflow(body: &serde_json::Value, is_cn: bool) -> Result, UsageResult> { + let data = match body.get("data") { + Some(d) => d, + None => { + return Err(UsageResult::failure( + "Missing 'data' field in response".to_string(), + )) + } + }; + let (plan_name, unit) = if is_cn { + ("SiliconFlow", "CNY") + } else { + ("SiliconFlow (EN)", "USD") + }; + Ok(vec![UsageData { + plan_name: Some(plan_name.to_string()), + remaining: Some(parse_f64_field(data, "totalBalance").unwrap_or(0.0)), + unit: Some(unit.to_string()), + is_valid: Some(true), + ..Default::default() + }]) +} + +// ── OpenRouter ────────────────────────────────────────────── +// GET https://openrouter.ai/api/v1/credits +// Response: { data: { total_credits, total_usage } } + +pub async fn query_openrouter(api_key: &str) -> Result { + match get_json( + "https://openrouter.ai/api/v1/credits", + api_key, + AuthStyle::Bearer, + ) + .await? + { + Fetched::Body(body) => Ok(UsageResult::ok(parse_openrouter(&body))), + Fetched::Failed(err) => Ok(err), + } +} + +fn parse_openrouter(body: &serde_json::Value) -> Vec { + let data = body.get("data").unwrap_or(body); + let total_credits = parse_f64_field(data, "total_credits").unwrap_or(0.0); + let total_usage = parse_f64_field(data, "total_usage").unwrap_or(0.0); + let remaining = total_credits - total_usage; + + vec![UsageData { + plan_name: Some("OpenRouter".to_string()), + remaining: Some(remaining), + total: Some(total_credits), + used: Some(total_usage), + unit: Some("USD".to_string()), + is_valid: Some(remaining > 0.0), + ..Default::default() + }] +} + +// ── Novita AI ─────────────────────────────────────────────── +// GET https://api.novita.ai/v3/user/balance +// Response: { availableBalance, ... };金额单位 0.0001 USD,需 /10000。 + +pub async fn query_novita(api_key: &str) -> Result { + match get_json( + "https://api.novita.ai/v3/user/balance", + api_key, + AuthStyle::Bearer, + ) + .await? + { + Fetched::Body(body) => Ok(UsageResult::ok(parse_novita(&body))), + Fetched::Failed(err) => Ok(err), + } +} + +fn parse_novita(body: &serde_json::Value) -> Vec { + let available = parse_f64_field(body, "availableBalance").unwrap_or(0.0) / 10000.0; + vec![UsageData { + plan_name: Some("Novita AI".to_string()), + remaining: Some(available), + unit: Some("USD".to_string()), + is_valid: Some(available > 0.0), + ..Default::default() + }] +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn deepseek_maps_balance_infos() { + let body = json!({ + "is_available": true, + "balance_infos": [ + { "currency": "CNY", "total_balance": "12.34" }, + { "currency": "USD", "total_balance": 5.0 } + ] + }); + let data = parse_deepseek(&body); + assert_eq!(data.len(), 2); + assert_eq!(data[0].plan_name.as_deref(), Some("CNY")); + assert_eq!(data[0].remaining, Some(12.34)); + assert_eq!(data[0].unit.as_deref(), Some("CNY")); + assert_eq!(data[0].is_valid, Some(true)); + assert_eq!(data[1].remaining, Some(5.0)); + } + + #[test] + fn stepfun_reads_balance() { + let body = json!({ "balance": 88.5 }); + let data = parse_stepfun(&body); + assert_eq!(data[0].remaining, Some(88.5)); + assert_eq!(data[0].unit.as_deref(), Some("CNY")); + } + + #[test] + fn siliconflow_missing_data_is_deterministic_failure() { + let body = json!({ "code": 500 }); + let err = parse_siliconflow(&body, true).unwrap_err(); + assert!(!err.success); + assert!(err.error.unwrap().contains("Missing 'data'")); + } + + #[test] + fn siliconflow_cn_and_en_units() { + let body = json!({ "data": { "totalBalance": "42.0" } }); + let cn = parse_siliconflow(&body, true).unwrap(); + let en = parse_siliconflow(&body, false).unwrap(); + assert_eq!(cn[0].unit.as_deref(), Some("CNY")); + assert_eq!(en[0].unit.as_deref(), Some("USD")); + assert_eq!(cn[0].remaining, Some(42.0)); + } + + #[test] + fn openrouter_computes_remaining_credits() { + let body = json!({ "data": { "total_credits": 20.0, "total_usage": 7.5 } }); + let data = parse_openrouter(&body); + assert_eq!(data[0].remaining, Some(12.5)); + assert_eq!(data[0].total, Some(20.0)); + assert_eq!(data[0].used, Some(7.5)); + assert_eq!(data[0].is_valid, Some(true)); + } + + #[test] + fn novita_converts_ten_thousandth_usd() { + let body = json!({ "availableBalance": 123400 }); + let data = parse_novita(&body); + assert_eq!(data[0].remaining, Some(12.34)); + assert_eq!(data[0].unit.as_deref(), Some("USD")); + + let zero = parse_novita(&json!({ "availableBalance": 0 })); + assert_eq!(zero[0].is_valid, Some(false)); + } +} diff --git a/src-tauri/src/services/coding_plan.rs b/src-tauri/src/services/coding_plan.rs new file mode 100644 index 0000000..67f6418 --- /dev/null +++ b/src-tauri/src/services/coding_plan.rs @@ -0,0 +1,414 @@ +// Adapted from cc-switch (MIT, © Jason Young) +// https://github.com/farion1231/cc-switch + +//! Token Plan 套餐额度查询服务 +//! +//! 支持 Kimi For Coding、智谱 GLM、MiniMax 的套餐额度查询。 +//! cc-switch 的 SubscriptionQuota/tiers 结构在此展平为 `Vec`: +//! 每个窗口(tier)一条 UsageData,`plan_name` = tier 名("five_hour" / +//! "weekly_limit"),`used` = 已用百分比(0-100),`total` = 100, +//! `remaining` = 剩余百分比,`resets_at` 为 ISO 8601 字符串。 +//! +//! 错误通道语义与 balance.rs 一致(Err = 瞬时,Ok(success:false) = 确定性)。 + +use super::balance::{get_json, AuthStyle, Fetched}; +use super::usage_types::{UsageData, UsageResult}; + +const TIER_FIVE_HOUR: &str = "five_hour"; +const TIER_WEEKLY_LIMIT: &str = "weekly_limit"; + +/// 套餐条目的统一构造:按百分比表示用量。 +fn percent_tier(name: &str, used_percent: f64, resets_at: Option) -> UsageData { + UsageData { + plan_name: Some(name.to_string()), + remaining: Some((100.0 - used_percent).max(0.0)), + total: Some(100.0), + used: Some(used_percent), + unit: Some("%".to_string()), + is_valid: Some(true), + resets_at, + } +} + +fn millis_to_iso8601(ms: i64) -> Option { + let secs = ms / 1000; + let nsecs = ((ms % 1000) * 1_000_000) as u32; + chrono::DateTime::from_timestamp(secs, nsecs).map(|dt| dt.to_rfc3339()) +} + +/// 从 JSON 值提取重置时间,兼容字符串和数字格式: +/// - 字符串:直接返回(视为 ISO 8601) +/// - 数字:自动判断秒/毫秒并转为 ISO 8601;0/负值视为无重置时间 +fn extract_reset_time(value: &serde_json::Value) -> Option { + if let Some(s) = value.as_str() { + return Some(s.to_string()); + } + if let Some(n) = value.as_i64() { + if n <= 0 { + return None; + } + // 秒级时间戳 < 1e12,毫秒 >= 1e12 + let ms = if n < 1_000_000_000_000 { n * 1000 } else { n }; + return millis_to_iso8601(ms); + } + None +} + +/// 解析 JSON 值为 f64,兼容数字和字符串格式(如 `100` 和 `"100"`) +fn parse_f64(value: &serde_json::Value) -> Option { + value + .as_f64() + .or_else(|| value.as_str().and_then(|s| s.parse().ok())) +} + +// ── Kimi For Coding ───────────────────────────────────────── +// GET https://api.kimi.com/coding/v1/usages +// Response: { limits: [{ detail: { limit, remaining, resetTime } }], +// usage: { limit, remaining, resetTime } } + +pub async fn query_kimi_coding(api_key: &str) -> Result { + match get_json( + "https://api.kimi.com/coding/v1/usages", + api_key, + AuthStyle::Bearer, + ) + .await? + { + Fetched::Body(body) => Ok(UsageResult::ok(parse_kimi_coding(&body))), + Fetched::Failed(err) => Ok(err), + } +} + +fn parse_kimi_coding(body: &serde_json::Value) -> Vec { + let mut tiers = Vec::new(); + + // 5 小时窗口限额(优先显示) + if let Some(limits) = body.get("limits").and_then(|v| v.as_array()) { + for limit_item in limits { + if let Some(detail) = limit_item.get("detail") { + tiers.push(kimi_limit_tier(TIER_FIVE_HOUR, detail)); + } + } + } + + // 总体用量(周限额) + if let Some(usage) = body.get("usage") { + tiers.push(kimi_limit_tier(TIER_WEEKLY_LIMIT, usage)); + } + + tiers +} + +fn kimi_limit_tier(name: &str, detail: &serde_json::Value) -> UsageData { + let limit = detail.get("limit").and_then(parse_f64).unwrap_or(1.0); + let remaining = detail.get("remaining").and_then(parse_f64).unwrap_or(0.0); + let resets_at = detail.get("resetTime").and_then(extract_reset_time); + + let used = (limit - remaining).max(0.0); + let utilization = if limit > 0.0 { + (used / limit) * 100.0 + } else { + 0.0 + }; + percent_tier(name, utilization, resets_at) +} + +// ── 智谱 GLM ──────────────────────────────────────────────── +// GET {open.bigmodel.cn | api.z.ai}/api/monitor/usage/quota/limit +// 注意:智谱鉴权不加 Bearer 前缀(cc-switch 实测行为,照搬)。 + +/// 智谱 TOKENS_LIMIT 条目按 `unit` 字段的显式窗口分类。 +/// 实测:`unit: 3` → 5 小时滚动窗口;`unit: 6` → 每周窗口。 +/// 缺失或不识别时走重置时间启发式兜底。 +fn parse_zhipu(body: &serde_json::Value) -> Result, UsageResult> { + // 业务级别错误 + if body.get("success").and_then(|v| v.as_bool()) == Some(false) { + let msg = body + .get("msg") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown error"); + return Err(UsageResult::failure(format!("API error: {msg}"))); + } + + let data = match body.get("data") { + Some(d) => d, + None => { + return Err(UsageResult::failure( + "Missing 'data' field in response".to_string(), + )) + } + }; + + type Entry = (Option, f64, Option); + let mut five_hour: Option = None; + let mut weekly: Option = None; + let mut unclassified: Vec = Vec::new(); + + if let Some(limits) = data.get("limits").and_then(|v| v.as_array()) { + for limit_item in limits { + let limit_type = limit_item + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if !limit_type.eq_ignore_ascii_case("TOKENS_LIMIT") { + continue; + } + let percentage = limit_item + .get("percentage") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let reset_ms = limit_item.get("nextResetTime").and_then(|v| v.as_i64()); + let reset_iso = reset_ms.and_then(millis_to_iso8601); + let entry = (reset_ms, percentage, reset_iso); + match limit_item.get("unit").and_then(|v| v.as_i64()) { + Some(3) if five_hour.is_none() => five_hour = Some(entry), + Some(6) if weekly.is_none() => weekly = Some(entry), + _ => unclassified.push(entry), + } + } + } + + // 兜底:无 nextResetTime 的优先归 five_hour,其余按 reset 升序填空槽。 + unclassified.sort_by_key(|(reset, _, _)| (reset.is_some(), reset.unwrap_or(i64::MIN))); + for entry in unclassified { + if five_hour.is_none() { + five_hour = Some(entry); + } else if weekly.is_none() { + weekly = Some(entry); + } + } + + let mut tiers = Vec::new(); + for (name, slot) in [(TIER_FIVE_HOUR, five_hour), (TIER_WEEKLY_LIMIT, weekly)] { + if let Some((_, percentage, resets_at)) = slot { + tiers.push(percent_tier(name, percentage, resets_at)); + } + } + Ok(tiers) +} + +/// 额度接口与推理接口同 host:bigmodel.cn 与 z.ai 共用同一后端与 JSON shape。 +fn zhipu_quota_base(base_url: &str) -> &'static str { + if base_url.to_lowercase().contains("bigmodel.cn") { + "https://open.bigmodel.cn" + } else { + "https://api.z.ai" + } +} + +pub async fn query_zhipu(base_url: &str, api_key: &str) -> Result { + let url = format!( + "{}/api/monitor/usage/quota/limit", + zhipu_quota_base(base_url) + ); + match get_json(&url, api_key, AuthStyle::Raw).await? { + Fetched::Body(body) => Ok(match parse_zhipu(&body) { + Ok(data) => UsageResult::ok(data), + Err(err) => err, + }), + Fetched::Failed(err) => Ok(err), + } +} + +// ── MiniMax ───────────────────────────────────────────────── +// GET https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains +// (海外站 api.minimax.io) +// 接口直接给"剩余百分比",反转为已用百分比;只取 model_name == "general"。 + +pub async fn query_minimax(api_key: &str, is_cn: bool) -> Result { + let domain = if is_cn { + "api.minimaxi.com" + } else { + "api.minimax.io" + }; + let url = format!("https://{domain}/v1/api/openplatform/coding_plan/remains"); + match get_json(&url, api_key, AuthStyle::Bearer).await? { + Fetched::Body(body) => Ok(match parse_minimax(&body) { + Ok(data) => UsageResult::ok(data), + Err(err) => err, + }), + Fetched::Failed(err) => Ok(err), + } +} + +fn parse_minimax(body: &serde_json::Value) -> Result, UsageResult> { + // 业务级别错误 + if let Some(base_resp) = body.get("base_resp") { + let status_code = base_resp + .get("status_code") + .and_then(|v| v.as_i64()) + .unwrap_or(-1); + if status_code != 0 { + let msg = base_resp + .get("status_msg") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown error"); + return Err(UsageResult::failure(format!( + "API error (code {status_code}): {msg}" + ))); + } + } + + let mut tiers = Vec::new(); + + let Some(model_remains) = body.get("model_remains").and_then(|v| v.as_array()) else { + return Ok(tiers); + }; + // 只取 general(编程套餐),跳过 video 等其他模型 + let Some(item) = model_remains.iter().find(|item| { + item.get("model_name") + .and_then(|v| v.as_str()) + .map(|s| s == "general") + .unwrap_or(false) + }) else { + return Ok(tiers); + }; + + // 5h 桶:剩余百分比 → 已用百分比 + if let Some(remain_pct) = item + .get("current_interval_remaining_percent") + .and_then(|v| v.as_f64()) + { + let resets_at = item + .get("end_time") + .and_then(|v| v.as_i64()) + .and_then(millis_to_iso8601); + tiers.push(percent_tier(TIER_FIVE_HOUR, 100.0 - remain_pct, resets_at)); + } + + // 周桶:仅 status=1 时激活;status=3 等表示该套餐无周限额,跳过 + if item.get("current_weekly_status").and_then(|v| v.as_i64()) == Some(1) { + if let Some(remain_pct) = item + .get("current_weekly_remaining_percent") + .and_then(|v| v.as_f64()) + { + let resets_at = item + .get("weekly_end_time") + .and_then(|v| v.as_i64()) + .and_then(millis_to_iso8601); + tiers.push(percent_tier( + TIER_WEEKLY_LIMIT, + 100.0 - remain_pct, + resets_at, + )); + } + } + + Ok(tiers) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn kimi_coding_flattens_limits_and_usage() { + let body = json!({ + "limits": [ + { "detail": { "limit": 100, "remaining": 40, "resetTime": 1_754_000_000_000i64 } } + ], + "usage": { "limit": 1000, "remaining": 900, "resetTime": "2026-08-01T00:00:00Z" } + }); + let tiers = parse_kimi_coding(&body); + assert_eq!(tiers.len(), 2); + assert_eq!(tiers[0].plan_name.as_deref(), Some("five_hour")); + assert_eq!(tiers[0].used, Some(60.0)); + assert_eq!(tiers[0].total, Some(100.0)); + assert_eq!(tiers[0].remaining, Some(40.0)); + assert!(tiers[0].resets_at.is_some()); + assert_eq!(tiers[1].plan_name.as_deref(), Some("weekly_limit")); + assert_eq!(tiers[1].used, Some(10.0)); + assert_eq!( + tiers[1].resets_at.as_deref(), + Some("2026-08-01T00:00:00Z") + ); + } + + #[test] + fn kimi_coding_reset_time_seconds_vs_millis() { + // 秒级时间戳自动 ×1000 + let v = json!(1_754_000_000i64); + assert!(extract_reset_time(&v).is_some()); + // 0 / 负值视为无重置时间 + assert_eq!(extract_reset_time(&json!(0)), None); + assert_eq!(extract_reset_time(&json!(-1)), None); + } + + #[test] + fn zhipu_classifies_by_unit_field() { + let body = json!({ + "success": true, + "data": { + "limits": [ + { "type": "TOKENS_LIMIT", "percentage": 35.0, "unit": 3, "nextResetTime": 1_754_000_000_000i64 }, + { "type": "TOKENS_LIMIT", "percentage": 80.0, "unit": 6, "nextResetTime": 1_754_500_000_000i64 } + ] + } + }); + let tiers = parse_zhipu(&body).unwrap(); + assert_eq!(tiers.len(), 2); + assert_eq!(tiers[0].plan_name.as_deref(), Some("five_hour")); + assert_eq!(tiers[0].used, Some(35.0)); + assert_eq!(tiers[1].plan_name.as_deref(), Some("weekly_limit")); + assert_eq!(tiers[1].used, Some(80.0)); + } + + #[test] + fn zhipu_business_error_is_deterministic_failure() { + let body = json!({ "success": false, "msg": "token invalid" }); + let err = parse_zhipu(&body).unwrap_err(); + assert!(!err.success); + assert!(err.error.unwrap().contains("token invalid")); + } + + #[test] + fn minimax_picks_general_and_inverts_remaining() { + let body = json!({ + "base_resp": { "status_code": 0, "status_msg": "success" }, + "model_remains": [ + { "model_name": "video", "current_interval_remaining_percent": 50.0 }, + { + "model_name": "general", + "current_interval_remaining_percent": 70.0, + "end_time": 1_754_000_000_000i64, + "current_weekly_status": 1, + "current_weekly_remaining_percent": 95.0, + "weekly_end_time": 1_754_500_000_000i64 + } + ] + }); + let tiers = parse_minimax(&body).unwrap(); + assert_eq!(tiers.len(), 2); + assert_eq!(tiers[0].plan_name.as_deref(), Some("five_hour")); + assert_eq!(tiers[0].used, Some(30.0)); + assert_eq!(tiers[0].remaining, Some(70.0)); + assert_eq!(tiers[1].plan_name.as_deref(), Some("weekly_limit")); + assert_eq!(tiers[1].used, Some(5.0)); + } + + #[test] + fn minimax_skips_inactive_weekly_bucket() { + let body = json!({ + "model_remains": [ + { + "model_name": "general", + "current_interval_remaining_percent": 70.0, + "current_weekly_status": 3, + "current_weekly_remaining_percent": 100.0 + } + ] + }); + let tiers = parse_minimax(&body).unwrap(); + assert_eq!(tiers.len(), 1); + assert_eq!(tiers[0].plan_name.as_deref(), Some("five_hour")); + } + + #[test] + fn minimax_business_error_is_deterministic_failure() { + let body = json!({ "base_resp": { "status_code": 1002, "status_msg": "invalid key" } }); + let err = parse_minimax(&body).unwrap_err(); + assert!(!err.success); + assert!(err.error.unwrap().contains("invalid key")); + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs new file mode 100644 index 0000000..9eca9ad --- /dev/null +++ b/src-tauri/src/services/mod.rs @@ -0,0 +1,182 @@ +// Adapted from cc-switch (MIT, © Jason Young) +// https://github.com/farion1231/cc-switch + +//! 供应商账单/用量查询统一入口。 +//! +//! - [`UsageKind`]:8 种查询类型,字符串形式与前端 / SQLite settings 约定一致 +//! (如 `"balance:deepseek"`、`"plan:kimi_coding"`)。 +//! - [`detect_provider`]:按 base_url host 子串匹配,旧用户无显式配置时自动识别。 +//! - [`query_kind`]:按 kind 路由到 balance / coding_plan 的具体实现。 + +pub mod balance; +pub mod coding_plan; +pub mod usage_types; + +pub use usage_types::{UsageData, UsageResult}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum UsageKind { + BalanceDeepseek, + BalanceSiliconflow, + BalanceOpenrouter, + BalanceStepfun, + BalanceNovita, + PlanKimiCoding, + PlanZhipu, + PlanMinimax, +} + +impl UsageKind { + /// 与前端 / SQLite settings(`usage_kinds:`)约定的字符串形式。 + pub fn as_str(&self) -> &'static str { + match self { + UsageKind::BalanceDeepseek => "balance:deepseek", + UsageKind::BalanceSiliconflow => "balance:siliconflow", + UsageKind::BalanceOpenrouter => "balance:openrouter", + UsageKind::BalanceStepfun => "balance:stepfun", + UsageKind::BalanceNovita => "balance:novita", + UsageKind::PlanKimiCoding => "plan:kimi_coding", + UsageKind::PlanZhipu => "plan:zhipu", + UsageKind::PlanMinimax => "plan:minimax", + } + } + + pub const ALL: [UsageKind; 8] = [ + UsageKind::BalanceDeepseek, + UsageKind::BalanceSiliconflow, + UsageKind::BalanceOpenrouter, + UsageKind::BalanceStepfun, + UsageKind::BalanceNovita, + UsageKind::PlanKimiCoding, + UsageKind::PlanZhipu, + UsageKind::PlanMinimax, + ]; +} + +impl std::str::FromStr for UsageKind { + type Err = (); + + fn from_str(s: &str) -> Result { + Ok(match s { + "balance:deepseek" => UsageKind::BalanceDeepseek, + "balance:siliconflow" => UsageKind::BalanceSiliconflow, + "balance:openrouter" => UsageKind::BalanceOpenrouter, + "balance:stepfun" => UsageKind::BalanceStepfun, + "balance:novita" => UsageKind::BalanceNovita, + "plan:kimi_coding" => UsageKind::PlanKimiCoding, + "plan:zhipu" => UsageKind::PlanZhipu, + "plan:minimax" => UsageKind::PlanMinimax, + _ => return Err(()), + }) + } +} + +/// 按 base_url 子串匹配可支持的查询类型;无匹配返回空 vec。 +/// 一个 base_url 理论上可同时命中多种(套餐 + 余额),故返回 Vec。 +pub fn detect_provider(base_url: &str) -> Vec { + let url = base_url.to_lowercase(); + let mut kinds = Vec::new(); + if url.contains("api.deepseek.com") { + kinds.push(UsageKind::BalanceDeepseek); + } + if url.contains("api.siliconflow.cn") { + kinds.push(UsageKind::BalanceSiliconflow); + } + if url.contains("openrouter.ai") { + kinds.push(UsageKind::BalanceOpenrouter); + } + if url.contains("api.stepfun.com") { + kinds.push(UsageKind::BalanceStepfun); + } + if url.contains("api.novita.ai") { + kinds.push(UsageKind::BalanceNovita); + } + if url.contains("api.kimi.com") && url.contains("/coding") { + kinds.push(UsageKind::PlanKimiCoding); + } + if url.contains("open.bigmodel.cn") || url.contains("api.z.ai") { + kinds.push(UsageKind::PlanZhipu); + } + if url.contains("api.minimaxi.com") { + kinds.push(UsageKind::PlanMinimax); + } + kinds +} + +/// 按 kind 路由到对应查询实现。`base_url` 用于消歧同一家供应商的 +/// 国内/海外站(SiliconFlow .cn/.com、MiniMax .com/.io、智谱 bigmodel/z.ai)。 +pub async fn query_kind( + kind: UsageKind, + base_url: &str, + api_key: &str, +) -> Result { + let lower = base_url.to_lowercase(); + match kind { + UsageKind::BalanceDeepseek => balance::query_deepseek(api_key).await, + UsageKind::BalanceSiliconflow => { + balance::query_siliconflow(api_key, !lower.contains("siliconflow.com")).await + } + UsageKind::BalanceOpenrouter => balance::query_openrouter(api_key).await, + UsageKind::BalanceStepfun => balance::query_stepfun(api_key).await, + UsageKind::BalanceNovita => balance::query_novita(api_key).await, + UsageKind::PlanKimiCoding => coding_plan::query_kimi_coding(api_key).await, + UsageKind::PlanZhipu => coding_plan::query_zhipu(base_url, api_key).await, + UsageKind::PlanMinimax => { + coding_plan::query_minimax(api_key, !lower.contains("minimax.io")).await + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detect_provider_maps_known_hosts() { + let cases: [(&str, UsageKind); 8] = [ + ("https://api.deepseek.com/v1", UsageKind::BalanceDeepseek), + ("https://api.siliconflow.cn/v1", UsageKind::BalanceSiliconflow), + ("https://openrouter.ai/api/v1", UsageKind::BalanceOpenrouter), + ("https://api.stepfun.com/v1", UsageKind::BalanceStepfun), + ("https://api.novita.ai/v3", UsageKind::BalanceNovita), + ("https://api.kimi.com/coding/v1", UsageKind::PlanKimiCoding), + ( + "https://open.bigmodel.cn/api/paas/v4", + UsageKind::PlanZhipu, + ), + ("https://api.minimaxi.com/v1", UsageKind::PlanMinimax), + ]; + for (url, expected) in cases { + assert_eq!( + detect_provider(url), + vec![expected], + "url: {url}" + ); + } + // z.ai 也命中智谱 + assert_eq!( + detect_provider("https://api.z.ai/api/paas/v4"), + vec![UsageKind::PlanZhipu] + ); + } + + #[test] + fn detect_provider_unknown_url_returns_empty() { + assert!(detect_provider("https://api.openai.com/v1").is_empty()); + assert!(detect_provider("https://example.com").is_empty()); + assert!(detect_provider("").is_empty()); + // api.kimi.com 但无 /coding 路径 → 不命中套餐查询 + assert!(detect_provider("https://api.kimi.com/v1").is_empty()); + } + + #[test] + fn usage_kind_string_roundtrip() { + use std::str::FromStr; + for kind in UsageKind::ALL { + let s = kind.as_str(); + assert_eq!(UsageKind::from_str(s), Ok(kind), "kind: {s}"); + } + assert!(UsageKind::from_str("balance:unknown").is_err()); + assert!(UsageKind::from_str("").is_err()); + } +} diff --git a/src-tauri/src/services/usage_types.rs b/src-tauri/src/services/usage_types.rs new file mode 100644 index 0000000..fa21462 --- /dev/null +++ b/src-tauri/src/services/usage_types.rs @@ -0,0 +1,76 @@ +// Adapted from cc-switch (MIT, © Jason Young) +// https://github.com/farion1231/cc-switch + +//! Shared return contract for provider billing/usage queries. +//! +//! MUST stay camelCase: the frontend reads `planName` / `resetsAt` / +//! `isValid` — snake_case serialization would silently misalign every field. + +use serde::Serialize; + +#[derive(Serialize, Clone, Debug, Default)] +#[serde(rename_all = "camelCase")] +pub struct UsageData { + pub plan_name: Option, + pub remaining: Option, + pub total: Option, + pub used: Option, + pub unit: Option, + pub is_valid: Option, + pub resets_at: Option, +} + +#[derive(Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct UsageResult { + pub success: bool, + pub data: Option>, + pub error: Option, +} + +impl UsageResult { + pub fn ok(data: Vec) -> Self { + UsageResult { + success: true, + data: if data.is_empty() { None } else { Some(data) }, + error: None, + } + } + + pub fn failure(msg: String) -> Self { + UsageResult { + success: false, + data: None, + error: Some(msg), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn usage_result_serializes_camel_case() { + let result = UsageResult { + success: true, + data: Some(vec![UsageData { + plan_name: Some("five_hour".to_string()), + remaining: Some(65.0), + total: Some(100.0), + used: Some(35.0), + unit: Some("%".to_string()), + is_valid: Some(true), + resets_at: Some("2026-07-29T12:00:00+00:00".to_string()), + }]), + error: None, + }; + let json = serde_json::to_string(&result).unwrap(); + assert!(json.contains("\"planName\""), "json: {json}"); + assert!(json.contains("\"resetsAt\""), "json: {json}"); + assert!(json.contains("\"isValid\""), "json: {json}"); + assert!(json.contains("\"plan_name\"") == false, "json: {json}"); + assert!(json.contains("\"resets_at\"") == false, "json: {json}"); + assert!(json.contains("\"is_valid\"") == false, "json: {json}"); + } +} diff --git a/src-tauri/src/validators.rs b/src-tauri/src/validators.rs new file mode 100644 index 0000000..a0b22e4 --- /dev/null +++ b/src-tauri/src/validators.rs @@ -0,0 +1 @@ +//! Validation helpers for KimiSwitch configuration and command inputs. diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 0000000..39a6aa6 --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -0,0 +1,43 @@ +{ + "productName": "Kimi Switch", + "version": "0.6.0", + "identifier": "com.kimiswitch.app", + "build": { + "beforeDevCommand": "npm run dev", + "beforeBuildCommand": "npm run build", + "devUrl": "http://localhost:1420", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "title": "Kimi Switch", + "width": 1200, + "height": 800, + "minWidth": 1000, + "minHeight": 700, + "resizable": true, + "fullscreen": false + } + ], + "security": { + "csp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;" + } + }, + "bundle": { + "active": true, + "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 new file mode 100644 index 0000000..40f2a38 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,802 @@ +import { useCallback, 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 { useUpdateCheck } from "./hooks/useUpdateCheck"; +import { ProviderList } from "./components/ProviderList"; +import { ProviderEdit } from "./components/ProviderEdit"; +import { DashboardPage } from "./components/dashboard/DashboardPage"; +import { SessionsPage } from "./components/sessions/SessionsPage"; +import { SettingsModal } from "./components/SettingsModal"; +import { PresetPickerModal } from "./components/PresetPickerModal"; +import { useTranslation } from "./i18n"; +import { getDefaultMaxContextSize } from "./lib/model-defaults"; +import { getModelRef } from "./lib/models-dev"; +import { + presetToProviderAndModels, + type ProviderPreset, +} from "./config/providerPresets"; +import { validateProviders } from "./lib/validation"; +import type { Agent, Model, Provider } from "./types"; + +const AGENT_STORAGE_KEY = "kimi-switch-agent"; + +function getProviderDefaultModel(provider: Provider): string | null { + const raw = provider.raw_other as Record | undefined; + const value = raw?.default_model; + return typeof value === "string" ? value : null; +} + +function setProviderDefaultModel(provider: Provider, alias: string | null): Provider { + const raw = (provider.raw_other as Record | undefined) ?? {}; + if (alias) { + return { ...provider, raw_other: { ...raw, default_model: alias } }; + } + const { default_model: _, ...rest } = raw; + return { ...provider, raw_other: rest }; +} + +function findFirstModelForProvider(models: Record, providerName: string): string | null { + for (const [key, m] of Object.entries(models)) { + if (m.provider === providerName) { + return key; + } + } + return null; +} + +function getInitialAgent(): Agent { + try { + const stored = localStorage.getItem(AGENT_STORAGE_KEY) as Agent | null; + if (stored === "kimi_code") return stored; + } catch { + // ignore + } + return "kimi_code"; +} + +export default function App() { + const { t, lang, setLang } = useTranslation(); + const { updateInfo, checking, checkNow, lastChecked } = useUpdateCheck(); + const [agent] = useState(getInitialAgent); + const { + config, + dirty, + error, + loading, + refresh, + save, + updateConfig, + } = useConfig(agent); + + const [view, setView] = useState<"list" | "edit" | "dashboard" | "sessions">("list"); + const [editingProvider, setEditingProvider] = useState(""); + const [loadTimeout, setLoadTimeout] = useState(false); + const [switchMessage, setSwitchMessage] = useState(null); + const [showSettings, setShowSettings] = useState(false); + const [showPresetPicker, setShowPresetPicker] = useState(false); + // Provider names that have been added to the in-memory config but not + // yet committed via save(). Pressing "back" in the edit form for one of + // these drops it silently (no confirm); all other dirty navigation + // keeps the existing unsavedConfirm flow. + const [pendingNewProviders, setPendingNewProviders] = useState>( + () => new Set(), + ); + + + useEffect(() => { + if (!loading) { + setLoadTimeout(false); + return; + } + const timer = setTimeout(() => setLoadTimeout(true), 5000); + return () => clearTimeout(timer); + }, [loading]); + + // 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] + ); + + // trySave calls validateProviders, which takes a library-style + // `(key: string) => string`; useTranslation's `t` is strictly typed to + // the project's i18n key union, so we relax it locally for the call. + const tGeneric = t as unknown as ( + key: string, + vars?: Record, + ) => string; + // Wraps `save` with a completeness check. Three flavours of caller: + // 1. Whole-config save (Ctrl+S, footer button, ProviderEdit onSave): + // no `target` → validate every enabled provider, show a confirm + // dialog listing the incomplete ones. + // 2. Switching a specific provider as active: `target` is the + // provider being switched to — only that one needs to be + // complete; sibling providers being merely listed in the config + // must not block the switch. + // 3. Programmatic auto-saves (duplicate) call `save` directly with + // no validation — duplicates inherit completeness from their + // source. + // Returns `true` when the config was actually persisted, `false` when + // the user cancelled the validation confirm (callers can react to this + // to roll back in-memory mutations made before the save). + const trySave = useCallback( + async (target?: string): Promise => { + if (!config) return false; + if (dirty) { + const issues = validateProviders(config, config.models, tGeneric, target); + if (issues.length > 0) { + const summary = issues + .map((i) => `• ${i.name}: ${i.reasons.join("; ")}`) + .join("\n"); + if (!confirm(t("saveValidationConfirm", { details: summary }))) { + return false; + } + } + } + await save(); + // Successful save: clear the "in-memory only" markers — every + // provider is now on disk. + setPendingNewProviders(new Set()); + return true; + }, + [config, dirty, save, t], + ); + + // 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(); + void trySave(); + 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); + }, [trySave, refresh, dirty, t, agent]); + + // "Add provider" opens the preset picker; the empty form is reachable via + // the picker's "+ custom config" entry (handleAddCustomProvider). + const handleAddProvider = () => { + if (!config) return; + setShowPresetPicker(true); + }; + + const handleAddCustomProvider = () => { + if (!config) return; + setShowPresetPicker(false); + 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, + }, + }, + })); + // Mark as in-memory only — never persisted until the user explicitly + // saves a complete config. Pressing "back" in the edit form will + // silently drop this entry. + setPendingNewProviders((prev) => new Set([...prev, name])); + setEditingProvider(name); + setView("edit"); + }; + + const handleSelectPreset = async (preset: ProviderPreset) => { + if (!config) return; + setShowPresetPicker(false); + const { provider, models, defaultModel } = presetToProviderAndModels(preset, { + existingProviderNames: new Set(Object.keys(config.providers)), + existingModelAliases: new Set(Object.keys(config.models)), + }); + updateConfig((cfg) => { + const providers = { ...cfg.providers, [provider.name]: provider }; + const updatedModels = { ...cfg.models }; + for (const m of models) { + updatedModels[m.alias] = m; + } + return { + ...cfg, + providers, + models: updatedModels, + default_model: defaultModel || cfg.default_model, + }; + }); + // Do NOT auto-save: a freshly-picked preset is intentionally + // incomplete (no api_key yet). The user lands in the edit form + // next; if they fill the key and save, validation passes and the + // config lands on disk. If they back out without saving, the + // in-memory additions are discarded on the next refresh — config.toml + // never receives a half-configured provider. + setPendingNewProviders((prev) => new Set([...prev, provider.name])); + setEditingProvider(provider.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 handleDuplicateProvider = async (name: string) => { + updateConfig((cfg) => { + const src = cfg.providers[name]; + if (!src) return cfg; + let newName = `${name}-copy`; + let n = 2; + while (cfg.providers[newName]) { + newName = `${name}-copy-${n}`; + n++; + } + // Clone the provider (deactivated, clear its remembered default model + // since the model aliases will be re-keyed to the new name below). + const copied = setProviderDefaultModel( + { ...src, name: newName, active: false }, + null + ); + const providers = { ...cfg.providers, [newName]: copied }; + // Re-key this provider's models to the new provider name so the copy + // has its own independent model set. + const models = { ...cfg.models }; + for (const [alias, m] of Object.entries(cfg.models)) { + if (m.provider === name) { + // The model belongs to this provider by `m.provider === name`. To + // re-key it under the new provider, we want a `newName/...` + // alias. The original code assumed `alias` is exactly + // `${name}/${modelId}` and used `alias.slice(name.length)`; that + // breaks for legacy / non-standard aliases (e.g. `kimi-k3` from + // pre-v0.6 data) which would produce `newName-k3` — missing the + // `/` separator and pointing at the wrong model. Use an explicit + // prefix check and fall back to a full re-prefix. + const prefix = `${name}/`; + const newAlias = alias.startsWith(prefix) + ? newName + alias.slice(name.length) + : `${newName}/${alias}`; + models[newAlias] = { ...m, alias: newAlias, provider: newName }; + } + } + return { ...cfg, providers, models }; + }); + await save(); + setSwitchMessage(t("copiedProvider", { name: `${name}-copy` })); + setTimeout(() => setSwitchMessage(null), 3000); + }; + + const handleSetDefaultModel = (alias: string) => { + updateConfig((cfg) => { + const model = cfg.models[alias]; + if (!model) return cfg; + const provider = cfg.providers[model.provider]; + if (!provider) return cfg; + const providers = { + ...cfg.providers, + [provider.name]: setProviderDefaultModel(provider, alias), + }; + return { ...cfg, providers, default_model: alias }; + }); + }; + + const handleSwitchProvider = async (name: string) => { + updateConfig((cfg) => { + const target = cfg.providers[name]; + if (!target) return cfg; + + // Find the currently active provider so we can remember its default + // model before switching away. + let activeProviderName: string | null = null; + for (const [key, p] of Object.entries(cfg.providers)) { + if (p.active) { + activeProviderName = key; + break; + } + } + + // Rebuild providers with the selected one moved to the front so it is + // visually prioritised. All others are deactivated. + const providers: Record = {}; + providers[name] = { ...target, active: true }; + + // Remember the old active provider's current default model (if it + // belongs to that provider) so switching back later restores the user's + // choice. + if (activeProviderName && activeProviderName !== name && cfg.default_model) { + const oldActive = cfg.providers[activeProviderName]; + if (oldActive && cfg.models[cfg.default_model]?.provider === activeProviderName) { + providers[activeProviderName] = setProviderDefaultModel( + { ...oldActive, active: false }, + cfg.default_model + ); + } + } + + // Add remaining providers (skip selected and old-active if already added). + for (const [key, p] of Object.entries(cfg.providers)) { + if (key === name) continue; + if (providers[key]) continue; + providers[key] = { ...p, active: false }; + } + + // Prefer the target provider's remembered default model; fall back to + // its first model when there is no saved preference or it is invalid. + let default_model = getProviderDefaultModel(target); + if (!default_model || cfg.models[default_model]?.provider !== name) { + default_model = findFirstModelForProvider(cfg.models, name); + } + + return { ...cfg, providers, default_model }; + }); + + // Persist the full config to Kimi Switch's SQLite and activate the selected provider. + const saved = await trySave(name); + if (!saved) { + // Validation was cancelled: undo the in-memory switch by reloading + // the on-disk state, and skip the activation + /reload nudge. + await refresh(); + return; + } + await invoke("activate_agent_config_command", { agent }); + + // /reload is an interactive Kimi Code TUI command with no CLI equivalent, + // so we copy it to the clipboard as a convenience. + try { + await navigator.clipboard.writeText("/reload"); + setSwitchMessage(t("reloadCopiedHint")); + setTimeout(() => setSwitchMessage(null), 4000); + } catch { + // Clipboard may be unavailable; ignore silently. + } + }; + + const handleEditBack = async () => { + const target = editingProvider; + if (target && pendingNewProviders.has(target)) { + // This provider was added in-memory (via preset or "+ custom config") + // but never committed. Drop it silently — no confirm — and reload + // from disk so the list reflects the on-disk truth. + setPendingNewProviders((prev) => { + const next = new Set(prev); + next.delete(target); + return next; + }); + await refresh(); + } else if (dirty) { + // Real edits to a previously-saved provider — keep the existing + // unsaved-changes confirm so the user doesn't lose work by accident. + if (!confirm(t("unsavedConfirm"))) return; + await refresh(); + } + setView("list"); + }; + + 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, + max_context_size: + m.max_context_size ?? + getModelRef(m.model)?.context ?? + getDefaultMaxContextSize(m.model), + }; + } + + let default_model = cfg.default_model; + if (default_model && !(default_model in updatedModels)) { + default_model = null; + } + + // Remember this provider's default model preference so it survives + // provider switches. + const updatedProvider = providers[provider.name]; + if (updatedProvider && default_model && updatedModels[default_model]?.provider === provider.name) { + providers[provider.name] = setProviderDefaultModel(updatedProvider, default_model); + } + + return { ...cfg, providers, models: updatedModels, default_model }; + }); + if (provider.name !== editingProvider) { + setEditingProvider(provider.name); + } + }; + + if (loading || !config) { + return ( +
+
{t("loading")}
+ {loadTimeout && ( +
{t("loadTimeout")}
+ )} + {error && ( +
+
{t("loadFailed")}
+
{error}
+ +
+ )} +
+ ); + } + + const currentProvider = config.providers[editingProvider]; + + return ( +
+
+
+ + + +
+
+ {/* Language */} + + {/* Settings gear */} + +
+
+ + {error && ( +
+ {error} +
+ )} + + {switchMessage && ( +
+ {switchMessage} +
+ )} + +
+ {view === "dashboard" ? ( + + ) : view === "sessions" ? ( + + ) : view === "list" ? ( + { + setEditingProvider(name); + setView("edit"); + }} + onDelete={handleDeleteProvider} + onDuplicate={handleDuplicateProvider} + onAdd={handleAddProvider} + onSwitchProvider={handleSwitchProvider} + agent={agent} + /> + ) : currentProvider ? ( + m.provider === currentProvider.name + )} + defaultModel={config.default_model} + rawOther={config.raw_other} + onRawOtherChange={(nextRawOther) => + updateConfig((cfg) => ({ ...cfg, raw_other: nextRawOther })) + } + onBack={handleEditBack} + 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 safeProvider = currentProvider.name.replace(/\//g, "-"); + const alias = `${safeProvider}/新模型`; + updateConfig((cfg) => ({ + ...cfg, + models: { + ...cfg.models, + [alias]: { + alias, + provider: currentProvider.name, + model: "", + max_context_size: getDefaultMaxContextSize(alias), + display_name: null, + supports_1m: false, + capabilities: agent === "kimi_code" ? ["thinking"] : [], + }, + }, + })); + }} + 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={trySave} + /> + ) : ( +
+
{t("providerNotFound")}
+ +
+ )} +
+ + {view !== "dashboard" && view !== "sessions" && ( +
+
+ {dirty && ( + ● {t("unsavedChanges")} + )} +
+
+ + + +
+
+ )} + + setShowSettings(false)} + updateInfo={updateInfo} + checking={checking} + onCheckUpdate={checkNow} + lastChecked={lastChecked} + /> + + setShowPresetPicker(false)} + onSelect={handleSelectPreset} + onCustom={handleAddCustomProvider} + /> +
+ ); +} diff --git a/src/components/AgentSettingsPanel.tsx b/src/components/AgentSettingsPanel.tsx new file mode 100644 index 0000000..26acd57 --- /dev/null +++ b/src/components/AgentSettingsPanel.tsx @@ -0,0 +1,376 @@ +import type { ReactNode } from "react"; +import { useTranslation } from "../i18n"; +import type { TranslationKey } from "../i18n/zh"; +import { getAgentSettings, setAgentSettings } from "../lib/agent-settings"; +import type { AgentSettings, Hook, PermissionRule } from "../types"; + +interface AgentSettingsPanelProps { + rawOther: unknown; + onChange: (nextRawOther: unknown) => void; +} + +const THINKING_LEVELS = ["low", "medium", "high", "max"] as const; +const THINKING_LABELS: Record<(typeof THINKING_LEVELS)[number], TranslationKey> = { + low: "thinkingLow", + medium: "thinkingMedium", + high: "thinkingHigh", + max: "thinkingMax", +}; + +const PERMISSION_DECISIONS = ["allow", "deny", "ask"] as const; +const PERMISSION_LABELS: Record<(typeof PERMISSION_DECISIONS)[number], TranslationKey> = { + allow: "permissionAllow", + deny: "permissionDeny", + ask: "permissionAsk", +}; + +const COMMON_EVENTS = ["PreToolUse", "PostToolUse"] as const; + +export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelProps) { + const { t } = useTranslation(); + const settings = getAgentSettings(rawOther); + + const update = (patch: Partial) => { + onChange(setAgentSettings(rawOther, patch)); + }; + + const updateThinking = (patch: Partial) => { + update({ thinking: { ...settings.thinking, ...patch } }); + }; + + const updateLoopControl = (patch: Partial) => { + update({ loop_control: { ...settings.loop_control, ...patch } }); + }; + + const updateBackground = (patch: Partial) => { + update({ background: { ...settings.background, ...patch } }); + }; + + const setRules = (rules: PermissionRule[]) => { + update({ permission: { rules } }); + }; + + const setHooks = (hooks: Hook[]) => { + update({ hooks }); + }; + + const thinkingEnabled = settings.thinking?.enabled ?? true; + + return ( +
+

{t("agentSettings")}

+ + + updateThinking({ enabled: checked })} + /> +
+ {t("thinkingLevel")} + ({ + key: lvl, + label: t(THINKING_LABELS[lvl]), + }))} + value={settings.thinking?.effort ?? "medium"} + onChange={(effort) => + updateThinking({ effort: effort as NonNullable["effort"] }) + } + disabled={!thinkingEnabled} + /> +
+ updateThinking({ keep: checked ? "all" : false })} + /> +

{t("thinkingContextHint")}

+
+ + + updateLoopControl({ max_retries_per_step: v })} + /> + updateLoopControl({ reserved_context_size: v })} + /> + + + + updateBackground({ max_running_tasks: v })} + /> + updateBackground({ keep_alive_on_exit: checked })} + /> + + + +
+ {(settings.permission?.rules ?? []).map((rule, idx) => ( +
+ + { + const rules = [...(settings.permission?.rules ?? [])]; + rules[idx] = { ...rule, pattern: e.target.value }; + setRules(rules); + }} + /> + +
+ ))} +
+
+ + +
+
+ + +
+ {(settings.hooks ?? []).map((hook, idx) => ( +
+ { + const hooks = [...(settings.hooks ?? [])]; + hooks[idx] = { ...hook, event: e.target.value }; + setHooks(hooks); + }} + /> + + {COMMON_EVENTS.map((e) => ( + + { + const hooks = [...(settings.hooks ?? [])]; + hooks[idx] = { ...hook, matcher: e.target.value }; + setHooks(hooks); + }} + /> + { + const hooks = [...(settings.hooks ?? [])]; + hooks[idx] = { ...hook, command: e.target.value }; + setHooks(hooks); + }} + /> + { + const hooks = [...(settings.hooks ?? [])]; + hooks[idx] = { + ...hook, + timeout: e.target.value === "" ? undefined : Number(e.target.value), + }; + setHooks(hooks); + }} + /> + +
+ ))} +
+ +
+
+ ); +} + +function Card({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function Checkbox({ + label, + checked, + disabled, + onChange, +}: { + label: string; + checked: boolean; + disabled?: boolean; + onChange: (checked: boolean) => void; +}) { + return ( + + ); +} + +function NumberField({ + label, + value, + onChange, +}: { + label: string; + value?: number; + onChange: (value: number | undefined) => void; +}) { + return ( + + ); +} + +function Segmented({ + options, + value, + onChange, + disabled, +}: { + options: { key: string; label: string }[]; + value: string; + onChange: (value: string) => void; + disabled?: boolean; +}) { + return ( +
+ {options.map((opt) => ( + + ))} +
+ ); +} diff --git a/src/components/IconPicker.tsx b/src/components/IconPicker.tsx new file mode 100644 index 0000000..c3397a2 --- /dev/null +++ b/src/components/IconPicker.tsx @@ -0,0 +1,70 @@ +import { useMemo, useState } from "react"; +import { useTranslation } from "../i18n"; +import { ProviderIcon } from "./ProviderIcon"; +import { iconList } from "../icons/extracted"; +import { searchIcons, getIconMetadata } from "../icons/extracted/metadata"; + +interface IconPickerProps { + value?: string | null; + onValueChange: (icon: string) => void; +} + +export function IconPicker({ value, onValueChange }: IconPickerProps) { + const { t } = useTranslation(); + const [searchQuery, setSearchQuery] = useState(""); + + const filteredIcons = useMemo(() => { + if (!searchQuery.trim()) return iconList; + return searchIcons(searchQuery); + }, [searchQuery]); + + return ( +
+
+ + setSearchQuery(e.target.value)} + /> +
+ +
+
+ {filteredIcons.map((iconName) => { + const meta = getIconMetadata(iconName); + const isSelected = value === iconName; + return ( + + ); + })} +
+ + {filteredIcons.length === 0 && ( +
+ {t("iconPickerNoResults")} +
+ )} +
+
+ ); +} diff --git a/src/components/PresetPickerModal.tsx b/src/components/PresetPickerModal.tsx new file mode 100644 index 0000000..cbaef09 --- /dev/null +++ b/src/components/PresetPickerModal.tsx @@ -0,0 +1,201 @@ +import { useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { useTranslation } from "../i18n"; +import type { TranslationKey } from "../i18n/zh"; +import { providerPresets, type PresetCategory, type ProviderPreset } from "../config/providerPresets"; +import { ProviderIcon } from "./ProviderIcon"; + +interface PresetPickerModalProps { + open: boolean; + onClose: () => void; + onSelect: (preset: ProviderPreset) => void; + onCustom: () => void; +} + +const CATEGORY_BADGE: Record, string> = { + official: "bg-blue-100 text-blue-700 dark:bg-blue-500/15 dark:text-blue-400", + cn_official: "bg-green-100 text-green-700 dark:bg-green-500/15 dark:text-green-400", + third_party: "bg-gray-200 text-gray-600 dark:bg-gray-500/15 dark:text-gray-400", + aggregator: "bg-purple-100 text-purple-700 dark:bg-purple-500/15 dark:text-purple-400", +}; + +const CATEGORY_LABEL: Record, TranslationKey> = { + official: "presetCategoryOfficial", + cn_official: "presetCategoryCnOfficial", + third_party: "presetCategoryThirdParty", + aggregator: "presetCategoryAggregator", +}; + +type SortMode = "original" | "alpha"; + +export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetPickerModalProps) { + const { t } = useTranslation(); + const [query, setQuery] = useState(""); + const [sort, setSort] = useState("original"); + + // Esc to close + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, onClose]); + + // Scroll lock while open + useEffect(() => { + if (!open) return; + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = prev; + }; + }, [open]); + + // Reset transient state each time the modal opens + useEffect(() => { + if (open) { + setQuery(""); + setSort("original"); + } + }, [open]); + + const presetName = (p: ProviderPreset) => + p.nameKey ? t(p.nameKey as TranslationKey) : p.name; + + const visible = useMemo(() => { + const q = query.trim().toLowerCase(); + let list = providerPresets; + if (q) { + list = list.filter( + (p) => p.id.toLowerCase().includes(q) || presetName(p).toLowerCase().includes(q) + ); + } + if (sort === "alpha") { + list = [...list].sort((a, b) => presetName(a).localeCompare(presetName(b))); + } + return list; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [query, sort, t]); + + if (!open) return null; + + return createPortal( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+ {t("presetPickerTitle")} +
+ +
+ + {/* Search + sort */} +
+ setQuery(e.target.value)} + placeholder={t("presetSearchPlaceholder")} + autoFocus + className="h-8 flex-1 rounded border border-border bg-input px-2 text-sm text-content-primary focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ + +
+
+ + {/* Preset grid */} +
+ {visible.length === 0 ? ( +
+ {t("presetNoResults")} +
+ ) : ( +
+ {visible.map((p) => ( + + ))} +
+ )} + + {/* Custom (empty form) entry */} + +
+ + {/* Footer hint */} +
+ {t("presetFooterHint")} +
+
+
, + document.body + ); +} diff --git a/src/components/ProviderEdit.tsx b/src/components/ProviderEdit.tsx new file mode 100644 index 0000000..42e9ad1 --- /dev/null +++ b/src/components/ProviderEdit.tsx @@ -0,0 +1,812 @@ +import { useEffect, useId, useState } from "react"; +import { createPortal } from "react-dom"; +import { invoke } from "@tauri-apps/api/core"; +import { useTranslation } from "../i18n"; +import type { TranslationKey } from "../i18n/zh"; +import { getDefaultMaxContextSize } from "../lib/model-defaults"; +import { capabilitiesFromRef, getModelRef } from "../lib/models-dev"; +import { getIconMetadata } from "../icons/extracted/metadata"; +import { AgentSettingsPanel } from "./AgentSettingsPanel"; +import { ProviderIcon } from "./ProviderIcon"; +import { IconPicker } from "./IconPicker"; +import type { Agent, DiscoveredModel, Model, Provider, ProviderType } from "../types"; + +const KNOWN_CAPABILITIES = [ + "thinking", + "always_thinking", + "image_in", + "video_in", + "tool_use", +] as const; + +const CAPABILITY_LABELS: Record< + (typeof KNOWN_CAPABILITIES)[number], + TranslationKey +> = { + thinking: "capThinking", + always_thinking: "capAlwaysThinking", + image_in: "capImageIn", + video_in: "capVideoIn", + tool_use: "capToolUse", +}; + +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; + rawOther: unknown; + onRawOtherChange: (nextRawOther: unknown) => void; + 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, + rawOther, + onRawOtherChange, + 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); + const [showIconPicker, setShowIconPicker] = 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")}

+ + {/* Icon selector */} +
+ + {provider.icon && ( + + )} +
+ +
+
+ + 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-border bg-input 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" && ( + <> + + {agent === "kimi_code" && ( + + )} + + )} + + {activeTab === "json" && ( + + )} +
+ + {showIconPicker && + createPortal( +
setShowIconPicker(false)} + role="dialog" + aria-modal="true" + > +
e.stopPropagation()} + > +
+

+ {t("selectIcon")} +

+ +
+
+ { + const meta = getIconMetadata(iconName); + onChange({ + ...provider, + icon: iconName, + icon_color: meta?.defaultColor ?? null, + }); + setShowIconPicker(false); + }} + /> +
+
+
, + document.body + )} +
+ ); +} + +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 [fetchThinking, setFetchThinking] = useState(true); + + 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 safeProvider = provider.name.replace(/\//g, "-"); + const safeModelId = dm.id.replace(/\//g, "-"); + const alias = `${safeProvider}/${safeModelId}`; + const ref = getModelRef(dm.id); + // Priority: API-provided value > models.dev reference > regex fallback + const max_context_size = + dm.max_context_size ?? ref?.context ?? getDefaultMaxContextSize(dm.id); + toAdd.push({ + alias, + provider: provider.name, + model: dm.id, + max_context_size, + display_name: dm.display_name ?? ref?.name ?? null, + supports_1m: max_context_size >= 1_000_000, + // Reference data wins when available; otherwise keep the old + // fetchThinking-checkbox behavior as the fallback. + capabilities: ref + ? capabilitiesFromRef(ref) + : fetchThinking + ? ["thinking"] + : [], + }); + } + 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("displayName")}{t("actualModel")}{t("contextSize")}{t("supports1M")}{t("capabilities")}{t("default")}{t("operation")}
+ + {m.model ? `${m.model}[${m.provider}]` : m.alias} + + + + 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-border bg-input text-blue-600 focus:ring-blue-500" + /> + + + onModelChange({ ...m, capabilities: next }) + } + /> + + + + +
+ {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 })} +
+ )} +