merge Dev_260708 → main: v0.6.1~v0.6.3 发版合入主线
# Conflicts: # src-tauri/src/commands.rs
This commit is contained in:
commit
b9ea6426d8
17 files changed
+903
-45
No files matched your search
@@ -0,0 +1 @@
|
|||||||
|
13
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# 用户自定义预设方案(User Presets)
|
||||||
|
|
||||||
|
## 动机
|
||||||
|
|
||||||
|
当前内置预设(`providerPresets.ts`)硬编码在源码中,用户无法自行添加自定义供应商预设或修改已有预设的 `apiKeyUrl`(获取 Key / 推广链接)。每次新增或修改都需要修改源码、重新编译、重新打包,缺乏灵活性。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
用户可通过外部配置文件**零代码**添加/修改预设供应商,包含:
|
||||||
|
- 自定义供应商预设(名称、Base URL、API 格式、图标、套餐类型)
|
||||||
|
- 自定义获取 API Key 入口 / 推广链接(`apiKeyUrl`,可直接放带 ref 的链接)
|
||||||
|
- 自定义模型列表(模型 ID、显示名称、上下文大小、能力)
|
||||||
|
- 覆盖内置预设(同一 `id` 时用户预设优先)
|
||||||
|
|
||||||
|
## 方案设计
|
||||||
|
|
||||||
|
### 1. 配置文件位置
|
||||||
|
|
||||||
|
```
|
||||||
|
Windows: %APPDATA%/KimiSwitch/user-presets.json
|
||||||
|
macOS: ~/Library/Application Support/KimiSwitch/user-presets.json
|
||||||
|
Linux: ~/.config/KimiSwitch/user-presets.json
|
||||||
|
```
|
||||||
|
|
||||||
|
统一使用应用数据目录(与 `app_config_dir` 一致),不扫描项目根目录——发行版是编译后的二进制,终端用户没有"项目根目录"。
|
||||||
|
|
||||||
|
首次启动时若文件不存在,自动创建一个空 `[]` 模板文件,方便用户直接编辑。
|
||||||
|
PresetPickerModal / 设置页提供一个"打开预设文件夹"按钮,让用户能找到文件位置。
|
||||||
|
|
||||||
|
### 2. 文件格式
|
||||||
|
|
||||||
|
> 支持 `//` 行注释(jsonc 风格):加载时先剥离 `//` 注释再 `JSON.parse`,方便用户写备注。
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
[
|
||||||
|
{
|
||||||
|
// 唯一标识,与内置预设 id 重名则覆盖内置预设
|
||||||
|
"id": "my-custom-vendor",
|
||||||
|
// 显示名称(fallback 英文)
|
||||||
|
"name": "My Custom Vendor",
|
||||||
|
// i18n key(可选,有则优先用 t(nameKey))
|
||||||
|
"nameKey": "presetNameMyCustom",
|
||||||
|
// 官网链接
|
||||||
|
"websiteUrl": "https://example.com",
|
||||||
|
// 获取 API Key 入口 / 推广链接(可直接放带 ref 的链接,如 ?ref=CODE)
|
||||||
|
"apiKeyUrl": "https://example.com?ref=MYCODE",
|
||||||
|
// 分类:official | cn_official | third_party | aggregator | custom
|
||||||
|
"category": "third_party",
|
||||||
|
// API 格式:openai | anthropic | google-genai | vertexai | kimi | openai_responses
|
||||||
|
"providerType": "openai",
|
||||||
|
// Base URL(null 表示使用默认)
|
||||||
|
"baseUrl": "https://api.example.com/v1",
|
||||||
|
// 图标 key(从 src/icons/extracted 中选,或空字符串用首字母)
|
||||||
|
"icon": "",
|
||||||
|
// 图标颜色(可选,hex 格式)
|
||||||
|
"iconColor": "",
|
||||||
|
// 计费模式:subscription | pay_as_you_go
|
||||||
|
"billingMode": "pay_as_you_go",
|
||||||
|
// 用量查询类型(可选,参考内置预设)
|
||||||
|
"usageKinds": [],
|
||||||
|
// 模型列表
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"model": "my-model-1",
|
||||||
|
"displayName": "My Model 1",
|
||||||
|
"maxContextSize": 128000,
|
||||||
|
"capabilities": ["thinking"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 加载逻辑(伪代码)
|
||||||
|
|
||||||
|
```
|
||||||
|
function loadAllPresets(): ProviderPreset[] {
|
||||||
|
const builtin = providerPresets; // 内置预设
|
||||||
|
const user = readUserPresetsFile(); // 读取用户预设文件
|
||||||
|
if (!user || user.length === 0) return builtin;
|
||||||
|
|
||||||
|
const merged = new Map<string, ProviderPreset>();
|
||||||
|
for (const p of builtin) merged.set(p.id, p); // 内置先进
|
||||||
|
for (const p of user) merged.set(p.id, p); // 用户覆盖
|
||||||
|
return Array.from(merged.values());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 需要修改的代码文件
|
||||||
|
|
||||||
|
| 文件 | 修改内容 |
|
||||||
|
|------|----------|
|
||||||
|
| `src/config/providerPresets.ts` | 导出 `loadAllPresets()` 函数替代直接使用 `providerPresets` 数组 |
|
||||||
|
| `src/config/userPresets.ts`(新建) | 实现 `readUserPresetsFile()`:通过 Tauri 命令(Rust 侧 `read_text_file`)读取 + 剥离注释 + `JSON.parse` + 校验结构。用 Rust 命令而非前端 FS 插件,避免 scope 配置且跨平台路径由 Rust `app_config_dir` 统一解析 |
|
||||||
|
| `src/components/PresetPickerModal.tsx` | 将 `providerPresets` 引用改为 `loadAllPresets()` |
|
||||||
|
| `src/config/providerPresets.ts`(`findPresetForProvider`) | 同样需要从合并后的预设中查找 |
|
||||||
|
|
||||||
|
### 5. 校验逻辑
|
||||||
|
|
||||||
|
用户 JSON 文件可能写错,需要做基础校验:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface UserPresetValidation {
|
||||||
|
valid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
presets: ProviderPreset[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateUserPreset(raw: unknown): UserPresetValidation {
|
||||||
|
// 1. 必须是数组
|
||||||
|
// 2. 每项必须有 id、name、providerType、billingMode
|
||||||
|
// 3. providerType 必须是合法值之一(openai | openai_responses | anthropic | google-genai | vertexai | kimi)
|
||||||
|
// 4. billingMode 必须是 "subscription" | "pay_as_you_go"
|
||||||
|
// 5. models 数组每项必须有 model 字段
|
||||||
|
// 6. usageKinds 若提供,每项必须在 SUPPORTED_USAGE_KINDS 内,否则丢弃并警告
|
||||||
|
// (Rust 侧运行时也会静默丢弃未知 kind,这里提前拦截给用户明确反馈)
|
||||||
|
// 7. 忽略未知字段不报错(仅警告)
|
||||||
|
// 8. 单条严重错误时跳过该条,不阻塞其他预设
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 用户交互
|
||||||
|
|
||||||
|
- PresetPickerModal 中用户预设顶部显示,与内置预设分栏展示(或加"自定义"标签)
|
||||||
|
- 若 JSON 文件解析失败,界面上显示一个黄色警告条 + 错误详情
|
||||||
|
- 提供一个"刷新用户预设"按钮,不必重启应用
|
||||||
|
|
||||||
|
### 7. 安全问题
|
||||||
|
|
||||||
|
- 不读取系统敏感路径,仅限于应用数据目录
|
||||||
|
- 不执行 JSON 中的任何代码;`JSON.parse` 产出的是纯对象,无原型污染风险,校验只需关注字段类型与取值
|
||||||
|
- 对 URL 字段(websiteUrl / apiKeyUrl)做基本 `http(s)://` 协议校验,`openUrl` 调用前过滤非法 scheme
|
||||||
|
|
||||||
|
### 8. 向后兼容
|
||||||
|
|
||||||
|
- 没有 `user-presets.json` 时行为完全不变
|
||||||
|
- 用户预设只影响 PresetPickerModal 中的选项,不影响已保存的 Provider
|
||||||
|
- 已保存的 Provider 不受用户预设删除影响
|
||||||
|
|
||||||
|
## 未解决的问题
|
||||||
|
|
||||||
|
1. **图标来源**:用户用内置图标集不存在的 key(含空字符串/null)时,统一降级为首字母显示。是否支持用户自定义 SVG 图标?—— 当前首字母降级足够,先不做。
|
||||||
|
2. **热加载**:文件修改后是否需要自动检测并刷新?—— 初期手动刷新按钮即可,后续可加 `fs::watch`。
|
||||||
|
3. **i18n**:用户预设的 `nameKey` 如果对应内置 i18n key 则显示翻译,否则 fallback 到 `name`。用户不能添加自定义 i18n key(涉及翻译文件修改,违背零代码原则)。
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "kimiswitch",
|
"name": "kimiswitch",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.6.0",
|
"version": "0.6.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
Generated
+1
-1
@@ -1958,7 +1958,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kimiswitch"
|
name = "kimiswitch"
|
||||||
version = "0.6.0"
|
version = "0.6.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "kimiswitch"
|
name = "kimiswitch"
|
||||||
version = "0.6.0"
|
version = "0.6.3"
|
||||||
description = "Kimi Switch - model config manager"
|
description = "Kimi Switch - model config manager"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -866,8 +866,6 @@ pub fn open_installer(app: tauri::AppHandle, path: String) -> Result<(), String>
|
|||||||
.open_path(&path, None::<&str>)
|
.open_path(&path, None::<&str>)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pick the GitHub release asset matching the given OS by file extension.
|
|
||||||
/// Preference for Linux is AppImage (portable, no install). If the preferred
|
/// Preference for Linux is AppImage (portable, no install). If the preferred
|
||||||
/// extension is not present, returns None so the UI can fall back to
|
/// extension is not present, returns None so the UI can fall back to
|
||||||
/// "open release page" instead of guessing a less-preferred format.
|
/// "open release page" instead of guessing a less-preferred format.
|
||||||
@@ -965,3 +963,14 @@ mod asset_picker_tests {
|
|||||||
assert_eq!(url, None);
|
assert_eq!(url, None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open an external https/http URL in the system default browser.
|
||||||
|
/// Uses the Rust opener directly (bypassing the JS plugin scope) so it
|
||||||
|
/// works regardless of capability scope configuration.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn open_external_url(app: tauri::AppHandle, url: String) -> Result<(), String> {
|
||||||
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
app.opener()
|
||||||
|
.open_url(url, None::<&str>)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
@@ -104,6 +104,7 @@ pub fn run() {
|
|||||||
commands::check_for_update,
|
commands::check_for_update,
|
||||||
commands::download_update,
|
commands::download_update,
|
||||||
commands::open_installer,
|
commands::open_installer,
|
||||||
|
commands::open_external_url,
|
||||||
dashboard::get_paths,
|
dashboard::get_paths,
|
||||||
dashboard::get_prices,
|
dashboard::get_prices,
|
||||||
dashboard::get_summary,
|
dashboard::get_summary,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"productName": "Kimi Switch",
|
"productName": "Kimi Switch",
|
||||||
"version": "0.6.0",
|
"version": "0.6.3",
|
||||||
"identifier": "com.kimiswitch.app",
|
"identifier": "com.kimiswitch.app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
@@ -703,6 +703,11 @@ export default function App() {
|
|||||||
updateConfig((cfg) => {
|
updateConfig((cfg) => {
|
||||||
const updated = { ...cfg.models };
|
const updated = { ...cfg.models };
|
||||||
for (const m of models) {
|
for (const m of models) {
|
||||||
|
// Skip if this model id already exists for the same provider
|
||||||
|
const dup = Object.values(updated).some(
|
||||||
|
(mm) => mm.provider === m.provider && mm.model === m.model && m.model !== ""
|
||||||
|
);
|
||||||
|
if (dup) continue;
|
||||||
let alias = m.alias;
|
let alias = m.alias;
|
||||||
let n = 1;
|
let n = 1;
|
||||||
while (alias in updated) {
|
while (alias in updated) {
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "../i18n";
|
import { useTranslation } from "../i18n";
|
||||||
import type { TranslationKey } from "../i18n/zh";
|
import type { TranslationKey } from "../i18n/zh";
|
||||||
import { providerPresets, type PresetCategory, type ProviderPreset } from "../config/providerPresets";
|
import {
|
||||||
|
providerPresets,
|
||||||
|
type BillingMode,
|
||||||
|
type PresetCategory,
|
||||||
|
type ProviderPreset,
|
||||||
|
} from "../config/providerPresets";
|
||||||
import { ProviderIcon } from "./ProviderIcon";
|
import { ProviderIcon } from "./ProviderIcon";
|
||||||
|
|
||||||
interface PresetPickerModalProps {
|
interface PresetPickerModalProps {
|
||||||
@@ -26,12 +31,26 @@ const CATEGORY_LABEL: Record<Exclude<PresetCategory, "custom">, TranslationKey>
|
|||||||
aggregator: "presetCategoryAggregator",
|
aggregator: "presetCategoryAggregator",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const BILLING_BADGE: Record<BillingMode, string> = {
|
||||||
|
subscription: "bg-violet-100 text-violet-700 dark:bg-violet-500/15 dark:text-violet-400",
|
||||||
|
pay_as_you_go: "bg-slate-200 text-slate-600 dark:bg-slate-500/15 dark:text-slate-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
const BILLING_LABEL: Record<BillingMode, TranslationKey> = {
|
||||||
|
subscription: "billingBadgeSubscription",
|
||||||
|
pay_as_you_go: "billingBadgePayAsYouGo",
|
||||||
|
};
|
||||||
|
|
||||||
type SortMode = "original" | "alpha";
|
type SortMode = "original" | "alpha";
|
||||||
|
type BillingTab = "all" | BillingMode;
|
||||||
|
|
||||||
|
const BILLING_TAB_ORDER: BillingTab[] = ["subscription", "pay_as_you_go", "all"];
|
||||||
|
|
||||||
export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetPickerModalProps) {
|
export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetPickerModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [sort, setSort] = useState<SortMode>("original");
|
const [sort, setSort] = useState<SortMode>("original");
|
||||||
|
const [billingTab, setBillingTab] = useState<BillingTab>("all");
|
||||||
|
|
||||||
// Esc to close
|
// Esc to close
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -58,15 +77,31 @@ export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetP
|
|||||||
if (open) {
|
if (open) {
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setSort("original");
|
setSort("original");
|
||||||
|
setBillingTab("all");
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
// Counts per billing tab — shown in the tab labels so users see how many
|
||||||
|
// presets are in each bucket without having to click through.
|
||||||
|
const billingCounts = useMemo(() => {
|
||||||
|
let sub = 0;
|
||||||
|
let payg = 0;
|
||||||
|
for (const p of providerPresets) {
|
||||||
|
if (p.billingMode === "subscription") sub++;
|
||||||
|
else payg++;
|
||||||
|
}
|
||||||
|
return { subscription: sub, pay_as_you_go: payg, all: sub + payg };
|
||||||
|
}, []);
|
||||||
|
|
||||||
const presetName = (p: ProviderPreset) =>
|
const presetName = (p: ProviderPreset) =>
|
||||||
p.nameKey ? t(p.nameKey as TranslationKey) : p.name;
|
p.nameKey ? t(p.nameKey as TranslationKey) : p.name;
|
||||||
|
|
||||||
const visible = useMemo(() => {
|
const visible = useMemo(() => {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
let list = providerPresets;
|
let list = providerPresets;
|
||||||
|
if (billingTab !== "all") {
|
||||||
|
list = list.filter((p) => p.billingMode === billingTab);
|
||||||
|
}
|
||||||
if (q) {
|
if (q) {
|
||||||
list = list.filter(
|
list = list.filter(
|
||||||
(p) => p.id.toLowerCase().includes(q) || presetName(p).toLowerCase().includes(q)
|
(p) => p.id.toLowerCase().includes(q) || presetName(p).toLowerCase().includes(q)
|
||||||
@@ -77,7 +112,7 @@ export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetP
|
|||||||
}
|
}
|
||||||
return list;
|
return list;
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [query, sort, t]);
|
}, [query, sort, billingTab, t]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
@@ -110,8 +145,35 @@ export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetP
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search + sort */}
|
{/* Billing-mode tabs + search + sort */}
|
||||||
<div className="flex items-center gap-2 border-b border-border px-5 py-3">
|
<div className="flex flex-col gap-2 border-b border-border px-5 py-3">
|
||||||
|
<div className="flex items-center bg-input border border-border rounded p-0.5 self-start">
|
||||||
|
{BILLING_TAB_ORDER.map((tab) => {
|
||||||
|
const labelKey: TranslationKey =
|
||||||
|
tab === "all"
|
||||||
|
? "billingTabAll"
|
||||||
|
: tab === "subscription"
|
||||||
|
? "billingTabSubscription"
|
||||||
|
: "billingTabPayAsYouGo";
|
||||||
|
const count = billingCounts[tab];
|
||||||
|
const active = billingTab === tab;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBillingTab(tab)}
|
||||||
|
className={`px-2.5 py-1 text-xs rounded transition-colors ${
|
||||||
|
active
|
||||||
|
? "bg-blue-600 text-white"
|
||||||
|
: "text-content-muted hover:text-content-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(labelKey)} ({count})
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
@@ -145,6 +207,7 @@ export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetP
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Preset grid */}
|
{/* Preset grid */}
|
||||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||||
@@ -168,6 +231,7 @@ export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetP
|
|||||||
<div className="w-full truncate text-sm font-medium text-content-primary">
|
<div className="w-full truncate text-sm font-medium text-content-primary">
|
||||||
{presetName(p)}
|
{presetName(p)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
{p.category !== "custom" && (
|
{p.category !== "custom" && (
|
||||||
<span
|
<span
|
||||||
className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${CATEGORY_BADGE[p.category]}`}
|
className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${CATEGORY_BADGE[p.category]}`}
|
||||||
@@ -175,6 +239,12 @@ export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetP
|
|||||||
{t(CATEGORY_LABEL[p.category])}
|
{t(CATEGORY_LABEL[p.category])}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<span
|
||||||
|
className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${BILLING_BADGE[p.billingMode]}`}
|
||||||
|
>
|
||||||
|
{t(BILLING_LABEL[p.billingMode])}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createPortal } from "react-dom";
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { useTranslation } from "../i18n";
|
import { useTranslation } from "../i18n";
|
||||||
import type { TranslationKey } from "../i18n/zh";
|
import type { TranslationKey } from "../i18n/zh";
|
||||||
|
import { findPresetForProvider } from "../config/providerPresets";
|
||||||
import { getDefaultMaxContextSize } from "../lib/model-defaults";
|
import { getDefaultMaxContextSize } from "../lib/model-defaults";
|
||||||
import { capabilitiesFromRef, getModelRef } from "../lib/models-dev";
|
import { capabilitiesFromRef, getModelRef } from "../lib/models-dev";
|
||||||
import { getIconMetadata } from "../icons/extracted/metadata";
|
import { getIconMetadata } from "../icons/extracted/metadata";
|
||||||
@@ -117,6 +118,10 @@ export function ProviderEdit({
|
|||||||
const [showApiKey, setShowApiKey] = useState(false);
|
const [showApiKey, setShowApiKey] = useState(false);
|
||||||
const [showIconPicker, setShowIconPicker] = useState(false);
|
const [showIconPicker, setShowIconPicker] = useState(false);
|
||||||
|
|
||||||
|
// Preset this provider was created from (if any) — provides the
|
||||||
|
// "Get API Key" / referral links shown under the key input.
|
||||||
|
const preset = findPresetForProvider(provider);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const def = defaultBaseUrl(agent, provider.provider_type);
|
const def = defaultBaseUrl(agent, provider.provider_type);
|
||||||
if (!def) return;
|
if (!def) return;
|
||||||
@@ -215,7 +220,7 @@ export function ProviderEdit({
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor={nameId} className="block text-sm text-content-muted mb-1.5">
|
<label htmlFor={nameId} className="block text-sm text-content-muted mb-1.5">
|
||||||
{t("providerName")}
|
{t("providerName")} <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id={nameId}
|
id={nameId}
|
||||||
@@ -296,7 +301,7 @@ export function ProviderEdit({
|
|||||||
{!provider.managed && (
|
{!provider.managed && (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor={apiKeyId} className="block text-sm text-content-muted mb-1.5">
|
<label htmlFor={apiKeyId} className="block text-sm text-content-muted mb-1.5">
|
||||||
{t("apiKey")}
|
{t("apiKey")} <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
@@ -316,12 +321,29 @@ export function ProviderEdit({
|
|||||||
{showApiKey ? t("hide") : t("show")}
|
{showApiKey ? t("hide") : t("show")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const linkUrl = preset?.apiKeyUrl ?? provider.official_url ?? null;
|
||||||
|
if (!linkUrl) return null;
|
||||||
|
return (
|
||||||
|
<div className="mt-1.5 flex items-center gap-4 text-xs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
invoke("open_external_url", { url: linkUrl }).catch(() => {})
|
||||||
|
}
|
||||||
|
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||||
|
>
|
||||||
|
{preset?.apiKeyUrl ? t("getApiKeyLink") : t("officialUrl")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor={baseUrlId} className="block text-sm text-content-muted mb-1.5">
|
<label htmlFor={baseUrlId} className="block text-sm text-content-muted mb-1.5">
|
||||||
{t("requestUrl")}
|
{t("requestUrl")} <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id={baseUrlId}
|
id={baseUrlId}
|
||||||
@@ -459,6 +481,9 @@ function ModelMapping({
|
|||||||
provider,
|
provider,
|
||||||
});
|
});
|
||||||
setDiscovered(result);
|
setDiscovered(result);
|
||||||
|
// Auto-select models that already exist in the current provider's list
|
||||||
|
const existingIds = new Set(models.filter((m) => m.model).map((m) => m.model));
|
||||||
|
setSelected(new Set(result.filter((dm) => existingIds.has(dm.id)).map((dm) => dm.id)));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setDiscoverError(err instanceof Error ? err.message : String(err));
|
setDiscoverError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -477,9 +502,11 @@ function ModelMapping({
|
|||||||
|
|
||||||
const handleAddSelected = () => {
|
const handleAddSelected = () => {
|
||||||
if (!discovered || selected.size === 0) return;
|
if (!discovered || selected.size === 0) return;
|
||||||
|
const existingIds = new Set(models.filter((m) => m.model).map((m) => m.model));
|
||||||
const toAdd: Model[] = [];
|
const toAdd: Model[] = [];
|
||||||
for (const dm of discovered) {
|
for (const dm of discovered) {
|
||||||
if (!selected.has(dm.id)) continue;
|
if (!selected.has(dm.id)) continue;
|
||||||
|
if (existingIds.has(dm.id)) continue; // skip models already in this provider
|
||||||
const safeProvider = provider.name.replace(/\//g, "-");
|
const safeProvider = provider.name.replace(/\//g, "-");
|
||||||
const safeModelId = dm.id.replace(/\//g, "-");
|
const safeModelId = dm.id.replace(/\//g, "-");
|
||||||
const alias = `${safeProvider}/${safeModelId}`;
|
const alias = `${safeProvider}/${safeModelId}`;
|
||||||
@@ -555,7 +582,9 @@ function ModelMapping({
|
|||||||
{t("fetchEnableThinking")}
|
{t("fetchEnableThinking")}
|
||||||
</label>
|
</label>
|
||||||
<div className="max-h-48 overflow-auto space-y-1">
|
<div className="max-h-48 overflow-auto space-y-1">
|
||||||
{discovered.map((m) => (
|
{discovered.map((m) => {
|
||||||
|
const exists = models.some((mm) => mm.model === m.id);
|
||||||
|
return (
|
||||||
<label
|
<label
|
||||||
key={m.id}
|
key={m.id}
|
||||||
className="flex items-center gap-2 text-sm cursor-pointer hover:bg-input p-1.5 rounded"
|
className="flex items-center gap-2 text-sm cursor-pointer hover:bg-input p-1.5 rounded"
|
||||||
@@ -569,8 +598,12 @@ function ModelMapping({
|
|||||||
{m.display_name && (
|
{m.display_name && (
|
||||||
<span className="text-content-muted">({m.display_name})</span>
|
<span className="text-content-muted">({m.display_name})</span>
|
||||||
)}
|
)}
|
||||||
|
{exists && (
|
||||||
|
<span className="ml-auto text-[10px] text-emerald-500 shrink-0">{t("alreadyAdded")}</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useTranslation } from "../../i18n";
|
|||||||
import { fmtInt, fmtPct, fmtTime, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
|
import { fmtInt, fmtPct, fmtTime, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
|
||||||
import { DailyBars } from "./DailyBars";
|
import { DailyBars } from "./DailyBars";
|
||||||
import { Heatmap } from "./Heatmap";
|
import { Heatmap } from "./Heatmap";
|
||||||
|
import { TrendLineChart } from "./TrendLineChart";
|
||||||
|
|
||||||
const RANGES: DashboardRange[] = ["today", "7d", "30d", "all"];
|
const RANGES: DashboardRange[] = ["today", "7d", "30d", "all"];
|
||||||
|
|
||||||
@@ -12,18 +13,20 @@ function Card({
|
|||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
children,
|
children,
|
||||||
|
className,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col rounded-xl border border-border bg-panel">
|
<div className={`flex flex-col rounded-xl border border-border bg-panel ${className ?? ""}`}>
|
||||||
<div className="border-b border-border px-4 py-2.5 flex items-baseline justify-between">
|
<div className="border-b border-border px-4 py-2.5 flex items-baseline justify-between">
|
||||||
<h3 className="text-sm font-medium text-content-primary">{title}</h3>
|
<h3 className="text-sm font-medium text-content-primary">{title}</h3>
|
||||||
{subtitle && <span className="text-xs text-content-muted">{subtitle}</span>}
|
{subtitle && <span className="text-xs text-content-muted">{subtitle}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 p-4">{children}</div>
|
<div className="flex flex-1 flex-col p-4">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -45,7 +48,7 @@ export function DashboardPage() {
|
|||||||
const { range, changeRange, data, loading, error, refresh } = useDashboard();
|
const { range, changeRange, data, loading, error, refresh } = useDashboard();
|
||||||
const [showAllModels, setShowAllModels] = useState(false);
|
const [showAllModels, setShowAllModels] = useState(false);
|
||||||
const [recentPage, setRecentPage] = useState(1);
|
const [recentPage, setRecentPage] = useState(1);
|
||||||
const [trendTab, setTrendTab] = useState<"model" | "provider">("model");
|
const [trendTab, setTrendTab] = useState<"daily" | "model" | "provider">("daily");
|
||||||
|
|
||||||
const RANGE_LABELS: Record<DashboardRange, string> = {
|
const RANGE_LABELS: Record<DashboardRange, string> = {
|
||||||
today: t("rangeToday"),
|
today: t("rangeToday"),
|
||||||
@@ -187,18 +190,29 @@ export function DashboardPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Heatmap — full-year strip, fixed-size cells, centered */}
|
{/* Heatmap — full row */}
|
||||||
<Card title={t("cardHeatmap")} subtitle={t("cardHeatmapSub")}>
|
<Card title={t("cardHeatmap")} subtitle={t("cardHeatmapSub")}>
|
||||||
<Heatmap heatmap={heatmap} />
|
<Heatmap heatmap={heatmap} />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Charts + Model side table */}
|
{/* Usage-trend tabs + Model table side by side */}
|
||||||
<div className="grid gap-4 lg:grid-cols-[1.2fr_1fr]">
|
<div className="grid items-stretch gap-4 lg:grid-cols-[1.2fr_1fr]">
|
||||||
{/* Usage-trend tab container */}
|
{/* Usage-trend tab container */}
|
||||||
<div className="flex flex-col rounded-xl border border-border bg-panel">
|
<div className="flex flex-col rounded-xl border border-border bg-panel">
|
||||||
{/* Tab bar header */}
|
{/* Tab bar header */}
|
||||||
<div className="flex items-end justify-between border-b border-border px-2">
|
<div className="flex items-end justify-between border-b border-border px-2">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTrendTab("daily")}
|
||||||
|
className={`px-3 py-2 -mb-px border-b-2 text-sm font-medium transition-colors ${
|
||||||
|
trendTab === "daily"
|
||||||
|
? "border-blue-500 text-content-primary"
|
||||||
|
: "border-transparent text-content-muted hover:text-content-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t("tabDailyTrend")}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTrendTab("model")}
|
onClick={() => setTrendTab("model")}
|
||||||
@@ -223,14 +237,21 @@ export function DashboardPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<span className="pb-2 text-xs text-content-muted">
|
<span className="pb-2 text-xs text-content-muted">
|
||||||
{daily.length > 30
|
{trendTab === "daily"
|
||||||
|
? t("trendLineSub", { n: trendDaily.length })
|
||||||
|
: daily.length > 30
|
||||||
? t("cardDailyTrendSubCapped", { n: daily.length })
|
? t("cardDailyTrendSubCapped", { n: daily.length })
|
||||||
: t("cardDailyTrendSub", { n: daily.length })}
|
: t("cardDailyTrendSub", { n: daily.length })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Active tab content */}
|
{/* Active tab content */}
|
||||||
<div className="flex-1 p-4">
|
<div className="flex flex-1 flex-col p-4">
|
||||||
<div className="h-full min-h-[240px]">
|
{trendTab === "daily" ? (
|
||||||
|
<div className="flex-1 min-h-[240px]">
|
||||||
|
<TrendLineChart daily={trendDaily} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 min-h-[240px]">
|
||||||
<DailyBars
|
<DailyBars
|
||||||
daily={trendDaily}
|
daily={trendDaily}
|
||||||
dimension={trendTab}
|
dimension={trendTab}
|
||||||
@@ -238,6 +259,7 @@ export function DashboardPage() {
|
|||||||
unknownProviderLabel={t("providerUnknown")}
|
unknownProviderLabel={t("providerUnknown")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -246,7 +268,7 @@ export function DashboardPage() {
|
|||||||
<div className="py-8 text-center text-sm text-content-muted">{t("noData")}</div>
|
<div className="py-8 text-center text-sm text-content-muted">{t("noData")}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="max-h-[360px] overflow-auto">
|
<div className="max-h-[360px] overflow-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm border-collapse">
|
||||||
<thead className="sticky top-0 bg-panel">
|
<thead className="sticky top-0 bg-panel">
|
||||||
<tr className="text-left text-xs text-content-muted border-b border-border">
|
<tr className="text-left text-xs text-content-muted border-b border-border">
|
||||||
<th className="pb-2 pr-3 font-normal">{t("colModel")}</th>
|
<th className="pb-2 pr-3 font-normal">{t("colModel")}</th>
|
||||||
@@ -260,7 +282,7 @@ export function DashboardPage() {
|
|||||||
{visibleModels.map((m) => (
|
{visibleModels.map((m) => (
|
||||||
<tr
|
<tr
|
||||||
key={m.model}
|
key={m.model}
|
||||||
className="border-b border-border/50 hover:bg-hover"
|
className="border-b border-border hover:bg-hover"
|
||||||
>
|
>
|
||||||
<td className="py-2 pr-3">
|
<td className="py-2 pr-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -273,10 +295,10 @@ export function DashboardPage() {
|
|||||||
: "#6b7280",
|
: "#6b7280",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0">
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
<div className="text-content-primary whitespace-nowrap">{m.modelDisplay || m.model}</div>
|
<span className="text-content-primary whitespace-nowrap truncate">{m.modelDisplay || m.model}</span>
|
||||||
{m.costEstimated && (
|
{m.costEstimated && (
|
||||||
<span className="text-[10px] text-yellow-600">{t("estimate")}</span>
|
<span className="text-[10px] text-yellow-600 shrink-0">({t("estimate")})</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -316,8 +338,8 @@ export function DashboardPage() {
|
|||||||
<div className="py-8 text-center text-sm text-content-muted">{t("noData")}</div>
|
<div className="py-8 text-center text-sm text-content-muted">{t("noData")}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="overflow-auto">
|
<div className="overflow-auto max-h-[860px]">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm border-collapse">
|
||||||
<thead className="sticky top-0 bg-panel">
|
<thead className="sticky top-0 bg-panel">
|
||||||
<tr className="text-left text-xs text-content-muted border-b border-border">
|
<tr className="text-left text-xs text-content-muted border-b border-border">
|
||||||
<th className="pb-2 pr-4 font-normal">{t("colTime")}</th>
|
<th className="pb-2 pr-4 font-normal">{t("colTime")}</th>
|
||||||
@@ -330,7 +352,7 @@ export function DashboardPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{recentPageRows.map((r, i) => (
|
{recentPageRows.map((r, i) => (
|
||||||
<tr key={recentStart + i} className="border-b border-border/50 hover:bg-hover">
|
<tr key={recentStart + i} className="border-b border-border hover:bg-hover">
|
||||||
<td className="py-1.5 pr-4 text-content-muted whitespace-nowrap">
|
<td className="py-1.5 pr-4 text-content-muted whitespace-nowrap">
|
||||||
{fmtTime(r.time)}
|
{fmtTime(r.time)}
|
||||||
</td>
|
</td>
|
||||||
@@ -398,11 +420,13 @@ export function DashboardPage() {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Attribution footer */}
|
{/* Footer — meta + attribution at the end of the content flow */}
|
||||||
<div className="text-xs text-content-muted pb-2">
|
<div className="text-xs text-content-muted pb-2">
|
||||||
|
<div>
|
||||||
{t("footerScanned", { n: data.meta.filesScanned, m: fmtInt(data.meta.recordCount) })} ·{" "}
|
{t("footerScanned", { n: data.meta.filesScanned, m: fmtInt(data.meta.recordCount) })} ·{" "}
|
||||||
{data.meta.home}
|
{data.meta.home}
|
||||||
<br />
|
</div>
|
||||||
|
<div className="mt-0.5">
|
||||||
{t("dashboardAttribution")}{" "}
|
{t("dashboardAttribution")}{" "}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -414,5 +438,6 @@ export function DashboardPage() {
|
|||||||
{t("attributionSuffix")}
|
{t("attributionSuffix")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -19,7 +19,7 @@ const LEVEL_BG = [
|
|||||||
const DOW_LABELS_ZH = ["日", "一", "二", "三", "四", "五", "六"];
|
const DOW_LABELS_ZH = ["日", "一", "二", "三", "四", "五", "六"];
|
||||||
const DOW_LABELS_EN = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
const DOW_LABELS_EN = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||||
|
|
||||||
const CELL = 12; // px
|
const CELL = 16; // px
|
||||||
const GAP = 3; // px
|
const GAP = 3; // px
|
||||||
const LABEL_W = 28; // dow label column width
|
const LABEL_W = 28; // dow label column width
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
import { useMemo, useState, type MouseEvent } from "react";
|
||||||
|
import { useTranslation } from "../../i18n";
|
||||||
|
import { fmtTokens } from "../../lib/dashboard-format";
|
||||||
|
import type { DailyRow } from "../../types/dashboard";
|
||||||
|
|
||||||
|
interface TrendLineChartProps {
|
||||||
|
/** Per-day totals, already capped to the desired window by the caller. */
|
||||||
|
daily: DailyRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const W = 800;
|
||||||
|
const H = 130;
|
||||||
|
const PAD_L = 56;
|
||||||
|
const PAD_R = 56;
|
||||||
|
const PAD_T = 10;
|
||||||
|
const PAD_B = 22;
|
||||||
|
const PLOT_W = W - PAD_L - PAD_R;
|
||||||
|
const PLOT_H = H - PAD_T - PAD_B;
|
||||||
|
|
||||||
|
const COLOR_TOKENS = "#3b82f6"; // blue
|
||||||
|
const COLOR_CACHE = "#22c55e"; // green
|
||||||
|
const COLOR_REQUESTS = "#f97316"; // orange
|
||||||
|
|
||||||
|
/** Human-friendly token count for axis labels (K / M / B). */
|
||||||
|
function fmtTokenAxis(value: number): string {
|
||||||
|
if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(1)}B`;
|
||||||
|
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (value >= 1_000) return `${(value / 1_000).toFixed(0)}K`;
|
||||||
|
return String(Math.round(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtInt(n: number): string {
|
||||||
|
return Math.round(n).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function niceMax(value: number): number {
|
||||||
|
if (value <= 0) return 1;
|
||||||
|
const exp = Math.floor(Math.log10(value));
|
||||||
|
const base = Math.pow(10, exp);
|
||||||
|
const norm = value / base;
|
||||||
|
let nice: number;
|
||||||
|
if (norm <= 1) nice = 1;
|
||||||
|
else if (norm <= 2) nice = 2;
|
||||||
|
else if (norm <= 5) nice = 5;
|
||||||
|
else nice = 10;
|
||||||
|
return nice * base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrendLineChart({ daily }: TrendLineChartProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [showRequests, setShowRequests] = useState(true);
|
||||||
|
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const maxTokens = useMemo(() => {
|
||||||
|
if (!daily?.length) return 1;
|
||||||
|
return niceMax(Math.max(...daily.map((d) => d.totalTokens || 0), 1));
|
||||||
|
}, [daily]);
|
||||||
|
|
||||||
|
const maxRequests = useMemo(() => {
|
||||||
|
if (!daily?.length) return 1;
|
||||||
|
return niceMax(Math.max(...daily.map((d) => d.requests || 0), 1));
|
||||||
|
}, [daily]);
|
||||||
|
|
||||||
|
const n = daily?.length ?? 0;
|
||||||
|
const stepX = n > 1 ? PLOT_W / (n - 1) : PLOT_W;
|
||||||
|
|
||||||
|
const pointX = (i: number) => (n > 1 ? PAD_L + i * stepX : PAD_L + PLOT_W / 2);
|
||||||
|
const pointYTokens = (v: number) => PAD_T + PLOT_H - (v / maxTokens) * PLOT_H;
|
||||||
|
// Cache hit rate is a fraction 0-1 (see fmtPct); map onto the right 0-100% axis.
|
||||||
|
const pointYCache = (v: number) => PAD_T + PLOT_H - Math.max(0, Math.min(1, v)) * PLOT_H;
|
||||||
|
const pointYReq = (v: number) => PAD_T + PLOT_H - (v / maxRequests) * PLOT_H;
|
||||||
|
|
||||||
|
const pathTokens = useMemo(() => {
|
||||||
|
if (!daily?.length) return "";
|
||||||
|
return daily
|
||||||
|
.map((d, i) => `${i === 0 ? "M" : "L"} ${pointX(i)} ${pointYTokens(d.totalTokens || 0)}`)
|
||||||
|
.join(" ");
|
||||||
|
}, [daily, maxTokens]);
|
||||||
|
|
||||||
|
const pathCache = useMemo(() => {
|
||||||
|
if (!daily?.length) return "";
|
||||||
|
return daily
|
||||||
|
.map((d, i) => `${i === 0 ? "M" : "L"} ${pointX(i)} ${pointYCache(d.cacheHitRate || 0)}`)
|
||||||
|
.join(" ");
|
||||||
|
}, [daily]);
|
||||||
|
|
||||||
|
const pathRequests = useMemo(() => {
|
||||||
|
if (!daily?.length) return "";
|
||||||
|
return daily
|
||||||
|
.map((d, i) => `${i === 0 ? "M" : "L"} ${pointX(i)} ${pointYReq(d.requests || 0)}`)
|
||||||
|
.join(" ");
|
||||||
|
}, [daily, maxRequests]);
|
||||||
|
|
||||||
|
const areaTokens = useMemo(() => {
|
||||||
|
if (!daily?.length) return "";
|
||||||
|
const lastX = pointX(daily.length - 1);
|
||||||
|
return (
|
||||||
|
pathTokens +
|
||||||
|
` L ${lastX} ${PAD_T + PLOT_H} L ${PAD_L} ${PAD_T + PLOT_H} Z`
|
||||||
|
);
|
||||||
|
}, [pathTokens, daily]);
|
||||||
|
|
||||||
|
// X-axis ticks: show every ~5 days (plus the first and last) to avoid overlap.
|
||||||
|
const xTicks = useMemo(() => {
|
||||||
|
if (!daily?.length) return [];
|
||||||
|
if (n <= 6) return daily.map((_, i) => i);
|
||||||
|
const step = Math.max(1, Math.round(n / 6));
|
||||||
|
const set = new Set<number>([0, n - 1]);
|
||||||
|
for (let i = step; i < n - 1; i += step) set.add(i);
|
||||||
|
return Array.from(set).sort((a, b) => a - b);
|
||||||
|
}, [daily, n]);
|
||||||
|
|
||||||
|
// Hover handling
|
||||||
|
const onMove = (e: MouseEvent<SVGSVGElement>) => {
|
||||||
|
if (!daily?.length) return;
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const scale = W / rect.width;
|
||||||
|
const x = (e.clientX - rect.left) * scale - PAD_L;
|
||||||
|
if (x < 0 || x > PLOT_W) {
|
||||||
|
setHoverIndex(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const idx = Math.max(0, Math.min(n - 1, Math.round(x / stepX)));
|
||||||
|
setHoverIndex(idx);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onLeave = () => setHoverIndex(null);
|
||||||
|
|
||||||
|
if (!daily?.length) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-32 items-center justify-center text-sm text-content-muted">
|
||||||
|
{t("trendLineNoData")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hover = hoverIndex != null ? daily[hoverIndex] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-2">
|
||||||
|
{/* Legend + toggle */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-content-muted">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: COLOR_TOKENS }} />
|
||||||
|
{t("trendLineLegendTokens")}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: COLOR_CACHE }} />
|
||||||
|
{t("trendLineLegendCacheHit")}
|
||||||
|
</span>
|
||||||
|
{showRequests && (
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: COLOR_REQUESTS }} />
|
||||||
|
{t("trendLineLegendRequests")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-1.5 text-[11px] text-content-muted cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showRequests}
|
||||||
|
onChange={(e) => setShowRequests(e.target.checked)}
|
||||||
|
className="h-3 w-3 accent-blue-500"
|
||||||
|
/>
|
||||||
|
{t("trendLineShowRequests")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart */}
|
||||||
|
<div className="relative flex-1 min-h-0">
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
|
className="w-full h-full"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
onMouseMove={onMove}
|
||||||
|
onMouseLeave={onLeave}
|
||||||
|
>
|
||||||
|
{/* Gradient definitions */}
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="tokenAreaGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor={COLOR_TOKENS} stopOpacity="0.28" />
|
||||||
|
<stop offset="100%" stopColor={COLOR_TOKENS} stopOpacity="0.02" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* Y-axis grid lines only (labels rendered as HTML overlay below) */}
|
||||||
|
{[0, 0.25, 0.5, 0.75, 1].map((p) => {
|
||||||
|
const y = PAD_T + PLOT_H * (1 - p);
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
key={p}
|
||||||
|
x1={PAD_L}
|
||||||
|
y1={y}
|
||||||
|
x2={W - PAD_R}
|
||||||
|
y2={y}
|
||||||
|
stroke="currentColor"
|
||||||
|
className="text-border"
|
||||||
|
strokeWidth={1}
|
||||||
|
strokeDasharray={p === 0 ? "" : "2 3"}
|
||||||
|
opacity={p === 0 ? 0.9 : 0.4}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Area fill for tokens */}
|
||||||
|
<path d={areaTokens} fill="url(#tokenAreaGrad)" />
|
||||||
|
|
||||||
|
{/* Tokens line (area + line) */}
|
||||||
|
<path
|
||||||
|
d={pathTokens}
|
||||||
|
fill="none"
|
||||||
|
stroke={COLOR_TOKENS}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Cache hit line */}
|
||||||
|
<path
|
||||||
|
d={pathCache}
|
||||||
|
fill="none"
|
||||||
|
stroke={COLOR_CACHE}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Requests line (optional) */}
|
||||||
|
{showRequests && (
|
||||||
|
<path
|
||||||
|
d={pathRequests}
|
||||||
|
fill="none"
|
||||||
|
stroke={COLOR_REQUESTS}
|
||||||
|
strokeWidth={1.25}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hover crosshair + dot */}
|
||||||
|
{hover && (
|
||||||
|
<g pointerEvents="none">
|
||||||
|
<line
|
||||||
|
x1={pointX(hoverIndex!)}
|
||||||
|
y1={PAD_T}
|
||||||
|
x2={pointX(hoverIndex!)}
|
||||||
|
y2={PAD_T + PLOT_H}
|
||||||
|
stroke="currentColor"
|
||||||
|
className="text-content-muted"
|
||||||
|
strokeWidth={1}
|
||||||
|
strokeDasharray="2 2"
|
||||||
|
opacity={0.5}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={pointX(hoverIndex!)}
|
||||||
|
cy={pointYTokens(hover.totalTokens || 0)}
|
||||||
|
r={3.5}
|
||||||
|
fill={COLOR_TOKENS}
|
||||||
|
stroke="currentColor"
|
||||||
|
className="text-panel"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={pointX(hoverIndex!)}
|
||||||
|
cy={pointYCache(hover.cacheHitRate || 0)}
|
||||||
|
r={3}
|
||||||
|
fill={COLOR_CACHE}
|
||||||
|
stroke="currentColor"
|
||||||
|
className="text-panel"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
{showRequests && (
|
||||||
|
<circle
|
||||||
|
cx={pointX(hoverIndex!)}
|
||||||
|
cy={pointYReq(hover.requests || 0)}
|
||||||
|
r={2.5}
|
||||||
|
fill={COLOR_REQUESTS}
|
||||||
|
stroke="currentColor"
|
||||||
|
className="text-panel"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Axis labels as HTML overlay (avoids SVG preserveAspectRatio="none" font distortion) */}
|
||||||
|
{/* Left Y-axis (tokens) */}
|
||||||
|
{[0, 0.25, 0.5, 0.75, 1].map((p) => {
|
||||||
|
const top = ((PAD_T + PLOT_H * (1 - p)) / H) * 100;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={`yl-${p}`}
|
||||||
|
className="pointer-events-none absolute text-[10px] leading-none text-content-muted"
|
||||||
|
style={{
|
||||||
|
left: 0,
|
||||||
|
top: `${top}%`,
|
||||||
|
width: `${((PAD_L - 4) / W) * 100}%`,
|
||||||
|
textAlign: "right",
|
||||||
|
transform: "translateY(-50%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fmtTokenAxis(maxTokens * p)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* Right Y-axis (cache hit %) */}
|
||||||
|
{[0, 0.5, 1].map((p) => {
|
||||||
|
const top = ((PAD_T + PLOT_H * (1 - p)) / H) * 100;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={`yr-${p}`}
|
||||||
|
className="pointer-events-none absolute text-[10px] leading-none text-content-muted"
|
||||||
|
style={{
|
||||||
|
right: 0,
|
||||||
|
top: `${top}%`,
|
||||||
|
width: `${((PAD_R - 4) / W) * 100}%`,
|
||||||
|
textAlign: "left",
|
||||||
|
transform: "translateY(-50%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Math.round(p * 100)}%
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* X-axis labels */}
|
||||||
|
{xTicks.map((i) => (
|
||||||
|
<span
|
||||||
|
key={`x-${i}`}
|
||||||
|
className="pointer-events-none absolute text-[10px] leading-none text-content-muted"
|
||||||
|
style={{
|
||||||
|
left: `${(pointX(i) / W) * 100}%`,
|
||||||
|
bottom: 0,
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{daily[i].date.slice(5)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Tooltip (HTML overlay for readability) */}
|
||||||
|
{hover && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute z-10 rounded-md border border-border bg-panel px-2.5 py-1.5 text-[11px] text-content-primary shadow-xl"
|
||||||
|
style={{
|
||||||
|
left: `${(pointX(hoverIndex!) / W) * 100}%`,
|
||||||
|
top: 4,
|
||||||
|
transform: hoverIndex! > n / 2 ? "translateX(-100%)" : "translateX(8px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="font-medium">{hover.date}</div>
|
||||||
|
<div className="text-content-muted">
|
||||||
|
{t("trendLineLegendTokens")}: <span className="tabular-nums">{fmtTokens(hover.totalTokens)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-content-muted">
|
||||||
|
{t("trendLineLegendCacheHit")}: <span className="tabular-nums">{(hover.cacheHitRate * 100).toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
{showRequests && (
|
||||||
|
<div className="text-content-muted">
|
||||||
|
{t("trendLineLegendRequests")}: <span className="tabular-nums">{fmtInt(hover.requests)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,9 @@ export type UsageKind =
|
|||||||
| "plan:zhipu"
|
| "plan:zhipu"
|
||||||
| "plan:minimax";
|
| "plan:minimax";
|
||||||
|
|
||||||
|
/** Billing model for a preset. Drives the tab filter in PresetPickerModal. */
|
||||||
|
export type BillingMode = "subscription" | "pay_as_you_go";
|
||||||
|
|
||||||
/** Runtime set used to catch TS/Rust enum drift (see dev-assert below). */
|
/** Runtime set used to catch TS/Rust enum drift (see dev-assert below). */
|
||||||
export const SUPPORTED_USAGE_KINDS: ReadonlySet<string> = new Set<UsageKind>([
|
export const SUPPORTED_USAGE_KINDS: ReadonlySet<string> = new Set<UsageKind>([
|
||||||
"balance:deepseek",
|
"balance:deepseek",
|
||||||
@@ -45,6 +48,7 @@ export interface ProviderPreset {
|
|||||||
nameKey?: string;
|
nameKey?: string;
|
||||||
/** Official website / console URL. */
|
/** Official website / console URL. */
|
||||||
websiteUrl?: string;
|
websiteUrl?: string;
|
||||||
|
/** Where to create / fetch an API key; shown as a link under the key input. */
|
||||||
apiKeyUrl?: string;
|
apiKeyUrl?: string;
|
||||||
/** Category: sorting & badge. */
|
/** Category: sorting & badge. */
|
||||||
category: PresetCategory;
|
category: PresetCategory;
|
||||||
@@ -71,6 +75,12 @@ export interface ProviderPreset {
|
|||||||
/** Defaults to ["thinking"]. */
|
/** Defaults to ["thinking"]. */
|
||||||
capabilities?: string[];
|
capabilities?: string[];
|
||||||
}>;
|
}>;
|
||||||
|
/**
|
||||||
|
* Billing model — drives the 套餐/按量 tab in PresetPickerModal.
|
||||||
|
* - "subscription": coding-plan / token-plan style subscription (quota-based)
|
||||||
|
* - "pay_as_you_go": per-token API billing
|
||||||
|
*/
|
||||||
|
billingMode: BillingMode;
|
||||||
/**
|
/**
|
||||||
* Billing/usage query kinds; omitted means "do not query".
|
* Billing/usage query kinds; omitted means "do not query".
|
||||||
* Persisted to SQLite settings by the Rust side on save, never to
|
* Persisted to SQLite settings by the Rust side on save, never to
|
||||||
@@ -95,12 +105,14 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
{ model: "claude-sonnet-4-5" },
|
{ model: "claude-sonnet-4-5" },
|
||||||
{ model: "claude-haiku-4-5" },
|
{ model: "claude-haiku-4-5" },
|
||||||
],
|
],
|
||||||
|
billingMode: "pay_as_you_go",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "kimi-coding",
|
id: "kimi-coding",
|
||||||
name: "Kimi For Coding",
|
name: "Kimi For Coding",
|
||||||
nameKey: "presetNameKimiCoding",
|
nameKey: "presetNameKimiCoding",
|
||||||
websiteUrl: "https://www.kimi.com/code",
|
websiteUrl: "https://www.kimi.com/code",
|
||||||
|
apiKeyUrl: "https://kimi-bot.com/activities/zh-cn/invite/share?scenario=invite&from=share_poster&invitation_code=6UJX7J",
|
||||||
category: "official",
|
category: "official",
|
||||||
providerType: "kimi",
|
providerType: "kimi",
|
||||||
baseUrl: "https://api.kimi.com/coding/v1",
|
baseUrl: "https://api.kimi.com/coding/v1",
|
||||||
@@ -111,6 +123,7 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
{ model: "kimi-for-coding", displayName: "Kimi For Coding" },
|
{ model: "kimi-for-coding", displayName: "Kimi For Coding" },
|
||||||
{ model: "kimi-k2.7-code" },
|
{ model: "kimi-k2.7-code" },
|
||||||
],
|
],
|
||||||
|
billingMode: "subscription",
|
||||||
usageKinds: ["plan:kimi_coding"],
|
usageKinds: ["plan:kimi_coding"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -127,6 +140,7 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
{ model: "kimi-k2-thinking-turbo" },
|
{ model: "kimi-k2-thinking-turbo" },
|
||||||
{ model: "kimi-k2.6" },
|
{ model: "kimi-k2.6" },
|
||||||
],
|
],
|
||||||
|
billingMode: "pay_as_you_go",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "deepseek",
|
id: "deepseek",
|
||||||
@@ -138,12 +152,13 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
baseUrl: "https://api.deepseek.com/v1",
|
baseUrl: "https://api.deepseek.com/v1",
|
||||||
icon: "deepseek",
|
icon: "deepseek",
|
||||||
models: [{ model: "deepseek-chat" }, { model: "deepseek-reasoner" }],
|
models: [{ model: "deepseek-chat" }, { model: "deepseek-reasoner" }],
|
||||||
|
billingMode: "pay_as_you_go",
|
||||||
usageKinds: ["balance:deepseek"],
|
usageKinds: ["balance:deepseek"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "zhipu",
|
id: "zhipu-api",
|
||||||
name: "Zhipu GLM",
|
name: "Zhipu GLM (API)",
|
||||||
nameKey: "presetNameZhipu",
|
nameKey: "presetNameZhipuApi",
|
||||||
websiteUrl: "https://open.bigmodel.cn",
|
websiteUrl: "https://open.bigmodel.cn",
|
||||||
category: "cn_official",
|
category: "cn_official",
|
||||||
providerType: "openai",
|
providerType: "openai",
|
||||||
@@ -154,18 +169,51 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
{ model: "glm-4.6" },
|
{ model: "glm-4.6" },
|
||||||
{ model: "glm-4.5-air" },
|
{ model: "glm-4.5-air" },
|
||||||
],
|
],
|
||||||
|
billingMode: "pay_as_you_go",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zhipu-coding",
|
||||||
|
name: "Zhipu GLM Coding Plan",
|
||||||
|
nameKey: "presetNameZhipuCoding",
|
||||||
|
websiteUrl: "https://open.bigmodel.cn",
|
||||||
|
category: "cn_official",
|
||||||
|
providerType: "openai",
|
||||||
|
// Coding Plan endpoint is separate from the pay-as-you-go PaaS API.
|
||||||
|
baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4",
|
||||||
|
icon: "zhipu",
|
||||||
|
models: [
|
||||||
|
{ model: "glm-4.7" },
|
||||||
|
{ model: "glm-4.6" },
|
||||||
|
{ model: "glm-4.5-air" },
|
||||||
|
],
|
||||||
|
billingMode: "subscription",
|
||||||
usageKinds: ["plan:zhipu"],
|
usageKinds: ["plan:zhipu"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "zai",
|
id: "zai-api",
|
||||||
name: "z.ai",
|
name: "z.ai (API)",
|
||||||
nameKey: "presetNameZai",
|
nameKey: "presetNameZaiApi",
|
||||||
websiteUrl: "https://z.ai",
|
websiteUrl: "https://z.ai",
|
||||||
category: "third_party",
|
category: "third_party",
|
||||||
providerType: "openai",
|
providerType: "openai",
|
||||||
baseUrl: "https://api.z.ai/api/paas/v4",
|
baseUrl: "https://api.z.ai/api/paas/v4",
|
||||||
icon: "zhipu",
|
icon: "zhipu",
|
||||||
models: [{ model: "glm-4.7" }, { model: "glm-4.6" }],
|
models: [{ model: "glm-4.7" }, { model: "glm-4.6" }],
|
||||||
|
billingMode: "pay_as_you_go",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zai-coding",
|
||||||
|
name: "z.ai Coding Plan",
|
||||||
|
nameKey: "presetNameZaiCoding",
|
||||||
|
websiteUrl: "https://z.ai",
|
||||||
|
category: "third_party",
|
||||||
|
providerType: "openai",
|
||||||
|
// Coding Plan endpoint (mirror of Zhipu GLM Coding Plan). If the path
|
||||||
|
// differs on z.ai, fall back to a known mirror.
|
||||||
|
baseUrl: "https://api.z.ai/api/coding/paas/v4",
|
||||||
|
icon: "zhipu",
|
||||||
|
models: [{ model: "glm-4.7" }, { model: "glm-4.6" }],
|
||||||
|
billingMode: "subscription",
|
||||||
usageKinds: ["plan:zhipu"],
|
usageKinds: ["plan:zhipu"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -182,6 +230,7 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
{ model: "qwen3-coder-plus" },
|
{ model: "qwen3-coder-plus" },
|
||||||
{ model: "qwen3.5-plus" },
|
{ model: "qwen3.5-plus" },
|
||||||
],
|
],
|
||||||
|
billingMode: "pay_as_you_go",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "minimax",
|
id: "minimax",
|
||||||
@@ -193,6 +242,26 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
baseUrl: "https://api.minimaxi.com/v1",
|
baseUrl: "https://api.minimaxi.com/v1",
|
||||||
icon: "minimax",
|
icon: "minimax",
|
||||||
models: [{ model: "MiniMax-M3" }, { model: "MiniMax-M2.7" }],
|
models: [{ model: "MiniMax-M3" }, { model: "MiniMax-M2.7" }],
|
||||||
|
billingMode: "pay_as_you_go",
|
||||||
|
usageKinds: ["plan:minimax"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "minimax-token-plan",
|
||||||
|
name: "MiniMax Token Plan",
|
||||||
|
nameKey: "presetNameMinimaxTokenPlan",
|
||||||
|
websiteUrl: "https://platform.minimaxi.com",
|
||||||
|
apiKeyUrl: "https://platform.minimaxi.com/subscribe/token-plan?code=GmmZA629b5&source=link",
|
||||||
|
category: "cn_official",
|
||||||
|
providerType: "openai",
|
||||||
|
// Token Plan is sold against the same platform endpoint as the
|
||||||
|
// pay-as-you-go API — only the API key differs. The Rust detect
|
||||||
|
// routes api.minimaxi.com to plan:minimax, so this preset picks up
|
||||||
|
// the same query path; the `subscription` tag helps the user pick
|
||||||
|
// the right entry based on which key they hold.
|
||||||
|
baseUrl: "https://api.minimaxi.com/v1",
|
||||||
|
icon: "minimax",
|
||||||
|
models: [{ model: "MiniMax-M3" }, { model: "MiniMax-M2.7" }],
|
||||||
|
billingMode: "subscription",
|
||||||
usageKinds: ["plan:minimax"],
|
usageKinds: ["plan:minimax"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -205,8 +274,24 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
baseUrl: "https://api.stepfun.com/v1",
|
baseUrl: "https://api.stepfun.com/v1",
|
||||||
icon: "stepfun",
|
icon: "stepfun",
|
||||||
models: [{ model: "step-3.7-flash" }, { model: "step-3.5-flash" }],
|
models: [{ model: "step-3.7-flash" }, { model: "step-3.5-flash" }],
|
||||||
|
billingMode: "pay_as_you_go",
|
||||||
usageKinds: ["balance:stepfun"],
|
usageKinds: ["balance:stepfun"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "stepfun-plan",
|
||||||
|
name: "StepFun Plan",
|
||||||
|
nameKey: "presetNameStepfunPlan",
|
||||||
|
websiteUrl: "https://platform.stepfun.com/step-plan",
|
||||||
|
apiKeyUrl: "https://platform.stepfun.com/?invite_code=HSIGHTPS",
|
||||||
|
category: "cn_official",
|
||||||
|
providerType: "openai",
|
||||||
|
// Step Plan endpoint — same host, different path prefix than the
|
||||||
|
// pay-as-you-go API (/v1). Serves both OpenAI and Anthropic protocols.
|
||||||
|
baseUrl: "https://api.stepfun.com/step_plan/v1",
|
||||||
|
icon: "stepfun",
|
||||||
|
models: [{ model: "step-3.7-flash" }, { model: "step-3.5-flash" }],
|
||||||
|
billingMode: "subscription",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "siliconflow",
|
id: "siliconflow",
|
||||||
name: "SiliconFlow",
|
name: "SiliconFlow",
|
||||||
@@ -229,6 +314,7 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
usageKinds: ["balance:siliconflow"],
|
usageKinds: ["balance:siliconflow"],
|
||||||
|
billingMode: "pay_as_you_go",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "novita",
|
id: "novita",
|
||||||
@@ -251,6 +337,7 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
{ model: "zai-org/glm-4.6" },
|
{ model: "zai-org/glm-4.6" },
|
||||||
],
|
],
|
||||||
usageKinds: ["balance:novita"],
|
usageKinds: ["balance:novita"],
|
||||||
|
billingMode: "pay_as_you_go",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "openrouter",
|
id: "openrouter",
|
||||||
@@ -273,6 +360,7 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
{ model: "google/gemini-3-pro-preview" },
|
{ model: "google/gemini-3-pro-preview" },
|
||||||
],
|
],
|
||||||
usageKinds: ["balance:openrouter"],
|
usageKinds: ["balance:openrouter"],
|
||||||
|
billingMode: "pay_as_you_go",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "openai",
|
id: "openai",
|
||||||
@@ -288,6 +376,7 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
{ model: "gpt-5.1-codex-max" },
|
{ model: "gpt-5.1-codex-max" },
|
||||||
{ model: "gpt-5-mini" },
|
{ model: "gpt-5-mini" },
|
||||||
],
|
],
|
||||||
|
billingMode: "pay_as_you_go",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "google-genai",
|
id: "google-genai",
|
||||||
@@ -303,6 +392,7 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
{ model: "gemini-3-flash-preview" },
|
{ model: "gemini-3-flash-preview" },
|
||||||
{ model: "gemini-2.5-flash" },
|
{ model: "gemini-2.5-flash" },
|
||||||
],
|
],
|
||||||
|
billingMode: "pay_as_you_go",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "volcengine",
|
id: "volcengine",
|
||||||
@@ -321,6 +411,50 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
},
|
},
|
||||||
{ model: "kimi-k2-250905", displayName: "Kimi K2" },
|
{ model: "kimi-k2-250905", displayName: "Kimi K2" },
|
||||||
],
|
],
|
||||||
|
billingMode: "pay_as_you_go",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "opencode-go",
|
||||||
|
name: "OpenCode Go",
|
||||||
|
nameKey: "presetNameOpencodeGo",
|
||||||
|
// Referral link — supports the OpenCode project
|
||||||
|
websiteUrl: "https://opencode.ai/go?ref=DFCNADQCEM",
|
||||||
|
apiKeyUrl: "https://opencode.ai/go?ref=DFCNADQCEM",
|
||||||
|
category: "third_party",
|
||||||
|
providerType: "openai",
|
||||||
|
baseUrl: "https://opencode.ai/zen/go/v1",
|
||||||
|
icon: "opencode",
|
||||||
|
models: [
|
||||||
|
{ model: "grok-4.5", displayName: "Grok 4.5" },
|
||||||
|
{ model: "glm-5.2", displayName: "GLM-5.2" },
|
||||||
|
{ model: "glm-5.1", displayName: "GLM-5.1" },
|
||||||
|
{ model: "kimi-k3", displayName: "Kimi K3" },
|
||||||
|
{ model: "kimi-k2.7-code", displayName: "Kimi K2.7 Code" },
|
||||||
|
{ model: "kimi-k2.6", displayName: "Kimi K2.6" },
|
||||||
|
{ model: "deepseek-v4-pro", displayName: "DeepSeek V4 Pro" },
|
||||||
|
{ model: "deepseek-v4-flash", displayName: "DeepSeek V4 Flash" },
|
||||||
|
],
|
||||||
|
billingMode: "subscription",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "opencode-zen",
|
||||||
|
name: "OpenCode Zen",
|
||||||
|
nameKey: "presetNameOpencodeZen",
|
||||||
|
websiteUrl: "https://opencode.ai/zen/",
|
||||||
|
apiKeyUrl: "https://opencode.ai/zen/",
|
||||||
|
category: "third_party",
|
||||||
|
providerType: "openai",
|
||||||
|
baseUrl: "https://opencode.ai/zen/v1",
|
||||||
|
icon: "opencode",
|
||||||
|
models: [
|
||||||
|
{ model: "claude-opus-5", displayName: "Claude Opus 5" },
|
||||||
|
{ model: "claude-sonnet-5", displayName: "Claude Sonnet 5" },
|
||||||
|
{ model: "gpt-5.5", displayName: "GPT 5.5" },
|
||||||
|
{ model: "gemini-3.6-flash", displayName: "Gemini 3.6 Flash" },
|
||||||
|
{ model: "grok-4.5", displayName: "Grok 4.5" },
|
||||||
|
{ model: "deepseek-v4-pro", displayName: "DeepSeek V4 Pro" },
|
||||||
|
],
|
||||||
|
billingMode: "pay_as_you_go",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -405,6 +539,21 @@ export function presetToProviderAndModels(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort reverse lookup: which preset (if any) a Provider was created
|
||||||
|
* from. Matches the provider name with any "-2"/"-3" dedup suffix stripped,
|
||||||
|
* falling back to an exact official_url match. Used to surface preset-only
|
||||||
|
* metadata (apiKeyUrl / referralUrl) in the edit form without persisting
|
||||||
|
* extra fields on Provider.
|
||||||
|
*/
|
||||||
|
export function findPresetForProvider(provider: Provider): ProviderPreset | undefined {
|
||||||
|
const base = provider.name.replace(/-\d+$/, "");
|
||||||
|
return (
|
||||||
|
providerPresets.find((p) => p.id === base) ??
|
||||||
|
providerPresets.find((p) => p.websiteUrl != null && p.websiteUrl === provider.official_url)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Dev-only cross-end drift check: assert that every usage kind referenced by
|
// Dev-only cross-end drift check: assert that every usage kind referenced by
|
||||||
// any preset is one the Rust `UsageKind` enum knows how to handle. The
|
// any preset is one the Rust `UsageKind` enum knows how to handle. The
|
||||||
// runtime filter in `query_provider_usage` silently drops unknown kinds,
|
// runtime filter in `query_provider_usage` silently drops unknown kinds,
|
||||||
|
|||||||
+28
-2
@@ -60,6 +60,7 @@ export const enTranslations: Record<TranslationKey, string> = {
|
|||||||
apiFormat: "API Format",
|
apiFormat: "API Format",
|
||||||
authField: "Auth Field",
|
authField: "Auth Field",
|
||||||
apiKey: "API Key",
|
apiKey: "API Key",
|
||||||
|
getApiKeyLink: "Get API Key",
|
||||||
show: "Show",
|
show: "Show",
|
||||||
hide: "Hide",
|
hide: "Hide",
|
||||||
requestUrl: "Request URL",
|
requestUrl: "Request URL",
|
||||||
@@ -73,6 +74,7 @@ export const enTranslations: Record<TranslationKey, string> = {
|
|||||||
fetchingModels: "Fetching...",
|
fetchingModels: "Fetching...",
|
||||||
discoveredModels: "Discovered {count} models",
|
discoveredModels: "Discovered {count} models",
|
||||||
addSelected: "Add selected",
|
addSelected: "Add selected",
|
||||||
|
alreadyAdded: "Added",
|
||||||
displayName: "Display name",
|
displayName: "Display name",
|
||||||
actualModel: "Actual model",
|
actualModel: "Actual model",
|
||||||
contextSize: "Context size",
|
contextSize: "Context size",
|
||||||
@@ -283,6 +285,7 @@ export const enTranslations: Record<TranslationKey, string> = {
|
|||||||
cardDailyTrendSubCapped: "Last 30 days · {n} days total",
|
cardDailyTrendSubCapped: "Last 30 days · {n} days total",
|
||||||
tabModelTrend: "Model usage trend",
|
tabModelTrend: "Model usage trend",
|
||||||
tabProviderTrend: "Provider–model usage trend",
|
tabProviderTrend: "Provider–model usage trend",
|
||||||
|
tabDailyTrend: "Daily usage trend",
|
||||||
providerUnknown: "Unknown provider",
|
providerUnknown: "Unknown provider",
|
||||||
cardDailyTrendSub: "{n} days",
|
cardDailyTrendSub: "{n} days",
|
||||||
cardModelUsage: "Model usage",
|
cardModelUsage: "Model usage",
|
||||||
@@ -339,17 +342,23 @@ export const enTranslations: Record<TranslationKey, string> = {
|
|||||||
presetNameKimiCoding: "Kimi For Coding",
|
presetNameKimiCoding: "Kimi For Coding",
|
||||||
presetNameMoonshot: "Moonshot Platform",
|
presetNameMoonshot: "Moonshot Platform",
|
||||||
presetNameDeepseek: "DeepSeek",
|
presetNameDeepseek: "DeepSeek",
|
||||||
presetNameZhipu: "Zhipu GLM",
|
presetNameZhipuApi: "Zhipu GLM (Pay-as-you-go)",
|
||||||
presetNameZai: "z.ai (Zhipu Global)",
|
presetNameZhipuCoding: "Zhipu GLM Coding Plan",
|
||||||
|
presetNameZaiApi: "z.ai (Pay-as-you-go)",
|
||||||
|
presetNameZaiCoding: "z.ai Coding Plan",
|
||||||
presetNameBailian: "Alibaba Bailian",
|
presetNameBailian: "Alibaba Bailian",
|
||||||
presetNameMinimax: "MiniMax",
|
presetNameMinimax: "MiniMax",
|
||||||
|
presetNameMinimaxTokenPlan: "MiniMax Token Plan",
|
||||||
presetNameStepfun: "StepFun",
|
presetNameStepfun: "StepFun",
|
||||||
|
presetNameStepfunPlan: "StepFun Plan",
|
||||||
presetNameSiliconflow: "SiliconFlow",
|
presetNameSiliconflow: "SiliconFlow",
|
||||||
presetNameNovita: "Novita AI",
|
presetNameNovita: "Novita AI",
|
||||||
presetNameOpenrouter: "OpenRouter",
|
presetNameOpenrouter: "OpenRouter",
|
||||||
presetNameOpenai: "OpenAI",
|
presetNameOpenai: "OpenAI",
|
||||||
presetNameGoogleGenai: "Google AI Studio",
|
presetNameGoogleGenai: "Google AI Studio",
|
||||||
presetNameVolcengine: "Volcengine Ark",
|
presetNameVolcengine: "Volcengine Ark",
|
||||||
|
presetNameOpencodeGo: "OpenCode Go",
|
||||||
|
presetNameOpencodeZen: "OpenCode Zen",
|
||||||
|
|
||||||
// Provider usage
|
// Provider usage
|
||||||
usageBalance: "Balance",
|
usageBalance: "Balance",
|
||||||
@@ -363,4 +372,21 @@ export const enTranslations: Record<TranslationKey, string> = {
|
|||||||
usageResetIn: "resets in {time}",
|
usageResetIn: "resets in {time}",
|
||||||
usageResetDone: "reset",
|
usageResetDone: "reset",
|
||||||
usageLoading: "Querying…",
|
usageLoading: "Querying…",
|
||||||
|
|
||||||
|
// Billing mode (subscription / pay-as-you-go)
|
||||||
|
billingTabSubscription: "Subscription",
|
||||||
|
billingTabPayAsYouGo: "Pay-as-you-go",
|
||||||
|
billingTabAll: "All",
|
||||||
|
billingBadgeSubscription: "Plan",
|
||||||
|
billingBadgePayAsYouGo: "PAYG",
|
||||||
|
|
||||||
|
// Usage trend line chart
|
||||||
|
trendLineTitle: "Usage Trend",
|
||||||
|
trendLineSub: "last {n} days",
|
||||||
|
trendLineLegendTokens: "Token usage",
|
||||||
|
trendLineLegendCacheHit: "Cache hit rate",
|
||||||
|
trendLineLegendRequests: "Requests",
|
||||||
|
trendLineShowRequests: "Show requests",
|
||||||
|
trendLineTooltip: "{date}\nTokens: {tokens}\nCache hit: {cache}\nRequests: {req}",
|
||||||
|
trendLineNoData: "No data",
|
||||||
};
|
};
|
||||||
+28
-2
@@ -58,6 +58,7 @@ export const zhTranslations = {
|
|||||||
apiFormat: "API 格式",
|
apiFormat: "API 格式",
|
||||||
authField: "认证字段",
|
authField: "认证字段",
|
||||||
apiKey: "API Key",
|
apiKey: "API Key",
|
||||||
|
getApiKeyLink: "获取 API Key",
|
||||||
show: "显示",
|
show: "显示",
|
||||||
hide: "隐藏",
|
hide: "隐藏",
|
||||||
requestUrl: "请求地址",
|
requestUrl: "请求地址",
|
||||||
@@ -71,6 +72,7 @@ export const zhTranslations = {
|
|||||||
fetchingModels: "获取中...",
|
fetchingModels: "获取中...",
|
||||||
discoveredModels: "发现 {count} 个模型",
|
discoveredModels: "发现 {count} 个模型",
|
||||||
addSelected: "添加选中的",
|
addSelected: "添加选中的",
|
||||||
|
alreadyAdded: "已添加",
|
||||||
displayName: "显示名称",
|
displayName: "显示名称",
|
||||||
actualModel: "实际请求模型",
|
actualModel: "实际请求模型",
|
||||||
contextSize: "上下文长度",
|
contextSize: "上下文长度",
|
||||||
@@ -278,6 +280,7 @@ export const zhTranslations = {
|
|||||||
cardDailyTrendSubCapped: "近 30 天 · 共 {n} 天",
|
cardDailyTrendSubCapped: "近 30 天 · 共 {n} 天",
|
||||||
tabModelTrend: "模型用量趋势",
|
tabModelTrend: "模型用量趋势",
|
||||||
tabProviderTrend: "供应商模型用量趋势",
|
tabProviderTrend: "供应商模型用量趋势",
|
||||||
|
tabDailyTrend: "每日用量趋势",
|
||||||
providerUnknown: "未知供应商",
|
providerUnknown: "未知供应商",
|
||||||
cardDailyTrendSub: "共 {n} 天",
|
cardDailyTrendSub: "共 {n} 天",
|
||||||
cardModelUsage: "模型用量",
|
cardModelUsage: "模型用量",
|
||||||
@@ -334,17 +337,23 @@ export const zhTranslations = {
|
|||||||
presetNameKimiCoding: "Kimi For Coding",
|
presetNameKimiCoding: "Kimi For Coding",
|
||||||
presetNameMoonshot: "Moonshot 平台",
|
presetNameMoonshot: "Moonshot 平台",
|
||||||
presetNameDeepseek: "DeepSeek",
|
presetNameDeepseek: "DeepSeek",
|
||||||
presetNameZhipu: "智谱 GLM",
|
presetNameZhipuApi: "智谱 GLM(按量)",
|
||||||
presetNameZai: "z.ai(智谱海外)",
|
presetNameZhipuCoding: "智谱 GLM Coding Plan",
|
||||||
|
presetNameZaiApi: "z.ai(按量)",
|
||||||
|
presetNameZaiCoding: "z.ai Coding Plan",
|
||||||
presetNameBailian: "阿里百炼",
|
presetNameBailian: "阿里百炼",
|
||||||
presetNameMinimax: "MiniMax",
|
presetNameMinimax: "MiniMax",
|
||||||
|
presetNameMinimaxTokenPlan: "MiniMax Token Plan",
|
||||||
presetNameStepfun: "阶跃星辰",
|
presetNameStepfun: "阶跃星辰",
|
||||||
|
presetNameStepfunPlan: "阶跃星辰 Step Plan",
|
||||||
presetNameSiliconflow: "硅基流动",
|
presetNameSiliconflow: "硅基流动",
|
||||||
presetNameNovita: "Novita AI",
|
presetNameNovita: "Novita AI",
|
||||||
presetNameOpenrouter: "OpenRouter",
|
presetNameOpenrouter: "OpenRouter",
|
||||||
presetNameOpenai: "OpenAI 官方",
|
presetNameOpenai: "OpenAI 官方",
|
||||||
presetNameGoogleGenai: "Google AI Studio",
|
presetNameGoogleGenai: "Google AI Studio",
|
||||||
presetNameVolcengine: "火山方舟",
|
presetNameVolcengine: "火山方舟",
|
||||||
|
presetNameOpencodeGo: "OpenCode Go",
|
||||||
|
presetNameOpencodeZen: "OpenCode Zen",
|
||||||
|
|
||||||
// Provider usage
|
// Provider usage
|
||||||
usageBalance: "余额",
|
usageBalance: "余额",
|
||||||
@@ -358,6 +367,23 @@ export const zhTranslations = {
|
|||||||
usageResetIn: "{time} 后重置",
|
usageResetIn: "{time} 后重置",
|
||||||
usageResetDone: "已重置",
|
usageResetDone: "已重置",
|
||||||
usageLoading: "查询中…",
|
usageLoading: "查询中…",
|
||||||
|
|
||||||
|
// Billing mode (套餐 / 按量)
|
||||||
|
billingTabSubscription: "套餐",
|
||||||
|
billingTabPayAsYouGo: "按量",
|
||||||
|
billingTabAll: "全部",
|
||||||
|
billingBadgeSubscription: "套餐",
|
||||||
|
billingBadgePayAsYouGo: "按量",
|
||||||
|
|
||||||
|
// Usage trend line chart
|
||||||
|
trendLineTitle: "用量趋势",
|
||||||
|
trendLineSub: "最近 {n} 天",
|
||||||
|
trendLineLegendTokens: "Token 用量",
|
||||||
|
trendLineLegendCacheHit: "缓存命中率",
|
||||||
|
trendLineLegendRequests: "请求数",
|
||||||
|
trendLineShowRequests: "显示请求数",
|
||||||
|
trendLineTooltip: "{date}\nToken: {tokens}\n命中率: {cache}\n请求: {req}",
|
||||||
|
trendLineNoData: "暂无数据",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type TranslationKey = keyof typeof zhTranslations;
|
export type TranslationKey = keyof typeof zhTranslations;
|
||||||
Reference in new issue
Block a user