Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14d48500bf | ||
|
|
32de98bff5 | ||
|
|
139af18826 | ||
|
|
5806fac056 | ||
|
|
948e96352e | ||
|
|
0ce89b09d9 |
No files matched your search
@@ -1,11 +1,14 @@
|
||||
# Pi Switch
|
||||
# Kimi Switch
|
||||
|
||||
> Windows 桌面端 LLM 供应商配置管理器,让你在 **Kimi Code CLI** 和 **Pi** 两个 Agent 之间无缝切换多家 LLM 供应商与模型。
|
||||
|
||||
[English](./README_EN.md) | **中文**
|
||||
|
||||
[](https://tauri.app)
|
||||
[](https://react.dev)
|
||||
[](https://www.typescriptlang.org)
|
||||
[](https://www.rust-lang.org)
|
||||
[](https://git.codingplan.site/admin/KimiCodeSwitch)
|
||||
[](./LICENSE)
|
||||
|
||||
---
|
||||
@@ -30,15 +33,15 @@
|
||||
|
||||
## 这是什么
|
||||
|
||||
**Pi Switch** 是一个为 LLM CLI 用户打造的 Windows 桌面配置管理工具。它解决两个痛点:
|
||||
**Kimi Switch** 是一个为 LLM CLI 用户打造的 Windows 桌面配置管理工具。它解决两个痛点:
|
||||
|
||||
1. **多供应商管理繁琐**:在 Kimi / Anthropic / OpenAI / Google GenAI / 自建代理 等多家供应商之间切换时,需要反复手改 TOML/JSON,容易出错。
|
||||
2. **多 Agent 配置割裂**:同时使用 **Kimi Code CLI** 和 **Pi** 两个 Agent 的开发者,每个 Agent 都有一套独立的配置格式,改一处要改两份。
|
||||
|
||||
Pi Switch 提供统一的图形界面:
|
||||
Kimi Switch 提供统一的图形界面:
|
||||
|
||||
- 统一图形界面,分别管理 Kimi Code 和 Pi 两套 Agent 配置,顶部 Tab 切换
|
||||
- 一键切换供应商,自动写入对应 Agent 的原生配置
|
||||
- 一键切换供应商,更新对应 Agent 原生配置的 `default_model`,保留全部供应商
|
||||
- 一键拉取模型列表(OpenAI / Anthropic / Google GenAI)
|
||||
- 切换后给出 `/reload` 提示,确保 Kimi Code 会话立即生效
|
||||
|
||||
@@ -57,14 +60,15 @@ Pi Switch 提供统一的图形界面:
|
||||
| **i18n** | 简体中文 / English,运行时切换 |
|
||||
| **自动备份** | 写入 Kimi Code / Pi 原生配置前自动备份,按时间戳命名,保留最近 7 天(见 `src-tauri/src/config_io.rs`) |
|
||||
| **快捷键** | Ctrl+S 保存、Ctrl+R 重载、Ctrl+O 打开配置目录 |
|
||||
| **校验(预留)** | i18n 已定义错误文案(名称重复、凭证缺失、Vertex 字段缺失等),后端 `validators.rs` 为占位实现,尚未接线 |
|
||||
| **用量仪表盘** | Token 用量统计、每日趋势(按模型分色)、全年热力图、最近请求翻页、双击查看模型分布 |
|
||||
| **会话管理** | 按工作区浏览、预览、归档、批量删除会话,逐行流式读取防崩溃(20MB 上限 + 500 字符折叠) |
|
||||
| **未保存提示** | 关闭窗口前检测未保存修改,标题栏加 `*` 前缀 |
|
||||
|
||||
## 架构总览
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ Pi Switch (Tauri v2) │
|
||||
│ Kimi Switch (Tauri v2) │
|
||||
│ │
|
||||
│ ┌──────────────────────────┐ ┌──────────────────────────┐ │
|
||||
│ │ React Frontend (TS) │ │ Rust Backend (lib.rs) │ │
|
||||
@@ -84,20 +88,21 @@ Pi Switch 提供统一的图形界面:
|
||||
▼ ▼
|
||||
┌──────────────────────┐ ┌──────────────────────────┐
|
||||
│ SQLite │ │ Agent 原生配置文件 │
|
||||
│ ~/.pi-switch/ │ │ ├─ ~/.kimi-code/ │
|
||||
│ pi-switch.db │ │ │ └─ config.toml │
|
||||
│ ~/.kimi-switch/ │ │ ├─ ~/.kimi-code/ │
|
||||
│ kimi-switch.db │ │ │ └─ config.toml │
|
||||
│ │ │ └─ ~/.pi/agent/ │
|
||||
│ (Pi Switch 内部状态) │ │ ├─ models.json │
|
||||
│ (Kimi Switch 内部状态) │ │ ├─ models.json │
|
||||
└──────────────────────┘ │ └─ settings.json │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
**关键设计**:
|
||||
|
||||
- Pi Switch 维护自己的 SQLite 数据库存**完整配置**(所有供应商 + 所有模型,含未激活的)
|
||||
- 点「切换使用」时只把**当前激活的供应商**写入 Agent 原生配置,其他不写
|
||||
- 这样 Kimi Code / Pi 始终只看到一个活跃供应商,不会被托管/OAuth 默认值干扰
|
||||
- `raw_other` 字段透传未知键,保证前后往返不丢字段
|
||||
- 对 Kimi Code,`config.toml` 是供应商/模型数据的**权威来源**:所有供应商和模型始终全量保留,`default_model` 决定哪个生效(与 CLI 原生 `/provider` 行为一致)
|
||||
- SQLite 只存 Kimi Switch 专有元数据(备注、官网、每个供应商记住的默认模型),并在 `config.toml` 不完整时兜底
|
||||
- 加载时以 `config.toml` 为准,因此通过 `/provider` 在 CLI 端增/改/删的供应商都会被正确反映,切换不再覆盖丢失
|
||||
- `raw_other` 字段透传未知键(含 `[oauth]` 段),保证前后往返不丢字段
|
||||
- Pi 的行为不变:切换时仍只写活跃供应商到 `models.json`
|
||||
|
||||
## 支持的供应商类型
|
||||
|
||||
@@ -116,11 +121,11 @@ Pi Switch 提供统一的图形界面:
|
||||
|
||||
| 文件 | 用途 | 备份 |
|
||||
| --- | --- | --- |
|
||||
| `%USERPROFILE%\.pi-switch\pi-switch.db` | Pi Switch 自己的 SQLite 数据库,存全量配置 | — |
|
||||
| `%USERPROFILE%\.kimi-code\config.toml` | Kimi Code CLI 的 TOML 配置(**切换时写入**) | 同目录下 `backups/config.toml.bak.{YYYYMMDD_HHMMSS}`,保留 7 天 |
|
||||
| `%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 天 |
|
||||
| `localStorage[pi-switch-agent]` | 前端记住上次选中的 Agent(kimi_code / pi) | — |
|
||||
| `localStorage[kimi-switch-agent]` | 前端记住上次选中的 Agent(kimi_code / pi) | — |
|
||||
|
||||
环境变量覆盖:
|
||||
|
||||
@@ -203,7 +208,7 @@ npm run dev
|
||||
│ └── tauri.conf.json # Tauri 配置(窗口/打包/CSP)
|
||||
│
|
||||
├── scripts/generate-icons.py # 从 SVG 生成各尺寸图标
|
||||
├── public/pi.svg # 应用图标源(蓝紫渐变 π)
|
||||
├── public/kimi.svg # 应用图标源(蓝紫渐变 π)
|
||||
└── docs/superpowers/ # 设计规范与实施计划
|
||||
```
|
||||
|
||||
@@ -211,9 +216,9 @@ npm run dev
|
||||
|
||||
| 命令 | 说明 |
|
||||
| --- | --- |
|
||||
| `load_agent_config_command(agent)` | 加载配置:优先 SQLite,为空则从 Agent 原生配置导入 |
|
||||
| `save_agent_config_command(agent, config)` | 保存全量配置到 SQLite |
|
||||
| `activate_agent_config_command(agent)` | 把当前激活的供应商写入 Agent 原生配置 |
|
||||
| `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 拉取模型列表(异步) |
|
||||
@@ -239,7 +244,7 @@ npm run dev
|
||||
|
||||
| 快捷键 | 作用 |
|
||||
| --- | --- |
|
||||
| `Ctrl + S` | 保存当前修改到 SQLite |
|
||||
| `Ctrl + S` | 保存当前修改到 SQLite + config.toml(Kimi Code) |
|
||||
| `Ctrl + R` | 重新读取配置(未保存时会提示) |
|
||||
| `Ctrl + O` | 打开当前 Agent 的配置目录 |
|
||||
|
||||
@@ -281,10 +286,20 @@ npm run tauri-build
|
||||
|
||||
产物位置:
|
||||
|
||||
- `src-tauri/target/release/bundle/msi/Pi Switch_0.1.0_x64_en-US.msi`
|
||||
- `src-tauri/target/release/bundle/msi/Kimi Switch_0.3.0_x64_en-US.msi`
|
||||
|
||||
Windows 安装包(MSI),含 WebView2 bootstrapper 自动下载。`nsis` 已禁用,目前只产 MSI。
|
||||
|
||||
### 发布版本
|
||||
|
||||
**v0.3.0** — 新增用量仪表盘与会话管理功能(基于 [kimicode-dashboard](https://github.com/JochenYang/kimicode-dashboard) 移植,感谢原作者 [JochenYang](https://github.com/JochenYang) 的开源贡献):
|
||||
|
||||
- 用量仪表盘:8 项 KPI、每日趋势图(按模型分色堆叠柱状图,双击查看模型分布弹窗)、全年热力图(按 Token 量 5 级着色,鼠标悬浮 tooltip)、最近请求翻页(30 条/页)
|
||||
- 会话管理:按工作区隔离浏览、预览会话内容(流式逐行读取防崩溃,20MB 字节上限,500 字符折叠可展开)、归档/取消归档/批量删除
|
||||
- 时间线修复:`today` 范围、每日热力图和 `day_key` 分桶改用本地时区日历日,UTC+8 用户跨零点后不再数据错位
|
||||
- 隐藏 Pi 选项:左侧导航栏 Pi 入口已移除(代码保留,仅 UI 隐藏)
|
||||
- 支持供应商:Kimi / Anthropic / OpenAI / OpenAI Responses / Google GenAI / Vertex AI
|
||||
|
||||
### 首次打包
|
||||
|
||||
首次运行会下载:
|
||||
@@ -301,7 +316,7 @@ A: 在 Kimi Code 会话里执行 `/reload`(Kimi Code CLI 才会重新读取 `~
|
||||
A: 关闭窗口前会弹原生 `beforeunload` 提示,标题栏也会显示 `*` 前缀。
|
||||
|
||||
**Q: 怎么备份/迁移我的配置?**
|
||||
A: 备份 `%USERPROFILE%\.pi-switch\pi-switch.db` 即可,里面存了全量配置(含未激活的供应商)。
|
||||
A: 对 Kimi Code,`config.toml` 本身就是全量配置的权威来源,直接备份它即可;SQLite 存额外的备注/官网等元数据,需要时一并备份 `kimi-switch.db`。对 Pi,备份 `kimi-switch.db`。
|
||||
|
||||
**Q: Vertex AI 为什么不能拉取模型列表?**
|
||||
A: Vertex 需要 GCP project/location 凭证,当前实现留了 TODO,等 GCP SDK 集成后再补。
|
||||
@@ -312,7 +327,7 @@ A: 代码不依赖 Windows 专属 API,但 `tauri.conf.json` 的 bundle 目标
|
||||
## 安全提示
|
||||
|
||||
- API Key 以明文存储在本地 SQLite 和 Agent 原生配置里——**不要在共享电脑上保存**
|
||||
- 不要把 `pi-switch.db`、`config.toml`、`models.json` 提交到 Git
|
||||
- 不要把 `kimi-switch.db`、`config.toml`、`models.json` 提交到 Git
|
||||
- 应用 CSP 已收紧(`default-src 'self'`),但 WebView2 仍可能缓存表单内容,注意在公共电脑用完退出
|
||||
|
||||
---
|
||||
@@ -323,6 +338,10 @@ A: 代码不依赖 Windows 专属 API,但 `tauri.conf.json` 的 bundle 目标
|
||||
- [Tauri](https://tauri.app) — 桌面应用框架
|
||||
- 设计文档见 `docs/superpowers/specs/`,实施计划见 `docs/superpowers/plans/`
|
||||
|
||||
## 致谢
|
||||
|
||||
用量仪表盘与会话管理功能基于 [kimicode-dashboard](https://github.com/JochenYang/kimicode-dashboard)(MIT 许可证,© JochenYang)移植。原始项目实现了 Kimi Code CLI 的本地 Token 用量统计与会话管理,本项目的 Rust 后端(`src-tauri/src/dashboard.rs`)、前端仪表盘(`src/components/dashboard/`)以及会话管理页(`src/components/sessions/`)均源自该项目,感谢原作者的开源贡献。
|
||||
|
||||
---
|
||||
|
||||
许可证:MIT,详见 [LICENSE](./LICENSE)。Copyright (c) 2026 CodingPlan.site
|
||||
@@ -0,0 +1,346 @@
|
||||
# Kimi Switch
|
||||
|
||||
> A Windows desktop LLM provider config manager that lets you switch between multiple LLM providers and models across the **Kimi Code CLI** and **Pi** agents.
|
||||
|
||||
**English** | [中文](./README.md)
|
||||
|
||||
[](https://tauri.app)
|
||||
[](https://react.dev)
|
||||
[](https://www.typescriptlang.org)
|
||||
[](https://www.rust-lang.org)
|
||||
[](https://git.codingplan.site/admin/KimiCodeSwitch)
|
||||
[](./LICENSE)
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [What Is This](#what-is-this)
|
||||
- [Key Features](#key-features)
|
||||
- [Architecture Overview](#architecture-overview)
|
||||
- [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)
|
||||
- [FAQ](#faq)
|
||||
- [Security Notes](#security-notes)
|
||||
|
||||
---
|
||||
|
||||
## What Is This
|
||||
|
||||
**Kimi Switch** is a Windows desktop configuration tool built for LLM CLI users. It solves two pain points:
|
||||
|
||||
1. **Tedious multi-provider management**: switching between Kimi / Anthropic / OpenAI / Google GenAI / self-hosted proxies requires repeatedly hand-editing TOML/JSON, which is error-prone.
|
||||
2. **Fragmented multi-agent configs**: developers using both **Kimi Code CLI** and **Pi** have to maintain two independent config formats, duplicating every change.
|
||||
|
||||
Kimi Switch provides a unified GUI:
|
||||
|
||||
- One unified interface to manage two separate agent configs (Kimi Code and Pi), switched via a top tab
|
||||
- One-click provider switching that updates `default_model` in the target agent's native config while preserving all providers
|
||||
- One-click model-list fetching (OpenAI / Anthropic / Google GenAI)
|
||||
- A `/reload` hint after switching so Kimi Code sessions take effect immediately
|
||||
|
||||
## Key Features
|
||||
|
||||
| Category | Feature |
|
||||
| --- | --- |
|
||||
| **Multi-agent** | Manage Kimi Code and Pi configs independently without interference, via a top tab |
|
||||
| **Multi-provider** | Kimi / Anthropic / OpenAI / OpenAI Responses / Google GenAI / Vertex AI |
|
||||
| **Model mapping** | Alias ↔ real model ID, with display name, context size, role (Sonnet/Opus/Fable/Haiku), and 1M-context declaration |
|
||||
| **One-click discovery** | Auto-fetch available model lists from the provider API |
|
||||
| **Managed providers** | Flag OAuth/managed providers to skip credential validation and preserve the `oauth` section in Kimi Code config |
|
||||
| **Env credentials** | Supports both the `api_key` field and env-table keys (e.g. `OPENAI_API_KEY`) |
|
||||
| **Global settings** | Thinking toggle/level, loop retries, background tasks, permission rules, lifecycle hooks (Kimi Code only) |
|
||||
| **Raw JSON editing** | Power users can hand-edit the full config JSON (unknown fields are preserved, never dropped) |
|
||||
| **i18n** | Simplified Chinese / English, switched at runtime |
|
||||
| **Auto backup** | Before writing the Kimi Code / Pi native configs, back up with timestamp-based names and keep the last 7 days (see `src-tauri/src/config_io.rs`) |
|
||||
| **Shortcuts** | Ctrl+S save, Ctrl+R reload, Ctrl+O open config dir |
|
||||
| **Validation (reserved)** | i18n error strings are defined (duplicate names, missing credentials, missing Vertex fields, etc.), but the backend `validators.rs` is a stub and not yet wired |
|
||||
| **Unsaved-changes prompt** | Detects unsaved edits before closing the window and prefixes the title bar with `*` |
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ Kimi Switch (Tauri v2) │
|
||||
│ │
|
||||
│ ┌──────────────────────────┐ ┌──────────────────────────┐ │
|
||||
│ │ React Frontend (TS) │ │ Rust Backend (lib.rs) │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ src/App.tsx │◄──►│ src-tauri/src/ │ │
|
||||
│ │ src/components/ │ Tauri ├── commands.rs │ │
|
||||
│ │ ProviderList │ invoke ├── db.rs │ │
|
||||
│ │ ProviderEdit │ ├── kimi_code_io │ │
|
||||
│ │ AgentSettingsPanel │ ├── pi_io.rs │ │
|
||||
│ │ src/hooks/useConfig │ ├── config_io.rs │ │
|
||||
│ │ src/i18n/{zh,en}.ts │ ├── validators.rs │ │
|
||||
│ │ src/types/index.ts │ └── profile_manager│ │
|
||||
│ └──────────────────────────┘ └──────────────────────────┘ │
|
||||
│ │ │ │
|
||||
└──────────────────┼──────────────────────────────┼──────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────────────────┐ ┌──────────────────────────┐
|
||||
│ SQLite │ │ Agent native configs │
|
||||
│ ~/.kimi-switch/ │ │ ├─ ~/.kimi-code/ │
|
||||
│ kimi-switch.db │ │ │ └─ config.toml │
|
||||
│ │ │ └─ ~/.pi/agent/ │
|
||||
│ (Kimi Switch internal)│ │ ├─ models.json │
|
||||
└──────────────────────┘ │ └─ settings.json │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
**Key design points**:
|
||||
|
||||
- For Kimi Code, `config.toml` is the **authoritative source** for provider/model data: all providers and models are always written in full, and `default_model` selects the active one (matching the CLI's native `/provider` behaviour)
|
||||
- SQLite stores only Kimi Switch-private metadata (notes, official URLs, per-provider remembered default model) and acts as a fallback when `config.toml` is incomplete
|
||||
- On load, `config.toml` takes precedence, so providers added/edited/deleted via the CLI's `/provider` are correctly reflected — switching no longer overwrites or loses them
|
||||
- The `raw_other` field passes unknown keys through untouched (including `[oauth]` blocks), so round-trips never lose fields
|
||||
- Pi behaviour is unchanged: switching still writes only the active provider to `models.json`
|
||||
|
||||
## 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: the `api_key` field wins over the same-named key in the `env` table.
|
||||
|
||||
## Data Storage Locations
|
||||
|
||||
| File | Purpose | Backup |
|
||||
| --- | --- | --- |
|
||||
| `%USERPROFILE%\.kimi-switch\kimi-switch.db` | Kimi Switch's own SQLite database, holding metadata (notes/official URLs/remembered models) + migration fallback | — |
|
||||
| `%USERPROFILE%\.kimi-code\config.toml` | Kimi Code CLI's 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's provider+model config (**written on switch**) | `backups/models.json.bak.{YYYYMMDD_HHMMSS}` next to it, kept 7 days |
|
||||
| `%USERPROFILE%\.pi\agent\settings.json` | Pi's default provider/model (**written on switch**) | `backups/settings.json.bak.{YYYYMMDD_HHMMSS}` next to it, kept 7 days |
|
||||
| `localStorage[kimi-switch-agent]` | Frontend remembers the last selected agent (kimi_code / pi) | — |
|
||||
|
||||
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`)
|
||||
|
||||
## 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 ProviderList/ProviderEdit
|
||||
│ ├── main.tsx # React entry + ErrorBoundary + I18nProvider
|
||||
│ ├── components/
|
||||
│ │ ├── ProviderList.tsx # Provider list + switch button
|
||||
│ │ ├── ProviderEdit.tsx # Edit provider + model mapping + raw JSON
|
||||
│ │ └── AgentSettingsPanel.tsx# Global settings (thinking/loop/permissions/hooks)
|
||||
│ ├── hooks/
|
||||
│ │ └── useConfig.ts # Config load/save hook
|
||||
│ ├── lib/
|
||||
│ │ ├── agent-settings.ts # AgentSettings parse/serialize
|
||||
│ │ └── model-defaults.ts # Default model context sizes
|
||||
│ ├── types/index.ts # Provider/Model/Config type definitions
|
||||
│ ├── i18n/
|
||||
│ │ ├── zh.ts # Chinese translations (143 keys)
|
||||
│ │ ├── en.ts # English translations
|
||||
│ │ └── index.tsx # useTranslation hook + Provider
|
||||
│ └── index.css # Tailwind entry
|
||||
│
|
||||
├── src-tauri/ # Rust backend
|
||||
│ ├── src/
|
||||
│ │ ├── lib.rs # Tauri Builder + invoke_handler registration
|
||||
│ │ ├── main.rs # Binary entry
|
||||
│ │ ├── commands.rs # 7 Tauri commands
|
||||
│ │ ├── db.rs # SQLite persistence
|
||||
│ │ ├── kimi_code_io.rs # ~/.kimi-code/config.toml read/write
|
||||
│ │ ├── pi_io.rs # ~/.pi/agent/*.json read/write
|
||||
│ │ ├── config_io.rs # File backup utilities
|
||||
│ │ ├── models.rs # Config/Provider/Model data structures
|
||||
│ │ ├── profile_manager.rs # Multi-profile management (stub)
|
||||
│ │ └── validators.rs # Config validation
|
||||
│ ├── capabilities/ # Tauri permission declarations
|
||||
│ ├── icons/ # App icons (script-generated)
|
||||
│ └── tauri.conf.json # Tauri config (window/bundle/CSP)
|
||||
│
|
||||
├── scripts/generate-icons.py # Generate all icon sizes from SVG
|
||||
├── public/kimi.svg # App icon source (blue-purple gradient π)
|
||||
└── docs/superpowers/ # Design specs & implementation plans
|
||||
```
|
||||
|
||||
### Tauri commands (frontend ↔ backend)
|
||||
|
||||
| Command | Description |
|
||||
| --- | --- |
|
||||
| `load_agent_config_command(agent)` | Load config: Kimi Code reads `config.toml` as the authoritative source, enriched with 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 model list from the provider API (async) |
|
||||
| `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 mappings in `kimi_code_io.rs::provider_type_for_kimi_type` and `pi_io.rs::provider_type_for_pi_api`
|
||||
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 the `tauri::generate_handler![...]` list 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 access is needed)
|
||||
|
||||
## 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
|
||||
|
||||
- Translation sources: `src/i18n/zh.ts` (source) and `src/i18n/en.ts` (target)
|
||||
- When adding a key, **add it to `zh.ts` first** — the `Record<TranslationKey, string>` type in `en.ts` will flag any missing entry at compile time
|
||||
- Language is switched at runtime via the top-right dropdown and held in component state (not persisted to SQLite)
|
||||
|
||||
## 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)
|
||||
|
||||
### Frontend
|
||||
|
||||
No automated tests yet. Suggested manual checklist:
|
||||
|
||||
- [ ] Switching agents doesn't cross-contaminate configs
|
||||
- [ ] Deleting a provider also deletes its models
|
||||
- [ ] The default model restores correctly after switching providers
|
||||
- [ ] Renaming a provider updates models that reference it
|
||||
- [ ] Raw-JSON editing preserves `raw_other` fields
|
||||
|
||||
## Build & Release
|
||||
|
||||
```bash
|
||||
npm run tauri-build
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
- `src-tauri/target/release/bundle/msi/Kimi Switch_0.3.0_x64_en-US.msi`
|
||||
|
||||
A Windows installer (MSI) with the WebView2 bootstrapper embedded for auto-download. `nsis` is disabled; only MSI is produced.
|
||||
|
||||
### v0.3.0 Release Notes
|
||||
|
||||
New dashboard and session management features, ported from [kimicode-dashboard](https://github.com/JochenYang/kimicode-dashboard) (MIT, © JochenYang). Many thanks to the original author for their open-source contribution.
|
||||
|
||||
- **Dashboard**: 8 KPI cards, daily trend chart (per-model stacked bars, double-click for per-model breakdown modal), full-year heatmap (5-level token coloring with hover tooltip), paginated recent requests (30/page)
|
||||
- **Session management**: browse by workspace, preview (streaming line-by-line read, 20MB byte cap, 500-char collapse with expand toggle), archive/unarchive/bulk delete
|
||||
- **Timezone fix**: `today` range, heatmap, and `day_key` now use the local calendar day — UTC+8 users no longer see empty data after midnight
|
||||
- **Pi option hidden**: the Pi tab has been removed from the navigation bar (code preserved, UI only)
|
||||
- **Supported providers**: Kimi / Anthropic / OpenAI / OpenAI Responses / Google GenAI / Vertex AI
|
||||
|
||||
### First build
|
||||
|
||||
The first run downloads:
|
||||
|
||||
- WiX Toolset 3.14 binaries → `src-tauri/wix314-binaries/`
|
||||
- WebView2 bootstrapper → embedded into the MSI
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: Kimi Code didn't pick up the change after switching providers?**
|
||||
A: Run `/reload` inside the Kimi Code session (the CLI only re-reads `~/.kimi-code/config.toml` on reload). The app shows this hint in the UI.
|
||||
|
||||
**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 extra metadata (notes, official URLs); back up `kimi-switch.db` too if you need those. For Pi, back up `kimi-switch.db`.
|
||||
|
||||
**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.
|
||||
|
||||
## Security Notes
|
||||
|
||||
- API keys are stored in plaintext in the local SQLite database 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
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Related Projects
|
||||
|
||||
- [Kimi Code CLI](https://github.com/MoonshotAI/kimi-cli) — one of the compatible agents
|
||||
- [Tauri](https://tauri.app) — the desktop app framework
|
||||
- Design specs in `docs/superpowers/specs/`, implementation plans in `docs/superpowers/plans/`
|
||||
|
||||
## Credits
|
||||
|
||||
The usage dashboard and session manager are ported from [kimicode-dashboard](https://github.com/JochenYang/kimicode-dashboard) (MIT License, © JochenYang). The original project implements local token usage analytics and session management for Kimi Code CLI. 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.
|
||||
|
||||
---
|
||||
|
||||
License: MIT, see [LICENSE](./LICENSE). Copyright (c) 2026 CodingPlan.site
|
||||
@@ -229,16 +229,16 @@ Search `src/components/` and `src/App.tsx` for any literal Chinese or English UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `index.html`
|
||||
- Create/overwrite: `public/pi.svg`
|
||||
- Create/overwrite: `public/kimi.svg`
|
||||
- Create/overwrite: `src-tauri/icons/32x32.png`, `src-tauri/icons/128x128.png`, `src-tauri/icons/128x128@2x.png`, `src-tauri/icons/icon.ico`
|
||||
|
||||
- [ ] **Step 6.1: Generate Pi Switch icons**
|
||||
- [ ] **Step 6.1: Generate Kimi Switch icons**
|
||||
|
||||
Use a Python script (Pillow) to generate PNG/ICO files with a π symbol. Update `index.html` favicon to `/pi.svg`.
|
||||
Use a Python script (Pillow) to generate PNG/ICO files with a π symbol. Update `index.html` favicon to `/kimi.svg`.
|
||||
|
||||
- [ ] **Step 6.2: Verify app title**
|
||||
|
||||
`tauri.conf.json` already has `"title": "Pi Switch"`. Ensure `index.html` `<title>` is also `Pi Switch`.
|
||||
`tauri.conf.json` already has `"title": "Kimi Switch"`. Ensure `index.html` `<title>` is also `Kimi Switch`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 背景
|
||||
|
||||
Kimi Code CLI 的 `~/.kimi-code/config.toml` 包含大量全局设置:`[thinking]`、`[loop_control]`、`[background]`、`[[permission.rules]]`、`[[hooks]]` 等。当前 Pi Switch 只暴露了 `providers` 和 `models` 的可视化编辑,其他全局配置只能通过"配置 JSON"标签页手动修改,不够便捷。
|
||||
Kimi Code CLI 的 `~/.kimi-code/config.toml` 包含大量全局设置:`[thinking]`、`[loop_control]`、`[background]`、`[[permission.rules]]`、`[[hooks]]` 等。当前 Kimi Switch 只暴露了 `providers` 和 `models` 的可视化编辑,其他全局配置只能通过"配置 JSON"标签页手动修改,不够便捷。
|
||||
|
||||
## 目标
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/pi.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/kimi.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Pi Switch</title>
|
||||
<title>Kimi Switch</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "piswitch",
|
||||
"name": "kimiswitch",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "piswitch",
|
||||
"name": "kimiswitch",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"name": "piswitch",
|
||||
"name": "kimiswitch",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.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",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#3b82f6"/>
|
||||
<stop offset="100%" stop-color="#8b5cf6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="100" height="100" rx="22" fill="url(#g)"/>
|
||||
<text x="48" y="70" text-anchor="middle" font-size="52" font-weight="700"
|
||||
fill="white" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">K</text>
|
||||
<circle cx="74" cy="26" r="9" fill="white" opacity="0.35"/>
|
||||
<circle cx="74" cy="26" r="6.5" fill="white" opacity="0.92"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 615 B |
@@ -1,10 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#3b82f6"/>
|
||||
<stop offset="100%" stop-color="#8b5cf6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="50" cy="50" r="48" fill="url(#g)"/>
|
||||
<text x="50" y="66" text-anchor="middle" font-size="50" fill="white" font-family="system-ui, -apple-system, sans-serif">π</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 444 B |
@@ -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<string, any>} */
|
||||
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}`,
|
||||
);
|
||||
@@ -3,78 +3,106 @@ import os
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
OUT_DIR = os.path.join(ROOT, "src-tauri", "icons")
|
||||
SVG_PATH = os.path.join(ROOT, "public", "pi.svg")
|
||||
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):
|
||||
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
# 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)
|
||||
|
||||
# Gradient-ish circular background: blue -> purple
|
||||
for y in range(size):
|
||||
ratio = y / size
|
||||
r = int(59 + (139 - 59) * ratio)
|
||||
g = int(130 + (92 - 130) * ratio)
|
||||
b = int(246 + (246 - 246) * ratio)
|
||||
draw.line([(0, y), (size, y)], fill=(r, g, b, 255))
|
||||
|
||||
# Clip to circle
|
||||
mask = Image.new("L", (size, size), 0)
|
||||
mask_draw = ImageDraw.Draw(mask)
|
||||
mask_draw.ellipse((0, 0, size, size), fill=255)
|
||||
img.putalpha(mask)
|
||||
|
||||
# Draw π symbol
|
||||
font_size = int(size * 0.55)
|
||||
try:
|
||||
font = ImageFont.truetype("segoeui.ttf", font_size)
|
||||
except Exception:
|
||||
# Bold "K"
|
||||
font_size = int(S * 0.52)
|
||||
font = None
|
||||
for name in ("segoeuib.ttf", "arialbd.ttf", "DejaVuSans-Bold.ttf"):
|
||||
try:
|
||||
font = ImageFont.truetype("arial.ttf", font_size)
|
||||
font = ImageFont.truetype(name, font_size)
|
||||
break
|
||||
except Exception:
|
||||
font = ImageFont.load_default()
|
||||
continue
|
||||
if font is None:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
draw = ImageDraw.Draw(img)
|
||||
text = "π"
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
text_w = bbox[2] - bbox[0]
|
||||
text_h = bbox[3] - bbox[1]
|
||||
x = (size - text_w) // 2 - bbox[0]
|
||||
y = (size - text_h) // 2 - bbox[1]
|
||||
draw.text((x, y), text, font=font, fill=(255, 255, 255, 255))
|
||||
return img
|
||||
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))
|
||||
|
||||
sizes = [32, 128, 256]
|
||||
for s in sizes:
|
||||
# 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 == 128:
|
||||
img.save(os.path.join(OUT_DIR, "128x128.png"))
|
||||
img2x = draw_icon(256)
|
||||
img2x.save(os.path.join(OUT_DIR, "128x128@2x.png"))
|
||||
elif s == 32:
|
||||
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,
|
||||
)
|
||||
imgs[0].save(os.path.join(OUT_DIR, "icon.ico"), format="ICO", sizes=ico_sizes)
|
||||
|
||||
# Simple SVG favicon for the web
|
||||
# --- Web favicon / app SVG ---
|
||||
svg = '''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#3b82f6"/>
|
||||
<stop offset="100%" stop-color="#8b5cf6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="50" cy="50" r="48" fill="url(#g)"/>
|
||||
<text x="50" y="66" text-anchor="middle" font-size="50" fill="white" font-family="system-ui, -apple-system, sans-serif">π</text>
|
||||
<rect width="100" height="100" rx="22" fill="url(#g)"/>
|
||||
<text x="48" y="70" text-anchor="middle" font-size="52" font-weight="700"
|
||||
fill="white" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">K</text>
|
||||
<circle cx="74" cy="26" r="9" fill="white" opacity="0.35"/>
|
||||
<circle cx="74" cy="26" r="6.5" fill="white" opacity="0.92"/>
|
||||
</svg>'''
|
||||
with open(SVG_PATH, "w", encoding="utf-8") as f:
|
||||
f.write(svg)
|
||||
|
||||
@@ -1091,6 +1091,12 @@ 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"
|
||||
@@ -1952,12 +1958,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kimiswitch"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
"fs_extra",
|
||||
"indexmap 2.14.0",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
@@ -1965,7 +1973,9 @@ dependencies = [
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-single-instance",
|
||||
"toml 0.8.23",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3773,6 +3783,22 @@ dependencies = [
|
||||
"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"
|
||||
@@ -4883,6 +4909,15 @@ 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"
|
||||
@@ -4931,13 +4966,30 @@ dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm",
|
||||
"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"
|
||||
@@ -4974,6 +5026,12 @@ 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"
|
||||
@@ -4992,6 +5050,12 @@ 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"
|
||||
@@ -5010,12 +5074,24 @@ 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"
|
||||
@@ -5034,6 +5110,12 @@ 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"
|
||||
@@ -5052,6 +5134,12 @@ 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"
|
||||
@@ -5070,6 +5158,12 @@ 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"
|
||||
@@ -5088,6 +5182,12 @@ 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"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "kimiswitch"
|
||||
version = "0.1.0"
|
||||
description = "Pi Switch - model config manager"
|
||||
version = "0.3.0"
|
||||
description = "Kimi Switch - model config manager"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -9,13 +9,17 @@ edition = "2021"
|
||||
tauri-build = { version = "2.0.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.0.0", features = [] }
|
||||
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 = "0.4"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
anyhow = "1.0"
|
||||
regex = "1"
|
||||
walkdir = "2"
|
||||
fs_extra = "1"
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
|
||||
dirs = "5.0"
|
||||
toml = "0.8"
|
||||
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 484 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 506 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 15 KiB |
@@ -18,53 +18,108 @@ pub fn debug_log(message: String) {
|
||||
|
||||
#[tauri::command]
|
||||
pub fn load_agent_config_command(agent: Agent) -> Result<Config, String> {
|
||||
// Load from Pi Switch's own SQLite database first.
|
||||
match db::load_config(&agent) {
|
||||
Ok(config) if !config.providers.is_empty() => Ok(config),
|
||||
_ => {
|
||||
// Fallback to the agent's native config on first use.
|
||||
match agent {
|
||||
Agent::KimiCode => crate::kimi_code_io::load_kimi_code_config_as_config()
|
||||
.map_err(fmt_anyhow),
|
||||
Agent::Pi => {
|
||||
let file = pi_io::load_pi_models().map_err(fmt_anyhow)?;
|
||||
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());
|
||||
// Load Kimi Switch's own SQLite database (metadata + migration fallback).
|
||||
let db_config = db::load_config(&agent).ok();
|
||||
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
Agent::Pi => {
|
||||
// Pi: SQLite first, fall back to native config on first use.
|
||||
if let Some(config) = db_config {
|
||||
if !config.providers.is_empty() {
|
||||
return Ok(config);
|
||||
}
|
||||
}
|
||||
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 save_agent_config_command(agent: Agent, config: Config) -> Result<(), String> {
|
||||
// Save the full Pi Switch configuration to local SQLite.
|
||||
db::save_config(&agent, &config).map_err(fmt_anyhow)
|
||||
// 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)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn activate_agent_config_command(agent: Agent) -> Result<(), String> {
|
||||
// Load the full config from SQLite and write only the active provider
|
||||
// to the agent's native config file.
|
||||
let config = db::load_config(&agent).map_err(fmt_anyhow)?;
|
||||
let active_config = build_active_config(&config);
|
||||
match agent {
|
||||
Agent::KimiCode => crate::kimi_code_io::save_config_as_kimi_code(&active_config)
|
||||
.map_err(fmt_anyhow),
|
||||
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)?;
|
||||
|
||||
@@ -87,9 +142,10 @@ fn active_provider_and_model(config: &Config) -> Option<(String, String)> {
|
||||
}
|
||||
|
||||
fn build_active_config(config: &Config) -> Config {
|
||||
// Only the provider explicitly marked as active is written to the agent's
|
||||
// native config. This ensures Kimi Code / Pi follow Pi Switch's choice
|
||||
// instead of falling back to a managed/native provider.
|
||||
// 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<String, Provider> = config
|
||||
.providers
|
||||
.iter()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Pi Switch local SQLite storage.
|
||||
//! Kimi Switch local SQLite storage.
|
||||
//!
|
||||
//! This module stores the full Pi Switch configuration (all providers and models
|
||||
//! 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.
|
||||
@@ -16,17 +16,43 @@ use crate::models::{Agent, Config, Model, Provider, ProviderType};
|
||||
|
||||
pub type DbResult<T> = anyhow::Result<T>;
|
||||
|
||||
pub fn pi_switch_data_dir() -> PathBuf {
|
||||
pub fn kimi_switch_data_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join(".pi-switch"))
|
||||
.map(|h| h.join(".kimi-switch"))
|
||||
.expect("failed to resolve home directory")
|
||||
}
|
||||
|
||||
pub fn db_path() -> PathBuf {
|
||||
pi_switch_data_dir().join("pi-switch.db")
|
||||
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<Connection> {
|
||||
migrate_legacy_data_dir();
|
||||
let path = db_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
@@ -64,7 +90,6 @@ pub fn init_db() -> DbResult<Connection> {
|
||||
model TEXT NOT NULL,
|
||||
max_context_size INTEGER NOT NULL,
|
||||
display_name TEXT,
|
||||
role TEXT,
|
||||
supports_1m INTEGER NOT NULL DEFAULT 0,
|
||||
capabilities TEXT,
|
||||
raw_other TEXT,
|
||||
@@ -128,20 +153,19 @@ pub fn load_config(agent: &Agent) -> DbResult<Config> {
|
||||
let mut models = IndexMap::new();
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"SELECT alias, provider_name, model, max_context_size, display_name, role, supports_1m, capabilities, raw_other
|
||||
"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<String> = row.get(7)?;
|
||||
let raw_json: Option<String> = row.get(8)?;
|
||||
let caps_json: Option<String> = row.get(6)?;
|
||||
let raw_json: Option<String> = 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)?,
|
||||
role: row.get(5)?,
|
||||
supports_1m: row.get::<_, i32>(6)? != 0,
|
||||
supports_1m: row.get::<_, i32>(5)? != 0,
|
||||
capabilities: caps_json
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default(),
|
||||
@@ -202,8 +226,8 @@ pub fn save_config(agent: &Agent, config: &Config) -> DbResult<()> {
|
||||
{
|
||||
let mut insert_model = tx.prepare(
|
||||
"INSERT INTO models
|
||||
(agent, alias, provider_name, model, max_context_size, display_name, role, supports_1m, capabilities, raw_other)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
(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() {
|
||||
@@ -214,7 +238,6 @@ pub fn save_config(agent: &Agent, config: &Config) -> DbResult<()> {
|
||||
model.model,
|
||||
model.max_context_size as i64,
|
||||
model.display_name,
|
||||
model.role,
|
||||
model.supports_1m as i32,
|
||||
serde_json::to_string(&model.capabilities).ok(),
|
||||
serde_json::to_string(&model.raw_other).ok(),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! Kimi Code stores its configuration in `~/.kimi-code/config.toml`.
|
||||
//! This module reads/writes that file and converts between Kimi's TOML
|
||||
//! format and Pi Switch's internal `Config`/`Provider`/`Model` types.
|
||||
//! format and Kimi Switch's internal `Config`/`Provider`/`Model` types.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -64,7 +64,7 @@ pub fn save_kimi_code_config(value: &TomlValue) -> KimiResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Import a Kimi Code TOML config into Pi Switch's internal `Config`.
|
||||
/// 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();
|
||||
|
||||
@@ -76,6 +76,72 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
|
||||
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<String> = table
|
||||
.get("capabilities")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let supports_1m = 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();
|
||||
@@ -108,8 +174,13 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
|
||||
rest.remove("api_key");
|
||||
rest.remove("managed");
|
||||
rest.remove("enabled");
|
||||
rest.remove("oauth");
|
||||
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))
|
||||
};
|
||||
|
||||
@@ -125,68 +196,7 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
|
||||
official_url: None,
|
||||
managed,
|
||||
enabled,
|
||||
active: true,
|
||||
raw_other,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(models_table) = root.get("models").and_then(|v| v.as_table()) {
|
||||
for (alias, mv) in models_table {
|
||||
let table = mv.as_table().cloned().unwrap_or_default();
|
||||
let provider = table
|
||||
.get("provider")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default();
|
||||
let model_id = table
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default();
|
||||
let max_context_size = table
|
||||
.get("max_context_size")
|
||||
.and_then(|v| v.as_integer())
|
||||
.map(|n| n as u64)
|
||||
.unwrap_or(128_000);
|
||||
let display_name = table
|
||||
.get("display_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let capabilities: Vec<String> = table
|
||||
.get("capabilities")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let supports_1m = capabilities.iter().any(|c| c == "thinking")
|
||||
|| max_context_size >= 1_000_000;
|
||||
|
||||
let raw_other = {
|
||||
let mut rest = table.clone();
|
||||
rest.remove("provider");
|
||||
rest.remove("model");
|
||||
rest.remove("max_context_size");
|
||||
rest.remove("display_name");
|
||||
rest.remove("capabilities");
|
||||
toml_value_to_json(&TomlValue::Table(rest))
|
||||
};
|
||||
|
||||
models.insert(
|
||||
alias.clone(),
|
||||
Model {
|
||||
alias: alias.clone(),
|
||||
provider,
|
||||
model: model_id,
|
||||
max_context_size,
|
||||
display_name,
|
||||
role: None,
|
||||
supports_1m,
|
||||
capabilities,
|
||||
active: active_provider_name == Some(name.as_str()),
|
||||
raw_other,
|
||||
},
|
||||
);
|
||||
@@ -209,7 +219,7 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// Export Pi Switch's internal `Config` to a Kimi Code TOML config value.
|
||||
/// 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.
|
||||
@@ -229,11 +239,10 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
|
||||
|
||||
let mut providers_table = Table::new();
|
||||
for (name, provider) in &config.providers {
|
||||
// Only write the active provider to Kimi Code config so the agent
|
||||
// follows Pi Switch's selection.
|
||||
if !provider.active {
|
||||
continue;
|
||||
}
|
||||
// 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()) {
|
||||
@@ -268,6 +277,10 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
|
||||
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);
|
||||
}
|
||||
@@ -276,19 +289,10 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
|
||||
}
|
||||
root.insert("providers".to_string(), TomlValue::Table(providers_table));
|
||||
|
||||
// Collect provider names that survived the filter above.
|
||||
let active_provider_names: std::collections::HashSet<&str> = config
|
||||
.providers
|
||||
.values()
|
||||
.filter(|p| p.active)
|
||||
.map(|p| p.name.as_str())
|
||||
.collect();
|
||||
|
||||
// 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 {
|
||||
if !active_provider_names.contains(model.provider.as_str()) {
|
||||
continue;
|
||||
}
|
||||
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()));
|
||||
@@ -299,10 +303,7 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
|
||||
if let Some(display_name) = model.display_name.clone().filter(|s| !s.is_empty()) {
|
||||
mt.insert("display_name".to_string(), TomlValue::String(display_name));
|
||||
}
|
||||
let mut capabilities = model.capabilities.clone();
|
||||
if model.supports_1m && !capabilities.iter().any(|c| c == "thinking") {
|
||||
capabilities.push("thinking".to_string());
|
||||
}
|
||||
let capabilities = model.capabilities.clone();
|
||||
if !capabilities.is_empty() {
|
||||
mt.insert(
|
||||
"capabilities".to_string(),
|
||||
@@ -461,7 +462,8 @@ api_key = ""
|
||||
assert_eq!(model.model, "kimi-for-coding");
|
||||
assert_eq!(model.max_context_size, 262144);
|
||||
assert!(model.capabilities.contains(&"thinking".to_string()));
|
||||
assert!(model.supports_1m);
|
||||
// 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");
|
||||
@@ -496,7 +498,6 @@ api_key = ""
|
||||
model: "glm-5.2".to_string(),
|
||||
max_context_size: 900_000,
|
||||
display_name: None,
|
||||
role: None,
|
||||
supports_1m: true,
|
||||
capabilities: vec!["thinking".to_string()],
|
||||
raw_other: Value::Null,
|
||||
@@ -527,4 +528,198 @@ api_key = ""
|
||||
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,
|
||||
raw_other: Value::Null,
|
||||
},
|
||||
);
|
||||
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,
|
||||
raw_other: Value::Null,
|
||||
},
|
||||
);
|
||||
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,
|
||||
raw_other: Value::Null,
|
||||
},
|
||||
);
|
||||
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,
|
||||
raw_other: serde_json::json!({"default_model": "some-alias"}),
|
||||
},
|
||||
);
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,97 @@
|
||||
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;
|
||||
|
||||
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") {
|
||||
if window.is_visible().unwrap_or(true) {
|
||||
let _ = window.hide();
|
||||
} else {
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
// Minimize to tray: restore then hide so the taskbar button disappears
|
||||
let window_clone = window.clone();
|
||||
window.on_window_event(move |event| match event {
|
||||
tauri::WindowEvent::Resized(_) => {
|
||||
if window_clone.is_minimized().unwrap_or(false) {
|
||||
let _ = window_clone.unminimize();
|
||||
let _ = window_clone.hide();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::load_agent_config_command,
|
||||
commands::save_agent_config_command,
|
||||
@@ -16,6 +100,15 @@ pub fn run() {
|
||||
commands::get_app_version,
|
||||
commands::list_provider_models,
|
||||
commands::debug_log,
|
||||
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");
|
||||
|
||||
@@ -129,8 +129,6 @@ pub struct Model {
|
||||
pub model: String,
|
||||
pub max_context_size: u64,
|
||||
pub display_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub role: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub supports_1m: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
@@ -147,7 +145,6 @@ impl std::fmt::Debug for Model {
|
||||
.field("model", &self.model)
|
||||
.field("max_context_size", &self.max_context_size)
|
||||
.field("display_name", &self.display_name)
|
||||
.field("role", &self.role)
|
||||
.field("supports_1m", &self.supports_1m)
|
||||
.field("capabilities", &self.capabilities)
|
||||
.field("raw_other", &"<json>")
|
||||
@@ -162,7 +159,6 @@ impl PartialEq for Model {
|
||||
&& self.model == other.model
|
||||
&& self.max_context_size == other.max_context_size
|
||||
&& self.display_name == other.display_name
|
||||
&& self.role == other.role
|
||||
&& self.supports_1m == other.supports_1m
|
||||
&& self.capabilities == other.capabilities
|
||||
&& self.raw_other == other.raw_other
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! Pi stores custom providers and models in `~/.pi/agent/models.json`.
|
||||
//! This module reads and writes that file and converts between Pi's JSON
|
||||
//! format and Pi Switch's internal `Config`/`Provider`/`Model` types.
|
||||
//! format and Kimi Switch's internal `Config`/`Provider`/`Model` types.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -150,7 +150,7 @@ pub fn pi_settings_path() -> PathBuf {
|
||||
pi_agent_dir().join("settings.json")
|
||||
}
|
||||
|
||||
/// Pi's `settings.json` file (only the fields Pi Switch manipulates).
|
||||
/// 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")]
|
||||
@@ -197,7 +197,7 @@ pub fn save_pi_settings(file: &PiSettingsFile) -> PiResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Convert a Pi Switch provider type to the Pi `api` field value.
|
||||
/// 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",
|
||||
@@ -210,7 +210,7 @@ pub fn pi_api_for_provider(provider_type: &ProviderType) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a Pi `api` field value back to a Pi Switch provider type.
|
||||
/// 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,
|
||||
@@ -222,7 +222,7 @@ pub fn provider_type_for_pi_api(api: &str) -> ProviderType {
|
||||
}
|
||||
|
||||
/// Merge provider-level fields that have dedicated `PiProvider` fields into the
|
||||
/// `raw_other` blob so they survive the round-trip through Pi Switch's internal
|
||||
/// `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()
|
||||
@@ -249,7 +249,7 @@ fn merge_provider_known_into_raw(pi_provider: &PiProvider, raw: &mut Value) {
|
||||
}
|
||||
|
||||
/// Merge model-level fields that have dedicated `PiModel` fields into the
|
||||
/// `raw_other` blob so they survive the round-trip through Pi Switch's internal
|
||||
/// `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()
|
||||
@@ -310,7 +310,7 @@ fn extract_model_fields(raw: &Value) -> (Option<PiCost>, Option<Value>, Option<u
|
||||
(cost, compat, max_tokens, extra)
|
||||
}
|
||||
|
||||
/// Import a Pi `models.json` into a Pi Switch `Config`.
|
||||
/// 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();
|
||||
@@ -364,7 +364,6 @@ pub fn pi_file_to_config(file: &PiModelsFile) -> Config {
|
||||
model: pi_model.id.clone(),
|
||||
max_context_size: pi_model.context_window,
|
||||
display_name,
|
||||
role: None,
|
||||
supports_1m: pi_model.reasoning || pi_model.context_window >= 1_000_000,
|
||||
capabilities: vec![],
|
||||
raw_other: model_raw,
|
||||
@@ -383,7 +382,7 @@ pub fn pi_file_to_config(file: &PiModelsFile) -> Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// Export a Pi Switch `Config` to a Pi `models.json`.
|
||||
/// Export a Kimi Switch `Config` to a Pi `models.json`.
|
||||
pub fn config_to_pi_file(config: &Config) -> PiModelsFile {
|
||||
let mut providers = IndexMap::new();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"productName": "Pi Switch",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.piswitch.app",
|
||||
"productName": "Kimi Switch",
|
||||
"version": "0.3.0",
|
||||
"identifier": "com.kimiswitch.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
@@ -11,7 +11,7 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Pi Switch",
|
||||
"title": "Kimi Switch",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"minWidth": 1000,
|
||||
|
||||
@@ -4,11 +4,14 @@ import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { useConfig } from "./hooks/useConfig";
|
||||
import { ProviderList } from "./components/ProviderList";
|
||||
import { ProviderEdit } from "./components/ProviderEdit";
|
||||
import { DashboardPage } from "./components/dashboard/DashboardPage";
|
||||
import { SessionsPage } from "./components/sessions/SessionsPage";
|
||||
import { useTranslation } from "./i18n";
|
||||
import { getDefaultMaxContextSize } from "./lib/model-defaults";
|
||||
import { getModelRef } from "./lib/models-dev";
|
||||
import type { Agent, Model, Provider } from "./types";
|
||||
|
||||
const AGENT_STORAGE_KEY = "pi-switch-agent";
|
||||
const AGENT_STORAGE_KEY = "kimi-switch-agent";
|
||||
|
||||
function getProviderDefaultModel(provider: Provider): string | null {
|
||||
const raw = provider.raw_other as Record<string, unknown> | undefined;
|
||||
@@ -34,24 +37,19 @@ function findFirstModelForProvider(models: Record<string, Model>, providerName:
|
||||
return null;
|
||||
}
|
||||
|
||||
const AGENTS: { key: Agent; label: string }[] = [
|
||||
{ key: "kimi_code", label: "Kimi Code" },
|
||||
{ key: "pi", label: "Pi" },
|
||||
];
|
||||
|
||||
function getInitialAgent(): Agent {
|
||||
try {
|
||||
const stored = localStorage.getItem(AGENT_STORAGE_KEY) as Agent | null;
|
||||
if (stored === "kimi_code" || stored === "pi") return stored;
|
||||
if (stored === "kimi_code") return stored;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return "pi";
|
||||
return "kimi_code";
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { t, lang, setLang } = useTranslation();
|
||||
const [agent, setAgentState] = useState<Agent>(getInitialAgent);
|
||||
const [agent] = useState<Agent>(getInitialAgent);
|
||||
const {
|
||||
config,
|
||||
dirty,
|
||||
@@ -62,20 +60,11 @@ export default function App() {
|
||||
updateConfig,
|
||||
} = useConfig(agent);
|
||||
|
||||
const [view, setView] = useState<"list" | "edit">("list");
|
||||
const [view, setView] = useState<"list" | "edit" | "dashboard" | "sessions">("list");
|
||||
const [editingProvider, setEditingProvider] = useState<string>("");
|
||||
const [loadTimeout, setLoadTimeout] = useState(false);
|
||||
const [switchMessage, setSwitchMessage] = useState<string | null>(null);
|
||||
|
||||
const setAgent = (next: Agent) => {
|
||||
setAgentState(next);
|
||||
try {
|
||||
localStorage.setItem(AGENT_STORAGE_KEY, next);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setView("list");
|
||||
setEditingProvider("");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
@@ -241,26 +230,31 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
|
||||
// Only the selected provider is active. All other providers (including
|
||||
// Kimi native/managed and custom) are deactivated so the target agent
|
||||
// follows Pi Switch's choice exactly.
|
||||
// Rebuild providers with the selected one moved to the front so it is
|
||||
// visually prioritised. All others are deactivated.
|
||||
const providers: Record<string, Provider> = {};
|
||||
for (const [key, p] of Object.entries(cfg.providers)) {
|
||||
providers[key] = { ...p, active: key === name };
|
||||
}
|
||||
providers[name] = { ...target, active: true };
|
||||
|
||||
// Remember the active provider's current default model (if it belongs to
|
||||
// that provider) so switching back later restores the user's choice.
|
||||
if (activeProviderName && cfg.default_model) {
|
||||
const activeProvider = providers[activeProviderName];
|
||||
if (activeProvider && cfg.models[cfg.default_model]?.provider === activeProviderName) {
|
||||
// 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(
|
||||
activeProvider,
|
||||
{ ...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);
|
||||
@@ -271,9 +265,19 @@ export default function App() {
|
||||
return { ...cfg, providers, default_model };
|
||||
});
|
||||
|
||||
// Persist the full config to Pi Switch's SQLite and activate the selected provider.
|
||||
// Persist the full config to Kimi Switch's SQLite and activate the selected provider.
|
||||
await save();
|
||||
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 handleApplyProviderJson = (provider: Provider, models: Model[]) => {
|
||||
@@ -304,7 +308,10 @@ export default function App() {
|
||||
...m,
|
||||
alias,
|
||||
provider: provider.name,
|
||||
max_context_size: m.max_context_size ?? getDefaultMaxContextSize(m.model),
|
||||
max_context_size:
|
||||
m.max_context_size ??
|
||||
getModelRef(m.model)?.context ??
|
||||
getDefaultMaxContextSize(m.model),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -357,20 +364,39 @@ export default function App() {
|
||||
<div className="flex flex-col h-full bg-[#0f0f11] text-[#e5e5e7]">
|
||||
<header className="flex items-center justify-between gap-3 px-4 py-3 border-b border-[#2a2a2e] bg-[#16161a]">
|
||||
<div className="flex items-center bg-[#1f1f23] border border-[#2a2a2e] rounded p-0.5">
|
||||
{AGENTS.map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => setAgent(key)}
|
||||
className={`px-3 py-1 text-sm rounded transition-colors ${
|
||||
agent === key
|
||||
? "bg-blue-600 text-white"
|
||||
: "text-gray-400 hover:text-[#e5e5e7]"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("list")}
|
||||
className={`px-3 py-1 text-sm rounded transition-colors ${
|
||||
view === "list" || view === "edit"
|
||||
? "bg-blue-600 text-white"
|
||||
: "text-gray-400 hover:text-[#e5e5e7]"
|
||||
}`}
|
||||
>
|
||||
{t("providers")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("dashboard")}
|
||||
className={`px-3 py-1 text-sm rounded transition-colors ${
|
||||
view === "dashboard"
|
||||
? "bg-blue-600 text-white"
|
||||
: "text-gray-400 hover:text-[#e5e5e7]"
|
||||
}`}
|
||||
>
|
||||
{t("dashboard")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("sessions")}
|
||||
className={`px-3 py-1 text-sm rounded transition-colors ${
|
||||
view === "sessions"
|
||||
? "bg-blue-600 text-white"
|
||||
: "text-gray-400 hover:text-[#e5e5e7]"
|
||||
}`}
|
||||
>
|
||||
{t("sessions")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
@@ -391,8 +417,18 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{switchMessage && (
|
||||
<div className="bg-green-900/20 text-green-400 p-2 text-sm border-b border-green-500/20 text-center">
|
||||
{switchMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className="flex-1 overflow-hidden relative">
|
||||
{view === "list" ? (
|
||||
{view === "dashboard" ? (
|
||||
<DashboardPage />
|
||||
) : view === "sessions" ? (
|
||||
<SessionsPage />
|
||||
) : view === "list" ? (
|
||||
<ProviderList
|
||||
providers={providers}
|
||||
defaultModel={config.default_model}
|
||||
@@ -452,11 +488,6 @@ export default function App() {
|
||||
});
|
||||
}}
|
||||
onModelAdd={() => {
|
||||
const providerModels = Object.values(config.models).filter(
|
||||
(m) => m.provider === currentProvider.name
|
||||
);
|
||||
const roles = ["Sonnet", "Opus", "Fable", "Haiku"];
|
||||
const defaultRole = roles[providerModels.length % roles.length];
|
||||
const alias = `新模型[${currentProvider.name}]`;
|
||||
updateConfig((cfg) => ({
|
||||
...cfg,
|
||||
@@ -468,9 +499,8 @@ export default function App() {
|
||||
model: "",
|
||||
max_context_size: getDefaultMaxContextSize(alias),
|
||||
display_name: null,
|
||||
role: defaultRole,
|
||||
supports_1m: false,
|
||||
capabilities: [],
|
||||
capabilities: agent === "kimi_code" ? ["thinking"] : [],
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -508,6 +538,7 @@ export default function App() {
|
||||
)}
|
||||
</main>
|
||||
|
||||
{view !== "dashboard" && view !== "sessions" && (
|
||||
<footer className="flex items-center justify-between px-4 py-2 border-t border-[#2a2a2e] bg-[#16161a] text-sm">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{dirty && (
|
||||
@@ -555,6 +586,7 @@ export default function App() {
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,31 @@
|
||||
import { useEffect, useId, useState } from "react";
|
||||
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 { AgentSettingsPanel } from "./AgentSettingsPanel";
|
||||
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",
|
||||
@@ -346,6 +367,7 @@ function ModelMapping({
|
||||
const [discovered, setDiscovered] = useState<DiscoveredModel[] | null>(null);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [discoverError, setDiscoverError] = useState<string | null>(null);
|
||||
const [fetchThinking, setFetchThinking] = useState(true);
|
||||
|
||||
const handleDiscover = async () => {
|
||||
setDiscovering(true);
|
||||
@@ -379,17 +401,24 @@ function ModelMapping({
|
||||
for (const dm of discovered) {
|
||||
if (!selected.has(dm.id)) continue;
|
||||
const alias = dm.id.replace(/[^a-zA-Z0-9_-]/g, "-");
|
||||
const role = `${dm.id}[${provider.name}]`;
|
||||
const max_context_size = dm.max_context_size ?? getDefaultMaxContextSize(dm.id);
|
||||
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,
|
||||
role,
|
||||
display_name: dm.display_name ?? ref?.name ?? null,
|
||||
supports_1m: max_context_size >= 1_000_000,
|
||||
capabilities: [],
|
||||
// Reference data wins when available; otherwise keep the old
|
||||
// fetchThinking-checkbox behavior as the fallback.
|
||||
capabilities: ref
|
||||
? capabilitiesFromRef(ref)
|
||||
: fetchThinking
|
||||
? ["thinking"]
|
||||
: [],
|
||||
});
|
||||
}
|
||||
onBulkAdd(toAdd);
|
||||
@@ -434,6 +463,15 @@ function ModelMapping({
|
||||
<div className="text-sm font-medium">
|
||||
{t("discoveredModels", { count: discovered.length })}
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-[#e5e5e7] cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fetchThinking}
|
||||
onChange={(e) => setFetchThinking(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-[#2a2a2e] bg-[#1f1f23] text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
{t("fetchEnableThinking")}
|
||||
</label>
|
||||
<div className="max-h-48 overflow-auto space-y-1">
|
||||
{discovered.map((m) => (
|
||||
<label
|
||||
@@ -467,7 +505,6 @@ function ModelMapping({
|
||||
<table className="w-full min-w-[640px] text-sm">
|
||||
<thead className="bg-[#1f1f23] text-gray-400">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium">{t("modelRole")}</th>
|
||||
<th className="text-left px-4 py-3 font-medium">{t("displayName")}</th>
|
||||
<th className="text-left px-4 py-3 font-medium">{t("actualModel")}</th>
|
||||
<th className="text-left px-4 py-3 font-medium w-28">{t("contextSize")}</th>
|
||||
@@ -482,18 +519,6 @@ function ModelMapping({
|
||||
<tbody className="divide-y divide-[#2a2a2e]">
|
||||
{models.map((m) => (
|
||||
<tr key={m.alias} className="hover:bg-[#1c1c20]">
|
||||
<td className="px-4 py-2">
|
||||
<select
|
||||
className="w-full bg-[#1f1f23] border border-[#2a2a2e] rounded px-2 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
value={m.role || "Sonnet"}
|
||||
onChange={(e) => onModelChange({ ...m, role: e.target.value })}
|
||||
>
|
||||
<option value="Sonnet">Sonnet</option>
|
||||
<option value="Opus">Opus</option>
|
||||
<option value="Fable">Fable</option>
|
||||
<option value="Haiku">Haiku</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className="text-sm text-gray-400">
|
||||
{m.model ? `${m.model}[${m.provider}]` : m.alias}
|
||||
@@ -539,18 +564,10 @@ function ModelMapping({
|
||||
</td>
|
||||
{agent === "kimi_code" && (
|
||||
<td className="px-4 py-2">
|
||||
<input
|
||||
className="w-full bg-transparent border border-[#2a2a2e] rounded px-2 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
value={(m.capabilities || []).join(", ")}
|
||||
placeholder="thinking, image_in"
|
||||
onChange={(e) =>
|
||||
onModelChange({
|
||||
...m,
|
||||
capabilities: e.target.value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
<CapabilitiesCell
|
||||
capabilities={m.capabilities || []}
|
||||
onChange={(next) =>
|
||||
onModelChange({ ...m, capabilities: next })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
@@ -668,3 +685,55 @@ function JsonPreview({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CapabilitiesCell({
|
||||
capabilities,
|
||||
onChange,
|
||||
}: {
|
||||
capabilities: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const known = new Set<string>(KNOWN_CAPABILITIES);
|
||||
const customs = capabilities.filter((c) => !known.has(c));
|
||||
|
||||
const toggle = (cap: string, on: boolean) => {
|
||||
if (on) {
|
||||
onChange([...capabilities.filter((c) => c !== cap), cap]);
|
||||
} else {
|
||||
onChange(capabilities.filter((c) => c !== cap));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||
{KNOWN_CAPABILITIES.map((cap) => (
|
||||
<label
|
||||
key={cap}
|
||||
className="flex items-center gap-1 text-xs text-gray-400 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={capabilities.includes(cap)}
|
||||
onChange={(e) => toggle(cap, e.target.checked)}
|
||||
className="w-3.5 h-3.5 rounded border-[#2a2a2e] bg-[#1f1f23] text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
{t(CAPABILITY_LABELS[cap])}
|
||||
</label>
|
||||
))}
|
||||
<input
|
||||
className="w-28 bg-transparent border border-[#2a2a2e] rounded px-1.5 py-1 text-xs focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
value={customs.join(", ")}
|
||||
placeholder={t("customCapabilities")}
|
||||
onChange={(e) => {
|
||||
const newCustoms = e.target.value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const kept = capabilities.filter((c) => known.has(c));
|
||||
onChange([...kept, ...newCustoms]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -66,9 +66,21 @@ export function ProviderList({
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
title={t("addProvider")}
|
||||
className="w-9 h-9 flex items-center justify-center rounded-full bg-orange-500 hover:bg-orange-600 text-white text-xl shadow-lg focus:ring-2 focus:ring-orange-400 focus:outline-none transition-colors"
|
||||
className="w-9 h-9 flex items-center justify-center rounded-full bg-orange-500 hover:bg-orange-600 text-white shadow-lg focus:ring-2 focus:ring-orange-400 focus:outline-none transition-colors"
|
||||
>
|
||||
+
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -80,9 +92,21 @@ export function ProviderList({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
className="mt-4 w-12 h-12 rounded-full bg-orange-500 hover:bg-orange-600 text-white text-2xl shadow-lg flex items-center justify-center mx-auto"
|
||||
className="mt-4 w-12 h-12 rounded-full bg-orange-500 hover:bg-orange-600 text-white shadow-lg flex items-center justify-center mx-auto"
|
||||
>
|
||||
+
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "../../i18n";
|
||||
import { fmtPct, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
|
||||
import type { DailyRow } from "../../types/dashboard";
|
||||
import { DailyDetailModal } from "./DailyDetailModal";
|
||||
|
||||
interface DailyBarsProps {
|
||||
daily: DailyRow[];
|
||||
/** Ordered model names for the colour palette & legend */
|
||||
modelNames: string[];
|
||||
}
|
||||
|
||||
/** Deterministic colour palette — one hue per model index */
|
||||
const PALETTE = [
|
||||
"#3b82f6", // blue
|
||||
"#22c55e", // green
|
||||
"#f97316", // orange
|
||||
"#a855f7", // purple
|
||||
"#ec4899", // pink
|
||||
"#eab308", // yellow
|
||||
"#06b6d4", // cyan
|
||||
"#f43f5e", // rose
|
||||
"#84cc16", // lime
|
||||
"#8b5cf6", // violet
|
||||
];
|
||||
|
||||
export function modelColor(index: number): string {
|
||||
return PALETTE[index % PALETTE.length];
|
||||
}
|
||||
|
||||
/** Short display name for a raw model id */
|
||||
function shortModel(model: string): string {
|
||||
const bare = model.includes("/") ? model.split("/").pop()! : model;
|
||||
return bare;
|
||||
}
|
||||
|
||||
export function DailyBars({ daily, modelNames }: DailyBarsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selected, setSelected] = useState<DailyRow | null>(null);
|
||||
|
||||
const colorMap = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
modelNames.forEach((m, i) => {
|
||||
map[m] = modelColor(i);
|
||||
});
|
||||
return map;
|
||||
}, [modelNames]);
|
||||
|
||||
if (!daily?.length) {
|
||||
return (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-gray-500">
|
||||
{t("noData")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const max = Math.max(...daily.map((d) => d.totalTokens || 0), 1);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
{modelNames.map((m) => (
|
||||
<span key={m} className="flex items-center gap-1.5 text-[11px] text-gray-400">
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 rounded-sm"
|
||||
style={{ backgroundColor: colorMap[m] }}
|
||||
/>
|
||||
{shortModel(m)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bars */}
|
||||
<div className="flex h-52 items-end gap-1 overflow-x-auto px-0.5 pb-1">
|
||||
{daily.map((d) => {
|
||||
const h = Math.max(4, Math.round(((d.totalTokens || 0) / max) * 170));
|
||||
const total = d.totalTokens || 1;
|
||||
|
||||
// Build stacked segments sorted by token count (largest at bottom)
|
||||
const segments = Object.entries(d.byModel || {})
|
||||
.map(([model, tokens]) => ({
|
||||
model,
|
||||
tokens,
|
||||
color: colorMap[model] || "#6b7280",
|
||||
height: Math.max(1, Math.round((tokens / total) * h)),
|
||||
}))
|
||||
.sort((a, b) => b.tokens - a.tokens);
|
||||
|
||||
const tooltipLines = [
|
||||
d.date,
|
||||
`总 Token: ${fmtTokens(d.totalTokens)} · ${fmtUsd(d.costUsd)} · hit ${fmtPct(d.cacheHitRate)}`,
|
||||
...segments.map(
|
||||
(s) => `${shortModel(s.model)}: ${fmtTokens(s.tokens)}`
|
||||
),
|
||||
"",
|
||||
t("doubleClickHint"),
|
||||
].join("\n");
|
||||
|
||||
return (
|
||||
<button
|
||||
key={d.date}
|
||||
type="button"
|
||||
title={tooltipLines}
|
||||
onDoubleClick={() => setSelected(d)}
|
||||
className="group flex min-w-[18px] flex-1 flex-col items-center gap-1.5 border-0 bg-transparent p-0 cursor-help"
|
||||
>
|
||||
<div
|
||||
className="w-full flex flex-col-reverse rounded-t-md rounded-b-sm overflow-hidden transition-transform group-hover:scale-105 group-active:scale-95"
|
||||
style={{ height: h }}
|
||||
>
|
||||
{segments.map((s) => (
|
||||
<div
|
||||
key={s.model}
|
||||
className="w-full transition-opacity group-hover:opacity-80"
|
||||
style={{
|
||||
height: s.height,
|
||||
backgroundColor: s.color,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="whitespace-nowrap text-[10px] text-gray-500">
|
||||
{d.date.slice(5)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selected &&
|
||||
createPortal(
|
||||
<DailyDetailModal
|
||||
day={selected}
|
||||
colorMap={colorMap}
|
||||
onClose={() => setSelected(null)}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "../../i18n";
|
||||
import { fmtPct, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
|
||||
import type { DailyRow } from "../../types/dashboard";
|
||||
import { modelColor } from "./DailyBars";
|
||||
|
||||
interface DailyDetailModalProps {
|
||||
day: DailyRow;
|
||||
/** model name → colour hex */
|
||||
colorMap: Record<string, string>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function shortModel(model: string): string {
|
||||
const bare = model.includes("/") ? model.split("/").pop()! : model;
|
||||
return bare;
|
||||
}
|
||||
|
||||
export function DailyDetailModal({ day, colorMap, onClose }: DailyDetailModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// ESC to close
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
// Lock background scroll while modal is open
|
||||
useEffect(() => {
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const total = day.totalTokens || 1;
|
||||
const segments = Object.entries(day.byModel || {})
|
||||
.map(([model, tokens]) => ({
|
||||
model,
|
||||
tokens,
|
||||
pct: (tokens / total) * 100,
|
||||
color: colorMap[model] || "#6b7280",
|
||||
}))
|
||||
.sort((a, b) => b.tokens - a.tokens);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={day.date}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-lg rounded-xl border border-[#2a2a2e] bg-[#16161a] shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between border-b border-[#2a2a2e] px-5 py-3.5">
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-wider text-gray-500">
|
||||
{t("dayDetail")}
|
||||
</div>
|
||||
<div className="mt-0.5 text-lg font-semibold text-[#e5e5e7]">
|
||||
{day.date}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t("close")}
|
||||
className="rounded-md p-1 text-gray-500 hover:bg-[#2a2a2e] hover:text-gray-200 transition-colors"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Day summary chips */}
|
||||
<div className="grid grid-cols-2 gap-2 px-5 py-3 sm:grid-cols-4">
|
||||
<SummaryChip
|
||||
label={t("totalTokens")}
|
||||
value={fmtTokens(day.totalTokens)}
|
||||
tone="default"
|
||||
/>
|
||||
<SummaryChip
|
||||
label={t("cost")}
|
||||
value={fmtUsd(day.costUsd)}
|
||||
tone="orange"
|
||||
/>
|
||||
<SummaryChip
|
||||
label={t("requests")}
|
||||
value={Math.round(day.requests).toLocaleString()}
|
||||
tone="blue"
|
||||
/>
|
||||
<SummaryChip
|
||||
label={t("cacheHitRate")}
|
||||
value={fmtPct(day.cacheHitRate)}
|
||||
tone="green"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Per-model breakdown */}
|
||||
<div className="px-5 pb-4">
|
||||
<div className="mb-2 text-[11px] uppercase tracking-wider text-gray-500">
|
||||
{t("modelDistribution")} · {segments.length}
|
||||
</div>
|
||||
{segments.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-gray-500">
|
||||
{t("noData")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5 max-h-72 overflow-y-auto pr-1">
|
||||
{segments.map((s, i) => (
|
||||
<div key={s.model} className="flex items-center gap-3 text-sm">
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 rounded-sm shrink-0"
|
||||
style={{ backgroundColor: modelColor(i) }}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span
|
||||
className="truncate text-[#e5e5e7]"
|
||||
title={s.model}
|
||||
>
|
||||
{shortModel(s.model)}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-gray-400 tabular-nums">
|
||||
{fmtTokens(s.tokens)}{" "}
|
||||
<span className="text-gray-500">
|
||||
· {s.pct.toFixed(1)}%
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-[#2a2a2e]">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${Math.max(2, s.pct)}%`,
|
||||
backgroundColor: modelColor(i),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer hint */}
|
||||
<div className="border-t border-[#2a2a2e] px-5 py-2 text-[11px] text-gray-500">
|
||||
{t("modalEscHint")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryChip({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone: "default" | "orange" | "blue" | "green";
|
||||
}) {
|
||||
const valueClass =
|
||||
tone === "orange"
|
||||
? "text-orange-400"
|
||||
: tone === "blue"
|
||||
? "text-blue-400"
|
||||
: tone === "green"
|
||||
? "text-emerald-400"
|
||||
: "text-[#e5e5e7]";
|
||||
return (
|
||||
<div className="rounded-md bg-[#1c1c20] px-3 py-2">
|
||||
<div className="text-[10px] uppercase tracking-wider text-gray-500">
|
||||
{label}
|
||||
</div>
|
||||
<div className={`mt-0.5 text-sm font-semibold tabular-nums ${valueClass}`}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useDashboard, type DashboardRange } from "../../hooks/useDashboard";
|
||||
import { fmtInt, fmtPct, fmtTime, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
|
||||
import { DailyBars } from "./DailyBars";
|
||||
import { Heatmap } from "./Heatmap";
|
||||
|
||||
const RANGES: { value: DashboardRange; label: string }[] = [
|
||||
{ value: "today", label: "今天" },
|
||||
{ value: "7d", label: "7 天" },
|
||||
{ value: "30d", label: "30 天" },
|
||||
{ value: "all", label: "全部" },
|
||||
];
|
||||
|
||||
function Card({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#2a2a2e] bg-[#16161a]">
|
||||
<div className="border-b border-[#2a2a2e] px-4 py-2.5 flex items-baseline justify-between">
|
||||
<h3 className="text-sm font-medium text-[#e5e5e7]">{title}</h3>
|
||||
{subtitle && <span className="text-xs text-gray-500">{subtitle}</span>}
|
||||
</div>
|
||||
<div className="p-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KpiCard({ label, value, sub }: { label: string; value: string; sub?: string }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#2a2a2e] bg-[#16161a] p-3">
|
||||
<div className="text-[11px] text-gray-500">{label}</div>
|
||||
<div className="mt-1 text-lg font-semibold text-[#e5e5e7] tracking-tight">
|
||||
{value}
|
||||
</div>
|
||||
{sub && <div className="mt-0.5 text-[11px] text-gray-500">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const { range, changeRange, data, loading, error, refresh } = useDashboard();
|
||||
const [showAllModels, setShowAllModels] = useState(false);
|
||||
const [recentPage, setRecentPage] = useState(1);
|
||||
|
||||
if (loading && !data) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-gray-500">
|
||||
<div className="animate-spin h-8 w-8 border-2 border-blue-500 border-t-transparent rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-red-400 mb-2">加载失败</div>
|
||||
<div className="text-sm text-gray-500">{error}</div>
|
||||
<button
|
||||
onClick={() => refresh()}
|
||||
className="mt-4 px-4 py-2 rounded bg-blue-600 hover:bg-blue-700 text-white text-sm"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const totals = data.stats.totals;
|
||||
const daily = data.stats.daily;
|
||||
const models = data.stats.models;
|
||||
const recent = data.stats.recent;
|
||||
const heatmap = data.heatmap;
|
||||
const rangeTotals = data.rangeTotals;
|
||||
const visibleModels = showAllModels ? models : models.slice(0, 8);
|
||||
|
||||
// Recent pagination
|
||||
const RECENT_PAGE_SIZE = 30;
|
||||
const recentTotal = data.stats.recentTotal;
|
||||
const recentTotalPages = Math.max(1, Math.ceil(recent.length / RECENT_PAGE_SIZE));
|
||||
const recentCurrentPage = Math.min(recentPage, recentTotalPages);
|
||||
const recentStart = (recentCurrentPage - 1) * RECENT_PAGE_SIZE;
|
||||
const recentPageRows = recent.slice(recentStart, recentStart + RECENT_PAGE_SIZE);
|
||||
|
||||
// Ordered model names for chart colour mapping
|
||||
const modelNames = models.map((m) => m.model);
|
||||
|
||||
const kpiItems = [
|
||||
{ label: "请求数", value: fmtInt(totals.requests) },
|
||||
{ label: "非缓存输入", value: fmtTokens(totals.inputOther), sub: fmtInt(totals.inputOther) },
|
||||
{ label: "输出", value: fmtTokens(totals.output), sub: fmtInt(totals.output) },
|
||||
{ label: "缓存读", value: fmtTokens(totals.inputCacheRead), sub: fmtInt(totals.inputCacheRead) },
|
||||
{ label: "缓存创建", value: fmtTokens(totals.inputCacheCreation), sub: fmtInt(totals.inputCacheCreation) },
|
||||
{ label: "缓存命中", value: fmtPct(totals.cacheHitRate) },
|
||||
{ label: "总 Token", value: fmtTokens(totals.totalTokens), sub: fmtInt(totals.totalTokens) },
|
||||
{ label: "预估费用", value: fmtUsd(totals.costUsd) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-4 space-y-4">
|
||||
{/* Range tabs + refresh */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center bg-[#1f1f23] border border-[#2a2a2e] rounded p-0.5">
|
||||
{RANGES.map((r) => (
|
||||
<button
|
||||
key={r.value}
|
||||
type="button"
|
||||
onClick={() => changeRange(r.value)}
|
||||
className={`px-3 py-1 text-sm rounded transition-colors ${
|
||||
range === r.value
|
||||
? "bg-blue-600 text-white"
|
||||
: "text-gray-400 hover:text-[#e5e5e7]"
|
||||
}`}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refresh()}
|
||||
disabled={loading}
|
||||
className="px-3 py-1.5 text-sm border border-[#2a2a2e] rounded hover:bg-[#252529] text-gray-400 hover:text-[#e5e5e7] disabled:opacity-50"
|
||||
>
|
||||
{loading ? "刷新中…" : "↻ 刷新"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Range overview cards */}
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
{RANGES.map((r) => {
|
||||
const tot = rangeTotals[r.value];
|
||||
if (!tot) return null;
|
||||
const active = range === r.value;
|
||||
return (
|
||||
<button
|
||||
key={r.value}
|
||||
type="button"
|
||||
onClick={() => changeRange(r.value)}
|
||||
className={`rounded-xl border p-4 text-left transition-colors ${
|
||||
active
|
||||
? "border-blue-500/50 ring-1 ring-blue-500/30 bg-blue-900/10"
|
||||
: "border-[#2a2a2e] bg-[#16161a] hover:border-[#3a3a42] hover:bg-[#1c1c20]"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs text-gray-500">{r.label}</div>
|
||||
<div className="mt-1 text-lg font-semibold text-[#e5e5e7] tracking-tight">
|
||||
{fmtTokens(tot.totalTokens)}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-gray-500">
|
||||
{fmtInt(tot.requests)} 次 · {fmtUsd(tot.costUsd)} · {fmtPct(tot.cacheHitRate)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* KPI strip */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 xl:grid-cols-8">
|
||||
{kpiItems.map((item) => (
|
||||
<KpiCard key={item.label} label={item.label} value={item.value} sub={item.sub} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Heatmap — full-year strip, fixed-size cells, centered */}
|
||||
<Card title="全年热力图" subtitle="按 Token 量着色">
|
||||
<Heatmap heatmap={heatmap} />
|
||||
</Card>
|
||||
|
||||
{/* Charts + Model side table */}
|
||||
<div className="grid gap-4 lg:grid-cols-[1.2fr_1fr]">
|
||||
<Card
|
||||
title="每日用量趋势"
|
||||
subtitle={
|
||||
daily.length > 30
|
||||
? `近 30 天 · 共 ${daily.length} 天`
|
||||
: `按模型着色 · ${daily.length} 天`
|
||||
}
|
||||
>
|
||||
<DailyBars
|
||||
daily={daily.length > 30 ? daily.slice(-30) : daily}
|
||||
modelNames={modelNames}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="模型用量" subtitle={`${models.length} 个模型`}>
|
||||
{models.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-gray-500">暂无数据</div>
|
||||
) : (
|
||||
<div className="max-h-[360px] overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-[#16161a]">
|
||||
<tr className="text-left text-xs text-gray-500 border-b border-[#2a2a2e]">
|
||||
<th className="pb-2 pr-3 font-normal">模型</th>
|
||||
<th className="pb-2 pr-3 font-normal text-right">请求</th>
|
||||
<th className="pb-2 pr-3 font-normal text-right">Token</th>
|
||||
<th className="pb-2 pr-3 font-normal text-right">缓存命中</th>
|
||||
<th className="pb-2 font-normal text-right">费用</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleModels.map((m) => (
|
||||
<tr
|
||||
key={m.model}
|
||||
className="border-b border-[#2a2a2e]/50 hover:bg-[#1c1c20]"
|
||||
>
|
||||
<td className="py-2 pr-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 rounded-sm shrink-0"
|
||||
style={{
|
||||
backgroundColor:
|
||||
models.indexOf(m) < 10
|
||||
? ["#3b82f6","#22c55e","#f97316","#a855f7","#ec4899","#eab308","#06b6d4","#f43f5e","#84cc16","#8b5cf6"][models.indexOf(m)]
|
||||
: "#6b7280",
|
||||
}}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[#e5e5e7] truncate">{m.modelDisplay || m.model}</div>
|
||||
{m.costEstimated && (
|
||||
<span className="text-[10px] text-yellow-600">估算</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right text-gray-400">{fmtInt(m.requests)}</td>
|
||||
<td className="py-2 pr-3 text-right text-gray-400">{fmtTokens(m.totalTokens)}</td>
|
||||
<td className="py-2 pr-3 text-right text-gray-400">{fmtPct(m.cacheHitRate)}</td>
|
||||
<td className="py-2 text-right text-orange-400">{fmtUsd(m.costUsd)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{models.length > 8 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllModels(!showAllModels)}
|
||||
className="mt-3 text-sm text-blue-500 hover:text-blue-400"
|
||||
>
|
||||
{showAllModels ? "收起" : `展开全部 (${models.length})`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recent requests */}
|
||||
<Card
|
||||
title="最近请求"
|
||||
subtitle={
|
||||
recentTotal > recent.length
|
||||
? `显示前 ${recent.length} / ${fmtInt(recentTotal)} 条`
|
||||
: `${recent.length} 条`
|
||||
}
|
||||
>
|
||||
{recent.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-gray-500">暂无数据</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-[#16161a]">
|
||||
<tr className="text-left text-xs text-gray-500 border-b border-[#2a2a2e]">
|
||||
<th className="pb-2 pr-4 font-normal">时间</th>
|
||||
<th className="pb-2 pr-4 font-normal">模型</th>
|
||||
<th className="pb-2 pr-4 font-normal text-right">输入</th>
|
||||
<th className="pb-2 pr-4 font-normal text-right">输出</th>
|
||||
<th className="pb-2 pr-4 font-normal text-right">缓存读</th>
|
||||
<th className="pb-2 font-normal text-right">费用</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentPageRows.map((r, i) => (
|
||||
<tr key={recentStart + i} className="border-b border-[#2a2a2e]/50 hover:bg-[#1c1c20]">
|
||||
<td className="py-1.5 pr-4 text-gray-500 whitespace-nowrap">
|
||||
{fmtTime(r.time)}
|
||||
</td>
|
||||
<td className="py-1.5 pr-4 text-[#e5e5e7]">{r.modelDisplay || r.model}</td>
|
||||
<td className="py-1.5 pr-4 text-right text-gray-400">{fmtTokens(r.inputOther)}</td>
|
||||
<td className="py-1.5 pr-4 text-right text-gray-400">{fmtTokens(r.output)}</td>
|
||||
<td className="py-1.5 pr-4 text-right text-gray-400">{fmtTokens(r.inputCacheRead)}</td>
|
||||
<td className="py-1.5 text-right text-orange-400">{fmtUsd(r.costUsd)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination footer */}
|
||||
<div className="mt-2 flex items-center justify-between gap-2 border-t border-[#2a2a2e] pt-2 text-xs text-gray-500">
|
||||
<span>
|
||||
第 {fmtInt(recentCurrentPage)} / {fmtInt(recentTotalPages)} 页 ·{" "}
|
||||
{fmtInt(recentStart + 1)}–{fmtInt(Math.min(recentStart + RECENT_PAGE_SIZE, recent.length))} 条 ·{" "}
|
||||
每页 {RECENT_PAGE_SIZE} 条
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={recentCurrentPage <= 1}
|
||||
onClick={() => setRecentPage(1)}
|
||||
className="px-2 py-1 rounded border border-[#2a2a2e] hover:bg-[#252529] disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
aria-label="首页"
|
||||
>
|
||||
«
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={recentCurrentPage <= 1}
|
||||
onClick={() => setRecentPage((p) => Math.max(1, p - 1))}
|
||||
className="px-2 py-1 rounded border border-[#2a2a2e] hover:bg-[#252529] disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
aria-label="上一页"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={recentCurrentPage >= recentTotalPages}
|
||||
onClick={() => setRecentPage((p) => Math.min(recentTotalPages, p + 1))}
|
||||
className="px-2 py-1 rounded border border-[#2a2a2e] hover:bg-[#252529] disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
aria-label="下一页"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={recentCurrentPage >= recentTotalPages}
|
||||
onClick={() => setRecentPage(recentTotalPages)}
|
||||
className="px-2 py-1 rounded border border-[#2a2a2e] hover:bg-[#252529] disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
aria-label="末页"
|
||||
>
|
||||
»
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Attribution footer */}
|
||||
<div className="text-xs text-gray-500 pb-2">
|
||||
扫描 {data.meta.filesScanned} 个文件 · {fmtInt(data.meta.recordCount)} 条记录 ·{" "}
|
||||
{data.meta.home}
|
||||
<br />
|
||||
仪表盘功能基于{" "}
|
||||
<a
|
||||
href="https://github.com/JochenYang/kimicode-dashboard"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:text-blue-400 underline"
|
||||
>
|
||||
kimicode-dashboard
|
||||
</a>{" "}
|
||||
(MIT,© JochenYang)移植
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "../../i18n";
|
||||
import { fmtPct, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
|
||||
import type { HeatmapCell, HeatmapData } from "../../types/dashboard";
|
||||
|
||||
interface HeatmapProps {
|
||||
heatmap: HeatmapData;
|
||||
}
|
||||
|
||||
const LEVEL_BG = [
|
||||
"bg-[#1a1a1e]", // 0 — empty / no usage
|
||||
"bg-emerald-900/55",
|
||||
"bg-emerald-700/65",
|
||||
"bg-emerald-500/85",
|
||||
"bg-emerald-400",
|
||||
];
|
||||
|
||||
const DOW_LABELS_ZH = ["日", "一", "二", "三", "四", "五", "六"];
|
||||
const DOW_LABELS_EN = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
|
||||
const CELL = 12; // px
|
||||
const GAP = 3; // px
|
||||
const LABEL_W = 28; // dow label column width
|
||||
|
||||
interface HoverState {
|
||||
cell: HeatmapCell;
|
||||
x: number; // viewport coords
|
||||
y: number;
|
||||
}
|
||||
|
||||
export function Heatmap({ heatmap }: HeatmapProps) {
|
||||
const { t, lang } = useTranslation();
|
||||
const cells = heatmap?.cells || [];
|
||||
const monthLabels = heatmap?.monthLabels || [];
|
||||
|
||||
const dowLabels = lang === "zh" ? DOW_LABELS_ZH : DOW_LABELS_EN;
|
||||
|
||||
const weekCount = useMemo(() => {
|
||||
if (!cells.length) return 0;
|
||||
return Math.max(...cells.map((c) => c.weekIndex)) + 1;
|
||||
}, [cells]);
|
||||
|
||||
const grid = useMemo(() => {
|
||||
const cols: (HeatmapCell | null)[][] = Array.from(
|
||||
{ length: weekCount },
|
||||
() => Array(7).fill(null)
|
||||
);
|
||||
for (const c of cells) {
|
||||
if (c.weekIndex >= 0 && c.weekIndex < weekCount) {
|
||||
cols[c.weekIndex][c.dow] = c;
|
||||
}
|
||||
}
|
||||
return cols;
|
||||
}, [cells, weekCount]);
|
||||
|
||||
// Tooltip state with 120ms enter delay, matching reference (delayDuration=120)
|
||||
const [hover, setHover] = useState<HoverState | null>(null);
|
||||
const enterTimer = useRef<number | null>(null);
|
||||
const leaveTimer = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (enterTimer.current) window.clearTimeout(enterTimer.current);
|
||||
if (leaveTimer.current) window.clearTimeout(leaveTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const showTooltip = (cell: HeatmapCell, e?: MouseEvent) => {
|
||||
if (leaveTimer.current) {
|
||||
window.clearTimeout(leaveTimer.current);
|
||||
leaveTimer.current = null;
|
||||
}
|
||||
enterTimer.current = window.setTimeout(() => {
|
||||
if (e) {
|
||||
setHover({ cell, x: e.clientX, y: e.clientY });
|
||||
} else {
|
||||
// Keyboard focus path — fall back to last known position
|
||||
setHover((prev) => (prev ? { ...prev, cell } : { cell, x: 0, y: 0 }));
|
||||
}
|
||||
}, 120);
|
||||
};
|
||||
|
||||
const moveTooltip = (cell: HeatmapCell, e: MouseEvent) => {
|
||||
if (hover && hover.cell.date === cell.date) {
|
||||
setHover({ cell, x: e.clientX, y: e.clientY });
|
||||
}
|
||||
};
|
||||
|
||||
const showTooltipFromFocus = (cell: HeatmapCell) => showTooltip(cell);
|
||||
|
||||
const hideTooltip = () => {
|
||||
if (enterTimer.current) {
|
||||
window.clearTimeout(enterTimer.current);
|
||||
enterTimer.current = null;
|
||||
}
|
||||
leaveTimer.current = window.setTimeout(() => {
|
||||
setHover(null);
|
||||
}, 60);
|
||||
};
|
||||
|
||||
if (!cells.length) {
|
||||
return (
|
||||
<div className="flex h-28 items-center justify-center text-sm text-gray-500">
|
||||
{t("noData")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Fixed-size year strip, centered; scrolls horizontally only when the card is narrower than the strip */}
|
||||
<div className="overflow-x-auto pb-1">
|
||||
<div
|
||||
className="mx-auto w-fit"
|
||||
style={{ minWidth: LABEL_W + weekCount * (CELL + GAP) }}
|
||||
>
|
||||
{/* month labels */}
|
||||
<div
|
||||
className="mb-1 grid"
|
||||
style={{
|
||||
gridTemplateColumns: `${LABEL_W}px repeat(${weekCount}, ${CELL}px)`,
|
||||
columnGap: GAP,
|
||||
}}
|
||||
>
|
||||
<div />
|
||||
{Array.from({ length: weekCount }, (_, wi) => {
|
||||
const label = monthLabels.find((m) => m.weekIndex === wi);
|
||||
return (
|
||||
<div
|
||||
key={wi}
|
||||
className="text-[10px] leading-none text-gray-500 whitespace-nowrap overflow-visible"
|
||||
>
|
||||
{label ? label.label : ""}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex" style={{ gap: GAP }}>
|
||||
{/* dow labels */}
|
||||
<div
|
||||
className="flex flex-col pr-0"
|
||||
style={{ width: LABEL_W, gap: GAP }}
|
||||
>
|
||||
{dowLabels.map((d, i) => (
|
||||
<div
|
||||
key={d}
|
||||
className={`flex items-center text-[10px] text-gray-500 ${
|
||||
i % 2 === 1 ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
style={{ height: CELL }}
|
||||
>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* cells */}
|
||||
<div className="flex" style={{ gap: GAP }}>
|
||||
{grid.map((week, wi) => (
|
||||
<div key={wi} className="flex flex-col" style={{ gap: GAP }}>
|
||||
{week.map((cell, di) => {
|
||||
if (!cell) {
|
||||
return (
|
||||
<div
|
||||
key={`${wi}-${di}`}
|
||||
className="rounded-[3px] bg-transparent"
|
||||
style={{ width: CELL, height: CELL }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const level = cell.level ?? 0;
|
||||
return (
|
||||
<button
|
||||
key={cell.date}
|
||||
type="button"
|
||||
aria-label={cell.date}
|
||||
onMouseEnter={(e) => showTooltip(cell, e)}
|
||||
onMouseMove={(e) => moveTooltip(cell, e)}
|
||||
onMouseLeave={hideTooltip}
|
||||
onFocus={() => showTooltipFromFocus(cell)}
|
||||
onBlur={hideTooltip}
|
||||
className={`rounded-[3px] ring-1 ring-inset ring-white/5 transition-transform hover:scale-[1.35] focus:scale-[1.35] outline-none ${LEVEL_BG[level] || LEVEL_BG[0]}`}
|
||||
style={{ width: CELL, height: CELL }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 text-[11px] text-gray-500">
|
||||
<span>
|
||||
{heatmap.start} → {heatmap.end}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>{t("heatLess")}</span>
|
||||
{[0, 1, 2, 3, 4].map((lv) => (
|
||||
<div
|
||||
key={lv}
|
||||
className={`h-3 w-3 rounded-[3px] ring-1 ring-inset ring-white/5 ${LEVEL_BG[lv]}`}
|
||||
/>
|
||||
))}
|
||||
<span>{t("heatMore")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hover &&
|
||||
createPortal(
|
||||
<HeatmapTooltip state={hover} />,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeatmapTooltip({ state }: { state: HoverState }) {
|
||||
const { t } = useTranslation();
|
||||
const { cell, x, y } = state;
|
||||
// Position above the cursor with a small offset; flip below if too close to top.
|
||||
const offset = 14;
|
||||
const style: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
top: y - offset,
|
||||
left: x,
|
||||
transform: "translate(-50%, -100%)",
|
||||
zIndex: 9999,
|
||||
pointerEvents: "none",
|
||||
};
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
className="rounded-md border border-gray-700 bg-gray-900 px-3 py-1.5 text-xs text-gray-100 shadow-xl"
|
||||
role="tooltip"
|
||||
>
|
||||
<div className="font-medium text-gray-50">{cell.date}</div>
|
||||
<div className="text-gray-400">
|
||||
{fmtTokens(cell.totalTokens)} · {fmtUsd(cell.costUsd)} ·{" "}
|
||||
{fmtInt(cell.requests)} {t("requests")}
|
||||
</div>
|
||||
<div className="text-gray-400">
|
||||
{t("cacheHitRate")}: {fmtPct(cell.cacheHitRate)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Local int formatter (same style as DailyBars in this project)
|
||||
function fmtInt(n: number): string {
|
||||
return Math.round(n).toLocaleString();
|
||||
}
|
||||
@@ -0,0 +1,763 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSessions } from "../../hooks/useSessions";
|
||||
import { fmtInt, fmtTime } from "../../lib/dashboard-format";
|
||||
import { useTranslation } from "../../i18n";
|
||||
import type { SessionRow, WorkspaceRow } from "../../types/sessions";
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
const v = Number(n) || 0;
|
||||
if (v < 1024) return `${v} B`;
|
||||
if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)} KB`;
|
||||
if (v < 1024 * 1024 * 1024) return `${(v / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(v / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function parseTime(value: string | number | null | undefined): number {
|
||||
if (value == null || value === "") return 0;
|
||||
if (typeof value === "number") return value > 1e12 ? value : value * 1000;
|
||||
const ms = Date.parse(value);
|
||||
return Number.isFinite(ms) ? ms : 0;
|
||||
}
|
||||
|
||||
function rowKey(row: SessionRow): string {
|
||||
return `${row.workspaceId}/${row.id}`;
|
||||
}
|
||||
|
||||
type ConfirmState =
|
||||
| { mode: "one"; row: SessionRow }
|
||||
| { mode: "bulk"; rows: SessionRow[] }
|
||||
| { mode: "workspace"; workspace: WorkspaceRow }
|
||||
| null;
|
||||
|
||||
type PreviewState = {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
messages: { role: string; time: number | null; text: string }[];
|
||||
truncated: boolean;
|
||||
row: SessionRow;
|
||||
} | null;
|
||||
|
||||
export function SessionsPage() {
|
||||
const { t, lang } = useTranslation();
|
||||
const locale = lang === "zh" ? "zh" : "en";
|
||||
const {
|
||||
status,
|
||||
setStatus,
|
||||
workspace,
|
||||
setWorkspace,
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
refresh,
|
||||
archiveSession,
|
||||
unarchiveSession,
|
||||
deleteSession,
|
||||
deleteWorkspace,
|
||||
getPreview,
|
||||
} = useSessions();
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [confirm, setConfirm] = useState<ConfirmState>(null);
|
||||
const [confirmBusy, setConfirmBusy] = useState(false);
|
||||
const [preview, setPreview] = useState<PreviewState>(null);
|
||||
|
||||
const workspaces = data?.workspaces ?? [];
|
||||
const sessions = data?.sessions ?? [];
|
||||
|
||||
const workspaceMap = useMemo(() => {
|
||||
const m = new Map<string, WorkspaceRow>();
|
||||
for (const w of workspaces) m.set(w.id, w);
|
||||
return m;
|
||||
}, [workspaces]);
|
||||
|
||||
const allKeys = useMemo(() => sessions.map(rowKey), [sessions]);
|
||||
const allSelected = allKeys.length > 0 && allKeys.every((k) => selected.has(k));
|
||||
const someSelected = allKeys.some((k) => selected.has(k)) && !allSelected;
|
||||
|
||||
const toggleSelect = (row: SessionRow) => {
|
||||
const key = rowKey(row);
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (allSelected) setSelected(new Set());
|
||||
else setSelected(new Set(allKeys));
|
||||
};
|
||||
|
||||
const onArchive = async (row: SessionRow) => {
|
||||
const key = rowKey(row);
|
||||
setBusyId(key);
|
||||
try {
|
||||
await archiveSession(row.workspaceId, row.id);
|
||||
setSelected(new Set());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const onUnarchive = async (row: SessionRow) => {
|
||||
const key = rowKey(row);
|
||||
setBusyId(key);
|
||||
try {
|
||||
await unarchiveSession(row.workspaceId, row.id);
|
||||
setSelected(new Set());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const openPreview = async (row: SessionRow) => {
|
||||
setPreview({
|
||||
loading: true,
|
||||
error: null,
|
||||
messages: [],
|
||||
truncated: false,
|
||||
row,
|
||||
});
|
||||
try {
|
||||
const res = await getPreview(row.workspaceId, row.id, row.status);
|
||||
setPreview({
|
||||
loading: false,
|
||||
error: null,
|
||||
messages: res.messages,
|
||||
truncated: res.truncated,
|
||||
row,
|
||||
});
|
||||
} catch (e) {
|
||||
setPreview({
|
||||
loading: false,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
messages: [],
|
||||
truncated: false,
|
||||
row,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const bulkArchive = async () => {
|
||||
const rows = sessions.filter((s) => selected.has(rowKey(s)) && s.status === "active");
|
||||
for (const row of rows) {
|
||||
try {
|
||||
await archiveSession(row.workspaceId, row.id);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
break;
|
||||
}
|
||||
}
|
||||
setSelected(new Set());
|
||||
};
|
||||
|
||||
const bulkUnarchive = async () => {
|
||||
const rows = sessions.filter((s) => selected.has(rowKey(s)) && s.status === "archived");
|
||||
for (const row of rows) {
|
||||
try {
|
||||
await unarchiveSession(row.workspaceId, row.id);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
break;
|
||||
}
|
||||
}
|
||||
setSelected(new Set());
|
||||
};
|
||||
|
||||
const runConfirm = async () => {
|
||||
if (!confirm) return;
|
||||
setConfirmBusy(true);
|
||||
try {
|
||||
if (confirm.mode === "one") {
|
||||
await deleteSession(confirm.row.workspaceId, confirm.row.id, confirm.row.status);
|
||||
} else if (confirm.mode === "bulk") {
|
||||
for (const row of confirm.rows) {
|
||||
await deleteSession(row.workspaceId, row.id, row.status);
|
||||
}
|
||||
} else if (confirm.mode === "workspace") {
|
||||
await deleteWorkspace(confirm.workspace.id);
|
||||
if (workspace === confirm.workspace.id) setWorkspace("all");
|
||||
}
|
||||
setConfirm(null);
|
||||
setSelected(new Set());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setConfirmBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const totalSessions = workspaces.reduce(
|
||||
(a, w) => a + (w.activeCount || 0) + (w.archivedCount || 0),
|
||||
0
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden p-4 space-y-3">
|
||||
<div className="flex shrink-0 flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[#e5e5e7]">{t("sessionsTitle")}</h2>
|
||||
<p className="mt-1 max-w-[60ch] text-sm text-gray-500">{t("sessionsSubtitle")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refresh()}
|
||||
disabled={loading}
|
||||
className="px-3 py-1.5 text-sm rounded border border-[#2a2a2e] hover:bg-[#252529] disabled:opacity-50"
|
||||
>
|
||||
{loading ? t("refreshing") : t("refresh")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="shrink-0 rounded-lg border border-red-500/40 bg-red-900/20 px-4 py-3 text-sm text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-[260px_1fr]">
|
||||
{/* Workspace rail */}
|
||||
<div className="flex min-h-0 flex-col overflow-hidden rounded-xl border border-[#2a2a2e] bg-[#16161a]">
|
||||
<div className="shrink-0 border-b border-[#2a2a2e] px-4 py-2.5">
|
||||
<div className="text-sm font-medium text-[#e5e5e7]">{t("workspaces")}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{t("workspaceIsolatedHint")}</div>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col p-2 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWorkspace("all")}
|
||||
className={`flex w-full shrink-0 items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors ${
|
||||
workspace === "all"
|
||||
? "bg-blue-600/20 text-[#e5e5e7]"
|
||||
: "text-gray-400 hover:bg-[#1f1f23] hover:text-[#e5e5e7]"
|
||||
}`}
|
||||
>
|
||||
<span>{t("allWorkspaces")}</span>
|
||||
<span className="text-xs tabular-nums">{fmtInt(totalSessions, locale)}</span>
|
||||
</button>
|
||||
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto mt-1">
|
||||
{workspaces.map((w) => {
|
||||
const total = (w.activeCount || 0) + (w.archivedCount || 0);
|
||||
const isEmpty = total === 0 || w.empty;
|
||||
return (
|
||||
<div
|
||||
key={w.id}
|
||||
className={`group flex items-start gap-1 rounded-md ${
|
||||
workspace === w.id ? "bg-blue-600/20" : "hover:bg-[#1f1f23]"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWorkspace(w.id)}
|
||||
className={`flex min-w-0 flex-1 flex-col gap-0.5 px-3 py-2 text-left ${
|
||||
workspace === w.id ? "text-[#e5e5e7]" : "text-gray-400 group-hover:text-[#e5e5e7]"
|
||||
}`}
|
||||
title={w.root || w.id}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-sm font-medium">{w.name || w.id}</span>
|
||||
<span className="shrink-0 text-[11px] tabular-nums">
|
||||
{isEmpty ? (
|
||||
<span className="rounded border border-[#2a2a2e] px-1.5 py-0 text-[10px] text-gray-500">
|
||||
{t("emptyWorkspace")}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{w.activeCount || 0}
|
||||
{w.archivedCount ? `/${w.archivedCount}` : ""}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{w.root && (
|
||||
<span className="truncate font-mono text-[10px] opacity-70">{w.root}</span>
|
||||
)}
|
||||
</button>
|
||||
{isEmpty && (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-1 mr-1 h-7 w-7 shrink-0 rounded text-red-400/80 opacity-70 hover:bg-red-900/30 hover:text-red-300 group-hover:opacity-100"
|
||||
title={t("deleteWorkspace")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirm({ mode: "workspace", workspace: w });
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!workspaces.length && !loading && (
|
||||
<div className="px-3 py-6 text-center text-xs text-gray-500">{t("noSessions")}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sessions table */}
|
||||
<div className="flex min-h-0 flex-col space-y-3 overflow-hidden">
|
||||
<div className="flex shrink-0 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center bg-[#1f1f23] border border-[#2a2a2e] rounded p-0.5">
|
||||
{(
|
||||
[
|
||||
["active", t("statusActive")],
|
||||
["archived", t("statusArchived")],
|
||||
["all", t("statusAll")],
|
||||
] as const
|
||||
).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStatus(value);
|
||||
setSelected(new Set());
|
||||
}}
|
||||
className={`px-3 py-1 text-sm rounded transition-colors ${
|
||||
status === value
|
||||
? "bg-blue-600 text-white"
|
||||
: "text-gray-400 hover:text-[#e5e5e7]"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{sessions.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleSelectAll}
|
||||
className="px-2 py-1 text-xs text-gray-400 hover:text-[#e5e5e7]"
|
||||
>
|
||||
{allSelected ? t("deselectAll") : t("selectAll")}
|
||||
</button>
|
||||
)}
|
||||
{selected.size > 0 && (
|
||||
<>
|
||||
<span className="text-xs text-gray-500">
|
||||
{t("selectedCount", { n: String(selected.size) })}
|
||||
</span>
|
||||
{status !== "archived" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={bulkArchive}
|
||||
className="px-2 py-1 text-xs rounded border border-[#2a2a2e] hover:bg-[#252529]"
|
||||
>
|
||||
{t("archive")}
|
||||
</button>
|
||||
)}
|
||||
{status !== "active" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={bulkUnarchive}
|
||||
className="px-2 py-1 text-xs rounded border border-[#2a2a2e] hover:bg-[#252529]"
|
||||
>
|
||||
{t("unarchive")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const rows = sessions.filter((s) => selected.has(rowKey(s)));
|
||||
if (rows.length) setConfirm({ mode: "bulk", rows });
|
||||
}}
|
||||
className="px-2 py-1 text-xs rounded border border-red-500/30 text-red-300 hover:bg-red-900/20"
|
||||
>
|
||||
{t("delete")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<select
|
||||
value={workspace}
|
||||
onChange={(e) => setWorkspace(e.target.value)}
|
||||
className="bg-[#1f1f23] border border-[#2a2a2e] rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none max-w-[200px]"
|
||||
>
|
||||
<option value="all">{t("allWorkspaces")}</option>
|
||||
{workspaces.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.name || w.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-[#2a2a2e] bg-[#16161a]">
|
||||
<div className="shrink-0 border-b border-[#2a2a2e] px-4 py-2.5">
|
||||
<div className="text-sm font-medium text-[#e5e5e7]">
|
||||
{workspace === "all"
|
||||
? t("allWorkspaces")
|
||||
: workspaceMap.get(workspace)?.name || workspace}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">
|
||||
{t("sessionsInView", { n: String(sessions.length) })}
|
||||
{workspace !== "all" ? ` · ${t("isolatedToWorkspace")}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 z-10 bg-[#16161a] text-left text-gray-500 border-b border-[#2a2a2e]">
|
||||
<tr>
|
||||
<th className="w-10 px-3 py-2 font-normal">
|
||||
<input
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someSelected;
|
||||
}}
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-blue-600"
|
||||
checked={allSelected}
|
||||
disabled={!sessions.length}
|
||||
onChange={toggleSelectAll}
|
||||
aria-label={t("selectAll")}
|
||||
/>
|
||||
</th>
|
||||
<th className="px-2 py-2 font-normal">{t("sessionTitle")}</th>
|
||||
<th className="px-2 py-2 font-normal">{t("workspace")}</th>
|
||||
<th className="px-2 py-2 font-normal">{t("status")}</th>
|
||||
<th className="px-2 py-2 font-normal text-right">{t("size")}</th>
|
||||
<th className="px-2 py-2 font-normal">{t("updated")}</th>
|
||||
<th className="px-2 py-2 font-normal text-right">{t("actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sessions.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-10 text-center text-gray-500">
|
||||
{loading ? t("scanning") : t("noSessions")}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sessions.map((row) => {
|
||||
const key = rowKey(row);
|
||||
const ws = workspaceMap.get(row.workspaceId);
|
||||
const busy = busyId === key;
|
||||
return (
|
||||
<tr
|
||||
key={key}
|
||||
className="border-b border-[#2a2a2e]/50 hover:bg-[#1c1c20]"
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-blue-600"
|
||||
checked={selected.has(key)}
|
||||
onChange={() => toggleSelect(row)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2 max-w-[280px]">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="truncate text-left font-medium text-[#e5e5e7] hover:text-blue-400 hover:underline"
|
||||
title={row.title || t("openPreview")}
|
||||
onClick={() => openPreview(row)}
|
||||
>
|
||||
{row.title || row.id}
|
||||
</button>
|
||||
<span className="truncate font-mono text-[10px] text-gray-500" title={row.id}>
|
||||
{row.id}
|
||||
</span>
|
||||
{row.workDir && (
|
||||
<span
|
||||
className="truncate font-mono text-[10px] text-gray-500/80"
|
||||
title={row.workDir}
|
||||
>
|
||||
{row.workDir}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2 py-2 max-w-[160px]">
|
||||
<span className="truncate text-sm text-gray-400">
|
||||
{ws?.name || row.workspaceId}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-2 whitespace-nowrap">
|
||||
{row.status === "archived" ? (
|
||||
<span className="rounded-md bg-amber-900/30 text-amber-300 px-2 py-0.5 text-xs">
|
||||
{t("statusArchived")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-md bg-emerald-900/30 text-emerald-300 px-2 py-0.5 text-xs">
|
||||
{t("statusActive")}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right tabular-nums text-gray-400">
|
||||
{fmtBytes(row.bytes)}
|
||||
</td>
|
||||
<td className="px-2 py-2 whitespace-nowrap tabular-nums text-gray-400">
|
||||
{fmtTime(parseTime(row.updatedAt), locale)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
{row.status === "active" ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => onArchive(row)}
|
||||
title={t("archive")}
|
||||
className="px-2 py-1 text-xs rounded border border-[#2a2a2e] hover:bg-[#252529] disabled:opacity-50"
|
||||
>
|
||||
{t("archive")}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => onUnarchive(row)}
|
||||
title={t("unarchive")}
|
||||
className="px-2 py-1 text-xs rounded border border-[#2a2a2e] hover:bg-[#252529] disabled:opacity-50"
|
||||
>
|
||||
{t("unarchive")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => setConfirm({ mode: "one", row })}
|
||||
title={t("delete")}
|
||||
className="px-2 py-1 text-xs rounded border border-red-500/30 text-red-300 hover:bg-red-900/20 disabled:opacity-50"
|
||||
>
|
||||
{t("delete")}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Attribution */}
|
||||
<div className="text-xs text-gray-500 pb-2">
|
||||
{data?.home && <>{data.home}</>}
|
||||
<br />
|
||||
会话管理功能基于{" "}
|
||||
<a
|
||||
href="https://github.com/JochenYang/kimicode-dashboard"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:text-blue-400 underline"
|
||||
>
|
||||
kimicode-dashboard
|
||||
</a>{" "}
|
||||
(MIT,© JochenYang)移植
|
||||
</div>
|
||||
|
||||
{/* Confirm dialog */}
|
||||
{confirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="w-full max-w-md rounded-xl border border-[#2a2a2e] bg-[#16161a] shadow-xl">
|
||||
<div className="space-y-3 p-5">
|
||||
<h3 className="text-base font-semibold text-[#e5e5e7]">
|
||||
{confirm.mode === "bulk"
|
||||
? t("confirmBulkTitle")
|
||||
: confirm.mode === "workspace"
|
||||
? t("confirmDeleteWorkspaceTitle")
|
||||
: t("confirmDeleteTitle")}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
{confirm.mode === "bulk"
|
||||
? t("confirmBulkDelete", { n: String(confirm.rows.length) })
|
||||
: confirm.mode === "workspace"
|
||||
? t("confirmDeleteWorkspace")
|
||||
: t("confirmDeleteSession")}
|
||||
</p>
|
||||
{confirm.mode === "one" && (
|
||||
<div className="rounded-md border border-[#2a2a2e] bg-[#1f1f23] px-3 py-2.5">
|
||||
<div className="truncate text-sm font-medium">
|
||||
{confirm.row.title || confirm.row.id}
|
||||
</div>
|
||||
<div className="mt-1 break-all font-mono text-[11px] text-gray-500">
|
||||
{confirm.row.id}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{confirm.mode === "bulk" && (
|
||||
<div className="rounded-md border border-[#2a2a2e] bg-[#1f1f23] px-3 py-2.5 text-sm text-gray-400">
|
||||
{t("selectedCount", { n: String(confirm.rows.length) })}
|
||||
</div>
|
||||
)}
|
||||
{confirm.mode === "workspace" && (
|
||||
<div className="rounded-md border border-[#2a2a2e] bg-[#1f1f23] px-3 py-2.5">
|
||||
<div className="truncate text-sm font-medium">
|
||||
{confirm.workspace.name || confirm.workspace.id}
|
||||
</div>
|
||||
{confirm.workspace.root && (
|
||||
<div className="mt-1 break-all font-mono text-[11px] text-gray-500">
|
||||
{confirm.workspace.root}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1 font-mono text-[11px] text-gray-500">
|
||||
{confirm.workspace.id}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-[#2a2a2e] bg-[#121214] px-5 py-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={confirmBusy}
|
||||
onClick={() => setConfirm(null)}
|
||||
className="px-3 py-1.5 text-sm rounded border border-[#2a2a2e] hover:bg-[#252529] disabled:opacity-50"
|
||||
>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={confirmBusy}
|
||||
onClick={runConfirm}
|
||||
className="px-3 py-1.5 text-sm rounded bg-red-600 text-white hover:bg-red-500 disabled:opacity-50"
|
||||
>
|
||||
{confirmBusy
|
||||
? t("refreshing")
|
||||
: confirm.mode === "workspace"
|
||||
? t("deleteWorkspace")
|
||||
: t("deleteForever")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview dialog */}
|
||||
{preview && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="flex w-full max-w-2xl max-h-[min(80vh,36rem)] flex-col overflow-hidden rounded-xl border border-[#2a2a2e] bg-[#16161a] shadow-xl">
|
||||
<div className="shrink-0 space-y-2 border-b border-[#2a2a2e] p-5">
|
||||
<h3 className="text-base font-semibold text-[#e5e5e7]">{t("previewTitle")}</h3>
|
||||
<p className="text-sm text-gray-500">{t("previewHint")}</p>
|
||||
<div className="text-xs text-gray-500">
|
||||
<div className="truncate font-medium text-[#e5e5e7]">
|
||||
{preview.row.title || preview.row.id}
|
||||
</div>
|
||||
<div className="mt-0.5 break-all font-mono opacity-80">{preview.row.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-5">
|
||||
{preview.loading && (
|
||||
<div className="flex items-center justify-center gap-2 py-10 text-sm text-gray-500">
|
||||
<div className="animate-spin h-4 w-4 border-2 border-blue-500 border-t-transparent rounded-full" />
|
||||
{t("scanning")}
|
||||
</div>
|
||||
)}
|
||||
{preview.error && (
|
||||
<div className="rounded-md border border-red-500/40 bg-red-900/20 px-3 py-2 text-sm text-red-300">
|
||||
{preview.error}
|
||||
</div>
|
||||
)}
|
||||
{!preview.loading && !preview.error && !preview.messages.length && (
|
||||
<div className="py-10 text-center text-sm text-gray-500">{t("previewEmpty")}</div>
|
||||
)}
|
||||
{preview.messages.map((m, i) => {
|
||||
const roleLabel =
|
||||
m.role === "user"
|
||||
? t("roleUser")
|
||||
: m.role === "assistant"
|
||||
? t("roleAssistant")
|
||||
: m.role === "system"
|
||||
? t("roleSystem")
|
||||
: m.role;
|
||||
return (
|
||||
<PreviewMessageCard
|
||||
key={`${m.role}-${i}`}
|
||||
role={m.role}
|
||||
roleLabel={roleLabel}
|
||||
time={m.time}
|
||||
text={m.text}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{preview.truncated && (
|
||||
<div className="text-center text-xs text-gray-500">{t("previewTruncated")}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 flex justify-end border-t border-[#2a2a2e] bg-[#121214] px-5 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreview(null)}
|
||||
className="px-3 py-1.5 text-sm rounded border border-[#2a2a2e] hover:bg-[#252529]"
|
||||
>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Per-message card with 500-char collapse + expand toggle.
|
||||
// Renders long texts lazily — prevents huge <pre> blocks from bloating the DOM.
|
||||
const PREVIEW_TEXT_LIMIT = 500;
|
||||
|
||||
function PreviewMessageCard({
|
||||
role,
|
||||
roleLabel,
|
||||
time,
|
||||
text,
|
||||
locale,
|
||||
}: {
|
||||
role: string;
|
||||
roleLabel: string;
|
||||
time: number | null;
|
||||
text: string;
|
||||
locale: string;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const isLong = text.length > PREVIEW_TEXT_LIMIT;
|
||||
const visible = expanded || !isLong ? text : text.slice(0, PREVIEW_TEXT_LIMIT);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border border-[#2a2a2e] px-3 py-2.5 ${
|
||||
role === "user"
|
||||
? "bg-blue-600/10"
|
||||
: role === "assistant"
|
||||
? "bg-[#1f1f23]"
|
||||
: "bg-[#16161a]"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between gap-2 text-[11px] text-gray-500">
|
||||
<span className="font-medium text-gray-300">{roleLabel}</span>
|
||||
{time ? (
|
||||
<span className="tabular-nums">{fmtTime(time, locale)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap break-words font-sans text-sm leading-relaxed text-gray-200">
|
||||
{visible}
|
||||
{!expanded && isLong && (
|
||||
<span className="text-gray-500"> …</span>
|
||||
)}
|
||||
</pre>
|
||||
{isLong && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="mt-1 text-[11px] text-blue-400 hover:text-blue-300 hover:underline"
|
||||
>
|
||||
{expanded
|
||||
? `收起 · ${text.length.toLocaleString()} 字符`
|
||||
: `展开全文 · ${text.length.toLocaleString()} 字符`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -48,11 +48,16 @@ export function useConfig(agent: Agent): UseConfigReturn {
|
||||
}, [refresh]);
|
||||
|
||||
const updateConfig = useCallback((updater: (config: Config) => Config) => {
|
||||
setConfig((prev) => {
|
||||
const next = prev ? updater(prev) : prev;
|
||||
// Compute next state synchronously from the ref (always current) and set
|
||||
// the ref BEFORE setConfig. Previously the ref was updated inside the
|
||||
// setConfig updater, which React 18 defers — so save() reading the ref
|
||||
// immediately after would get a stale value.
|
||||
const prev = configRef.current;
|
||||
if (prev) {
|
||||
const next = updater(prev);
|
||||
configRef.current = next;
|
||||
return next;
|
||||
});
|
||||
setConfig(next);
|
||||
}
|
||||
setDirty(true);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { SummaryResult } from "../types/dashboard";
|
||||
|
||||
export type DashboardRange = "today" | "7d" | "30d" | "all";
|
||||
|
||||
const RANGE_STORAGE_KEY = "kimi-switch-dashboard-range";
|
||||
|
||||
export function useDashboard() {
|
||||
const [range, setRange] = useState<DashboardRange>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(RANGE_STORAGE_KEY) as DashboardRange | null;
|
||||
if (stored === "today" || stored === "7d" || stored === "30d" || stored === "all") {
|
||||
return stored;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return "30d";
|
||||
});
|
||||
|
||||
const [data, setData] = useState<SummaryResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await invoke<SummaryResult>("get_summary", { range });
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [range]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const changeRange = useCallback((next: DashboardRange) => {
|
||||
setRange(next);
|
||||
try {
|
||||
localStorage.setItem(RANGE_STORAGE_KEY, next);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { range, changeRange, data, loading, error, refresh };
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
ActionResponse,
|
||||
PreviewResult,
|
||||
SessionStatusFilter,
|
||||
SessionsResult,
|
||||
} from "../types/sessions";
|
||||
|
||||
export function useSessions() {
|
||||
const [status, setStatus] = useState<SessionStatusFilter>("active");
|
||||
const [workspace, setWorkspace] = useState<string>("all");
|
||||
const [data, setData] = useState<SessionsResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await invoke<SessionsResult>("list_sessions", {
|
||||
status,
|
||||
workspace: workspace === "all" ? null : workspace,
|
||||
});
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
setData(null);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [status, workspace]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const archiveSession = useCallback(
|
||||
async (workspaceId: string, sessionId: string) => {
|
||||
await invoke<ActionResponse>("archive_session", { workspaceId, sessionId });
|
||||
await refresh();
|
||||
},
|
||||
[refresh]
|
||||
);
|
||||
|
||||
const unarchiveSession = useCallback(
|
||||
async (workspaceId: string, sessionId: string) => {
|
||||
await invoke<ActionResponse>("unarchive_session", {
|
||||
workspaceId,
|
||||
sessionId,
|
||||
});
|
||||
await refresh();
|
||||
},
|
||||
[refresh]
|
||||
);
|
||||
|
||||
const deleteSession = useCallback(
|
||||
async (workspaceId: string, sessionId: string, sessionStatus?: string) => {
|
||||
await invoke<ActionResponse>("delete_session", {
|
||||
workspaceId,
|
||||
sessionId,
|
||||
status: sessionStatus ?? null,
|
||||
});
|
||||
await refresh();
|
||||
},
|
||||
[refresh]
|
||||
);
|
||||
|
||||
const deleteWorkspace = useCallback(
|
||||
async (workspaceId: string) => {
|
||||
await invoke<ActionResponse>("delete_workspace", {
|
||||
workspaceId,
|
||||
confirm: true,
|
||||
});
|
||||
await refresh();
|
||||
},
|
||||
[refresh]
|
||||
);
|
||||
|
||||
const getPreview = useCallback(
|
||||
async (workspaceId: string, sessionId: string, sessionStatus?: string) => {
|
||||
return invoke<PreviewResult>("get_session_preview", {
|
||||
workspaceId,
|
||||
sessionId,
|
||||
status: sessionStatus ?? null,
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return {
|
||||
status,
|
||||
setStatus,
|
||||
workspace,
|
||||
setWorkspace,
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
refresh,
|
||||
archiveSession,
|
||||
unarchiveSession,
|
||||
deleteSession,
|
||||
deleteWorkspace,
|
||||
getPreview,
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import type { TranslationKey } from "./zh";
|
||||
|
||||
export const enTranslations: Record<TranslationKey, string> = {
|
||||
// App / global
|
||||
appTitle: "Pi Switch",
|
||||
appTitle: "Kimi Switch",
|
||||
loading: "Loading...",
|
||||
loadTimeout: "Loading is taking a while. Try restarting the app if it stays stuck.",
|
||||
loadFailed: "Failed to load:",
|
||||
@@ -20,6 +20,8 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
|
||||
// Provider list
|
||||
providers: "Providers",
|
||||
dashboard: "Dashboard",
|
||||
sessions: "Sessions",
|
||||
addProvider: "Add Provider",
|
||||
noProviders: "No providers yet",
|
||||
addFirstProvider: "Add first provider",
|
||||
@@ -58,7 +60,6 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
fetchingModels: "Fetching...",
|
||||
discoveredModels: "Discovered {count} models",
|
||||
addSelected: "Add selected",
|
||||
modelRole: "Model role",
|
||||
displayName: "Display name",
|
||||
actualModel: "Actual model",
|
||||
contextSize: "Context size",
|
||||
@@ -88,6 +89,7 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
switchTo: "Switch to",
|
||||
inUse: "In use",
|
||||
switchReloadHint: "After switching providers, run /reload in Kimi Code to apply the change.",
|
||||
reloadCopiedHint: "Provider switched. /reload copied to clipboard; paste it in Kimi Code.",
|
||||
unsavedChanges: "Unsaved changes",
|
||||
qwenActivateNotImplemented: "Qwen Code activation will be implemented in a follow-up.",
|
||||
agentSettings: "Global Settings",
|
||||
@@ -123,6 +125,78 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
importConfigConfirm: "Importing will overwrite unsaved changes. Continue?",
|
||||
importConfigFailed: "Import failed: {message}",
|
||||
capabilities: "Capabilities",
|
||||
fetchEnableThinking: "Enable thinking for fetched models",
|
||||
capThinking: "Thinking",
|
||||
capAlwaysThinking: "Always Thinking",
|
||||
capImageIn: "Image Input",
|
||||
capVideoIn: "Video Input",
|
||||
capToolUse: "Tool Use",
|
||||
customCapabilities: "Other capabilities (comma-separated)",
|
||||
|
||||
// Sessions
|
||||
sessionsTitle: "Sessions",
|
||||
sessionsSubtitle:
|
||||
"Browse, preview, archive, or delete Kimi Code sessions by workspace. Archiving keeps data on disk.",
|
||||
workspaces: "Workspaces",
|
||||
workspaceIsolatedHint: "Sessions are isolated per workspace",
|
||||
allWorkspaces: "All workspaces",
|
||||
emptyWorkspace: "Empty",
|
||||
deleteWorkspace: "Delete workspace",
|
||||
noSessions: "No sessions",
|
||||
statusActive: "Active",
|
||||
statusArchived: "Archived",
|
||||
statusAll: "All",
|
||||
selectAll: "Select all",
|
||||
deselectAll: "Deselect all",
|
||||
selectedCount: "{n} selected",
|
||||
archive: "Archive",
|
||||
unarchive: "Unarchive",
|
||||
sessionTitle: "Session",
|
||||
workspace: "Workspace",
|
||||
status: "Status",
|
||||
size: "Size",
|
||||
updated: "Updated",
|
||||
actions: "Actions",
|
||||
sessionsInView: "{n} sessions",
|
||||
isolatedToWorkspace: "this workspace only",
|
||||
openPreview: "Open preview",
|
||||
scanning: "Scanning…",
|
||||
refresh: "Refresh",
|
||||
refreshing: "Refreshing…",
|
||||
refreshed: "Refreshed",
|
||||
cancel: "Cancel",
|
||||
confirmDeleteTitle: "Delete session forever?",
|
||||
confirmDeleteSession:
|
||||
"This cannot be undone. Prefer archive if you only want it out of the way.",
|
||||
confirmBulkTitle: "Delete selected forever?",
|
||||
confirmBulkDelete: "Permanently delete {n} sessions. This cannot be undone.",
|
||||
confirmDeleteWorkspaceTitle: "Delete empty workspace?",
|
||||
confirmDeleteWorkspace: "Only workspaces with no sessions can be removed.",
|
||||
deleteForever: "Delete forever",
|
||||
previewTitle: "Session preview",
|
||||
previewHint:
|
||||
"Roles and truncated text only — secrets and raw tool dumps are omitted.",
|
||||
previewEmpty: "No previewable messages",
|
||||
previewTruncated: "Preview truncated",
|
||||
roleUser: "User",
|
||||
roleAssistant: "Assistant",
|
||||
roleSystem: "System",
|
||||
|
||||
// Heatmap
|
||||
noData: "No data",
|
||||
heatLess: "Less",
|
||||
heatMore: "More",
|
||||
requests: "Requests",
|
||||
cacheHitRate: "Cache hit rate",
|
||||
|
||||
// Daily detail modal
|
||||
dayDetail: "Daily usage detail",
|
||||
close: "Close",
|
||||
totalTokens: "Total tokens",
|
||||
cost: "Cost",
|
||||
modelDistribution: "Model distribution",
|
||||
modalEscHint: "Press Esc or click outside to close",
|
||||
doubleClickHint: "Double-click for details",
|
||||
|
||||
// Languages
|
||||
language: "Language",
|
||||
|
||||
@@ -27,7 +27,7 @@ interface I18nContextValue {
|
||||
|
||||
const I18nContext = createContext<I18nContextValue | null>(null);
|
||||
|
||||
const STORAGE_KEY = "pi-switch-lang";
|
||||
const STORAGE_KEY = "kimi-switch-lang";
|
||||
|
||||
function getInitialLang(): Language {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const zhTranslations = {
|
||||
// App / global
|
||||
appTitle: "Pi Switch",
|
||||
appTitle: "Kimi Switch",
|
||||
loading: "加载中...",
|
||||
loadTimeout: "加载时间较长,如果一直卡住,请尝试重新启动应用。",
|
||||
loadFailed: "加载失败:",
|
||||
@@ -18,6 +18,8 @@ export const zhTranslations = {
|
||||
|
||||
// Provider list
|
||||
providers: "供应商",
|
||||
dashboard: "用量仪表盘",
|
||||
sessions: "会话管理",
|
||||
addProvider: "添加供应商",
|
||||
noProviders: "暂无供应商",
|
||||
addFirstProvider: "添加第一个供应商",
|
||||
@@ -56,7 +58,6 @@ export const zhTranslations = {
|
||||
fetchingModels: "获取中...",
|
||||
discoveredModels: "发现 {count} 个模型",
|
||||
addSelected: "添加选中的",
|
||||
modelRole: "模型角色",
|
||||
displayName: "显示名称",
|
||||
actualModel: "实际请求模型",
|
||||
contextSize: "上下文长度",
|
||||
@@ -86,6 +87,7 @@ export const zhTranslations = {
|
||||
switchTo: "切换使用",
|
||||
inUse: "使用中",
|
||||
switchReloadHint: "切换供应商后,请在 Kimi Code 中执行 /reload 以生效。",
|
||||
reloadCopiedHint: "已切换供应商,/reload 已复制到剪贴板,请在 Kimi Code 中粘贴执行。",
|
||||
unsavedChanges: "未保存修改",
|
||||
qwenActivateNotImplemented: "Qwen Code 启用功能将在后续实现。",
|
||||
agentSettings: "全局配置",
|
||||
@@ -121,6 +123,75 @@ export const zhTranslations = {
|
||||
importConfigConfirm: "导入会覆盖当前未保存的修改,是否继续?",
|
||||
importConfigFailed: "导入失败:{message}",
|
||||
capabilities: "能力标签",
|
||||
fetchEnableThinking: "为获取的模型启用思考",
|
||||
capThinking: "思考",
|
||||
capAlwaysThinking: "始终思考",
|
||||
capImageIn: "图像输入",
|
||||
capVideoIn: "视频输入",
|
||||
capToolUse: "工具调用",
|
||||
customCapabilities: "其他能力(逗号分隔)",
|
||||
|
||||
// Sessions
|
||||
sessionsTitle: "会话管理",
|
||||
sessionsSubtitle: "按工作区查看、预览、归档或删除 Kimi Code 会话。归档不会删除数据。",
|
||||
workspaces: "工作区",
|
||||
workspaceIsolatedHint: "会话按工作区隔离存储",
|
||||
allWorkspaces: "全部工作区",
|
||||
emptyWorkspace: "空",
|
||||
deleteWorkspace: "删除工作区",
|
||||
noSessions: "暂无会话",
|
||||
statusActive: "活跃",
|
||||
statusArchived: "已归档",
|
||||
statusAll: "全部",
|
||||
selectAll: "全选",
|
||||
deselectAll: "取消全选",
|
||||
selectedCount: "已选 {n} 项",
|
||||
archive: "归档",
|
||||
unarchive: "取消归档",
|
||||
sessionTitle: "会话",
|
||||
workspace: "工作区",
|
||||
status: "状态",
|
||||
size: "大小",
|
||||
updated: "更新时间",
|
||||
actions: "操作",
|
||||
sessionsInView: "共 {n} 个会话",
|
||||
isolatedToWorkspace: "当前工作区",
|
||||
openPreview: "打开预览",
|
||||
scanning: "扫描中…",
|
||||
refresh: "刷新",
|
||||
refreshing: "刷新中…",
|
||||
refreshed: "已刷新",
|
||||
cancel: "取消",
|
||||
confirmDeleteTitle: "永久删除会话?",
|
||||
confirmDeleteSession: "删除后无法恢复。若只是暂时不用,请改用归档。",
|
||||
confirmBulkTitle: "批量永久删除?",
|
||||
confirmBulkDelete: "将永久删除 {n} 个会话,此操作无法撤销。",
|
||||
confirmDeleteWorkspaceTitle: "删除空工作区?",
|
||||
confirmDeleteWorkspace: "仅允许删除没有任何会话的工作区。",
|
||||
deleteForever: "永久删除",
|
||||
previewTitle: "会话预览",
|
||||
previewHint: "仅展示角色与截断后的文本,不会暴露密钥或工具原始输出。",
|
||||
previewEmpty: "没有可预览的消息",
|
||||
previewTruncated: "预览已截断",
|
||||
roleUser: "用户",
|
||||
roleAssistant: "助手",
|
||||
roleSystem: "系统",
|
||||
|
||||
// Heatmap
|
||||
noData: "暂无数据",
|
||||
heatLess: "少",
|
||||
heatMore: "多",
|
||||
requests: "请求",
|
||||
cacheHitRate: "缓存命中率",
|
||||
|
||||
// Daily detail modal
|
||||
dayDetail: "每日用量详情",
|
||||
close: "关闭",
|
||||
totalTokens: "总 Token",
|
||||
cost: "费用",
|
||||
modelDistribution: "模型用量分布",
|
||||
modalEscHint: "按 Esc 或点击外部关闭",
|
||||
doubleClickHint: "双击查看详情",
|
||||
|
||||
// Languages
|
||||
language: "语言",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Formatting helpers for the dashboard — ported from kimicode-dashboard/web/src/format.js
|
||||
|
||||
export function fmtInt(n: number, locale = "en"): string {
|
||||
return new Intl.NumberFormat(locale === "zh" ? "zh-CN" : "en-US").format(
|
||||
Math.round(Number(n) || 0)
|
||||
);
|
||||
}
|
||||
|
||||
export function fmtTokens(n: number): string {
|
||||
const v = Number(n) || 0;
|
||||
if (v >= 1e9) return (v / 1e9).toFixed(2) + "B";
|
||||
if (v >= 1e6) return (v / 1e6).toFixed(2) + "M";
|
||||
if (v >= 1e3) return (v / 1e3).toFixed(1) + "K";
|
||||
return String(Math.round(v));
|
||||
}
|
||||
|
||||
export function fmtUsd(n: number): string {
|
||||
const v = Number(n) || 0;
|
||||
if (v === 0) return "$0.00";
|
||||
if (v < 0.01) return "$" + v.toFixed(4);
|
||||
if (v < 1) return "$" + v.toFixed(3);
|
||||
return "$" + v.toFixed(2);
|
||||
}
|
||||
|
||||
export function fmtPct(n: number): string {
|
||||
return ((Number(n) || 0) * 100).toFixed(1) + "%";
|
||||
}
|
||||
|
||||
export function fmtTime(ms: number, locale = "en"): string {
|
||||
if (!ms) return "—";
|
||||
return new Date(ms).toLocaleString(locale === "zh" ? "zh-CN" : "en-US", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import snapshot from "./models-dev.json";
|
||||
|
||||
/**
|
||||
* Compact per-model reference data from models.dev
|
||||
* (see scripts/fetch-models-dev.mjs).
|
||||
*/
|
||||
export interface ModelRef {
|
||||
name?: string;
|
||||
context?: number;
|
||||
reasoning?: boolean;
|
||||
tool_call?: boolean;
|
||||
structured_output?: boolean;
|
||||
image?: boolean;
|
||||
video?: boolean;
|
||||
}
|
||||
|
||||
const data: Record<string, ModelRef> = snapshot;
|
||||
|
||||
// models.dev keys are "<lab>/<model>" and may be mixed-case (e.g.
|
||||
// "minimax/MiniMax-M3"), while provider APIs return bare model ids
|
||||
// (e.g. "kimi-k2.5"). Index lowercase for tolerant matching.
|
||||
const byLowerKey: Record<string, ModelRef> = {};
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
byLowerKey[key.toLowerCase()] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up reference data for a provider model id.
|
||||
* Matches exact key first, then any "<lab>/<id>" suffix.
|
||||
* Returns undefined when nothing matches — callers fall back to
|
||||
* getDefaultMaxContextSize / manual capabilities.
|
||||
*/
|
||||
export function getModelRef(modelId: string): ModelRef | undefined {
|
||||
const id = modelId.trim().toLowerCase();
|
||||
if (!id) return undefined;
|
||||
const exact = byLowerKey[id];
|
||||
if (exact) return exact;
|
||||
const suffix = `/${id}`;
|
||||
for (const [key, value] of Object.entries(byLowerKey)) {
|
||||
if (key.endsWith(suffix)) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map reference data to Kimi Code capability flags.
|
||||
* Multimodal (image_in/video_in) comes from models.dev input modalities;
|
||||
* always_thinking cannot be derived and stays manual.
|
||||
*/
|
||||
export function capabilitiesFromRef(ref: ModelRef): string[] {
|
||||
const caps: string[] = [];
|
||||
if (ref.reasoning) caps.push("thinking");
|
||||
if (ref.tool_call) caps.push("tool_use");
|
||||
if (ref.image) caps.push("image_in");
|
||||
if (ref.video) caps.push("video_in");
|
||||
return caps;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Dashboard types — match the Rust structs in src-tauri/src/dashboard.rs (camelCase serde)
|
||||
|
||||
export interface TotalsRow {
|
||||
requests: number;
|
||||
inputOther: number;
|
||||
output: number;
|
||||
inputCacheRead: number;
|
||||
inputCacheCreation: number;
|
||||
costUsd: number;
|
||||
totalTokens: number;
|
||||
cacheHitRate: number;
|
||||
}
|
||||
|
||||
export interface DailyRow extends TotalsRow {
|
||||
date: string;
|
||||
byModel: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface ModelRow extends TotalsRow {
|
||||
model: string;
|
||||
modelDisplay: string;
|
||||
modelResolved: string;
|
||||
priceId: string;
|
||||
costEstimated: boolean;
|
||||
}
|
||||
|
||||
export interface RecentRow {
|
||||
time: number;
|
||||
model: string;
|
||||
modelDisplay: string;
|
||||
modelResolved: string;
|
||||
inputOther: number;
|
||||
output: number;
|
||||
inputCacheRead: number;
|
||||
inputCacheCreation: number;
|
||||
totalTokens: number;
|
||||
costUsd: number;
|
||||
costEstimated: boolean;
|
||||
priceId: string;
|
||||
fromEnv: boolean;
|
||||
}
|
||||
|
||||
export interface RangeStats {
|
||||
range: string;
|
||||
totals: TotalsRow;
|
||||
daily: DailyRow[];
|
||||
models: ModelRow[];
|
||||
recent: RecentRow[];
|
||||
recentTotal: number;
|
||||
recentLimit: number;
|
||||
}
|
||||
|
||||
export interface HeatmapCell {
|
||||
date: string;
|
||||
dow: number;
|
||||
weekIndex: number;
|
||||
requests: number;
|
||||
totalTokens: number;
|
||||
costUsd: number;
|
||||
cacheHitRate: number;
|
||||
level: number;
|
||||
}
|
||||
|
||||
export interface MonthLabel {
|
||||
weekIndex: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface HeatmapData {
|
||||
weeks: number;
|
||||
start: string;
|
||||
end: string;
|
||||
maxTokens: number;
|
||||
cells: HeatmapCell[];
|
||||
monthLabels: MonthLabel[];
|
||||
}
|
||||
|
||||
export interface ScanMeta {
|
||||
filesScanned: number;
|
||||
linesSeen: number;
|
||||
recordCount: number;
|
||||
home: string;
|
||||
sessionsRoot: string;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface AllModelRow {
|
||||
model: string;
|
||||
modelDisplay: string;
|
||||
requests: number;
|
||||
totalTokens: number;
|
||||
costUsd: number;
|
||||
costEstimated: boolean;
|
||||
cacheHitRate: number;
|
||||
}
|
||||
|
||||
export interface SummaryResult {
|
||||
home: string;
|
||||
valid: boolean;
|
||||
scannedAt: number;
|
||||
meta: ScanMeta;
|
||||
range: string;
|
||||
stats: RangeStats;
|
||||
heatmap: HeatmapData;
|
||||
allModels: AllModelRow[];
|
||||
allModelCount: number;
|
||||
rangeTotals: Record<string, TotalsRow>;
|
||||
}
|
||||
@@ -34,8 +34,6 @@ export interface Model {
|
||||
model: string;
|
||||
max_context_size: number;
|
||||
display_name: string | null;
|
||||
/** Optional model role, e.g. Sonnet/Opus/Fable/Haiku for Claude-style mapping. */
|
||||
role?: string | null;
|
||||
/** Whether the model declares extended context / thinking support. */
|
||||
supports_1m?: boolean;
|
||||
/** Agent-specific capability flags (e.g. Kimi Code `capabilities`). */
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// Session management types — match Rust structs in dashboard.rs (camelCase serde)
|
||||
|
||||
export interface WorkspaceRow {
|
||||
id: string;
|
||||
name: string;
|
||||
root: string | null;
|
||||
createdAt: string | null;
|
||||
lastOpenedAt: string | null;
|
||||
activeCount: number;
|
||||
archivedCount: number;
|
||||
empty: boolean;
|
||||
}
|
||||
|
||||
export interface SessionRow {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
status: "active" | "archived" | string;
|
||||
title: string | null;
|
||||
workDir: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
bytes: number;
|
||||
files: number;
|
||||
}
|
||||
|
||||
export interface SessionsResult {
|
||||
home: string;
|
||||
archiveRoot: string;
|
||||
workspaces: WorkspaceRow[];
|
||||
sessions: SessionRow[];
|
||||
}
|
||||
|
||||
export interface ActionResponse {
|
||||
ok: boolean;
|
||||
workspaceId: string;
|
||||
sessionId: string;
|
||||
status?: string | null;
|
||||
path?: string | null;
|
||||
deleted?: boolean | null;
|
||||
}
|
||||
|
||||
export interface PreviewMessage {
|
||||
role: string;
|
||||
time: number | null;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface PreviewResult {
|
||||
workspaceId: string;
|
||||
sessionId: string;
|
||||
status: string;
|
||||
title: string | null;
|
||||
workDir: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
messageCount: number;
|
||||
truncated: boolean;
|
||||
messages: PreviewMessage[];
|
||||
}
|
||||
|
||||
export type SessionStatusFilter = "active" | "archived" | "all";
|
||||