From bee981aefa87b72cdfbe620bbc24bcd97ac796c4 Mon Sep 17 00:00:00 2001 From: KimiSwitch Dev Date: Thu, 30 Jul 2026 11:50:05 +0800 Subject: [PATCH 1/3] =?UTF-8?q?v0.6.2:=20=E4=BB=AA=E8=A1=A8=E7=9B=98?= =?UTF-8?q?=E5=B8=83=E5=B1=80=E9=87=8D=E6=9E=84=20+=20=E6=AF=8F=E6=97=A5?= =?UTF-8?q?=E8=B6=8B=E5=8A=BF=E6=8A=98=E7=BA=BF=E5=9B=BE=20+=20OpenCode=20?= =?UTF-8?q?Go/Zen=20=E9=A2=84=E8=AE=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 仪表盘: - 新增每日用量趋势折线图(Token/缓存命中率/请求数三线) - 用量趋势 Tab 组(每日/模型/供应商模型)与模型表格同行等高 - 热力图独立整行,格子放大 16px - 折线图坐标轴文字改 HTML 覆盖层(修复 SVG 拉伸变形) - 表格 border-collapse + 行线与边框同色 - 模型表格估算标签行内显示(行高统一) - 最近请求容器 max-h 限制 - 页脚归属放内容流末尾 模型唯一性: - 获取模型列表时自动勾选已存在模型 + 已添加徽章 - 批量添加跳过同供应商已存在 model id - onBulkAdd 同供应商 model id 去重 新增预设: - OpenCode Go(套餐 subscription,带推荐链接) - OpenCode Zen(按量 pay_as_you_go) 折线图优化: - 缓存命中率 0-1 映射修复 - 请求数默认显示 - 直线连接 + 渐变填充 --- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- src/App.tsx | 5 + src/components/PresetPickerModal.tsx | 148 +++++--- src/components/ProviderEdit.tsx | 15 +- src/components/dashboard/DashboardPage.tsx | 103 +++--- src/components/dashboard/Heatmap.tsx | 2 +- src/components/dashboard/TrendLineChart.tsx | 369 ++++++++++++++++++++ src/config/providerPresets.ts | 128 ++++++- src/i18n/en.ts | 28 +- src/i18n/zh.ts | 28 +- 13 files changed, 739 insertions(+), 95 deletions(-) create mode 100644 src/components/dashboard/TrendLineChart.tsx diff --git a/package.json b/package.json index 49ad049..a8913b2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "kimiswitch", "private": true, - "version": "0.6.0", + "version": "0.6.2", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e8d0152..e937bc3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1958,7 +1958,7 @@ dependencies = [ [[package]] name = "kimiswitch" -version = "0.6.0" +version = "0.6.2" dependencies = [ "anyhow", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a2ce5a9..7a8f359 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kimiswitch" -version = "0.6.0" +version = "0.6.2" description = "Kimi Switch - model config manager" authors = ["you"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 39a6aa6..760f57e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "Kimi Switch", - "version": "0.6.0", + "version": "0.6.2", "identifier": "com.kimiswitch.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index 40f2a38..357a7fc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -703,6 +703,11 @@ export default function App() { updateConfig((cfg) => { const updated = { ...cfg.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 n = 1; while (alias in updated) { diff --git a/src/components/PresetPickerModal.tsx b/src/components/PresetPickerModal.tsx index cbaef09..ee1ff83 100644 --- a/src/components/PresetPickerModal.tsx +++ b/src/components/PresetPickerModal.tsx @@ -2,7 +2,12 @@ import { useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "../i18n"; import type { TranslationKey } from "../i18n/zh"; -import { providerPresets, type PresetCategory, type ProviderPreset } from "../config/providerPresets"; +import { + providerPresets, + type BillingMode, + type PresetCategory, + type ProviderPreset, +} from "../config/providerPresets"; import { ProviderIcon } from "./ProviderIcon"; interface PresetPickerModalProps { @@ -26,12 +31,26 @@ const CATEGORY_LABEL: Record, TranslationKey> aggregator: "presetCategoryAggregator", }; +const BILLING_BADGE: Record = { + 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 = { + subscription: "billingBadgeSubscription", + pay_as_you_go: "billingBadgePayAsYouGo", +}; + 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) { const { t } = useTranslation(); const [query, setQuery] = useState(""); const [sort, setSort] = useState("original"); + const [billingTab, setBillingTab] = useState("all"); // Esc to close useEffect(() => { @@ -58,15 +77,31 @@ export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetP if (open) { setQuery(""); setSort("original"); + setBillingTab("all"); } }, [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) => p.nameKey ? t(p.nameKey as TranslationKey) : p.name; const visible = useMemo(() => { const q = query.trim().toLowerCase(); let list = providerPresets; + if (billingTab !== "all") { + list = list.filter((p) => p.billingMode === billingTab); + } if (q) { list = list.filter( (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; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [query, sort, t]); + }, [query, sort, billingTab, t]); if (!open) return null; @@ -110,39 +145,67 @@ export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetP - {/* Search + sort */} -
- setQuery(e.target.value)} - placeholder={t("presetSearchPlaceholder")} - autoFocus - className="h-8 flex-1 rounded border border-border bg-input px-2 text-sm text-content-primary focus:outline-none focus:ring-2 focus:ring-blue-500" - /> -
- - + {/* Billing-mode tabs + search + sort */} +
+
+ {BILLING_TAB_ORDER.map((tab) => { + const labelKey: TranslationKey = + tab === "all" + ? "billingTabAll" + : tab === "subscription" + ? "billingTabSubscription" + : "billingTabPayAsYouGo"; + const count = billingCounts[tab]; + const active = billingTab === tab; + return ( + + ); + })} +
+
+ setQuery(e.target.value)} + placeholder={t("presetSearchPlaceholder")} + autoFocus + className="h-8 flex-1 rounded border border-border bg-input px-2 text-sm text-content-primary focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ + +
@@ -168,13 +231,20 @@ export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetP
{presetName(p)}
- {p.category !== "custom" && ( +
+ {p.category !== "custom" && ( + + {t(CATEGORY_LABEL[p.category])} + + )} - {t(CATEGORY_LABEL[p.category])} + {t(BILLING_LABEL[p.billingMode])} - )} +
))}
diff --git a/src/components/ProviderEdit.tsx b/src/components/ProviderEdit.tsx index 42e9ad1..be5bff0 100644 --- a/src/components/ProviderEdit.tsx +++ b/src/components/ProviderEdit.tsx @@ -459,6 +459,9 @@ function ModelMapping({ provider, }); 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) { setDiscoverError(err instanceof Error ? err.message : String(err)); } finally { @@ -477,9 +480,11 @@ function ModelMapping({ const handleAddSelected = () => { if (!discovered || selected.size === 0) return; + const existingIds = new Set(models.filter((m) => m.model).map((m) => m.model)); const toAdd: Model[] = []; for (const dm of discovered) { 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 safeModelId = dm.id.replace(/\//g, "-"); const alias = `${safeProvider}/${safeModelId}`; @@ -555,7 +560,9 @@ function ModelMapping({ {t("fetchEnableThinking")}
- {discovered.map((m) => ( + {discovered.map((m) => { + const exists = models.some((mm) => mm.model === m.id); + return ( - ))} + ); + })}
- {/* Heatmap — full-year strip, fixed-size cells, centered */} + {/* Heatmap — full row */} - {/* Charts + Model side table */} -
+ {/* Usage-trend tabs + Model table side by side */} +
{/* Usage-trend tab container */}
{/* Tab bar header */}
+
- {daily.length > 30 - ? t("cardDailyTrendSubCapped", { n: daily.length }) - : t("cardDailyTrendSub", { n: daily.length })} + {trendTab === "daily" + ? t("trendLineSub", { n: trendDaily.length }) + : daily.length > 30 + ? t("cardDailyTrendSubCapped", { n: daily.length }) + : t("cardDailyTrendSub", { n: daily.length })}
{/* Active tab content */} -
-
- -
+
+ {trendTab === "daily" ? ( +
+ +
+ ) : ( +
+ +
+ )}
@@ -246,7 +268,7 @@ export function DashboardPage() {
{t("noData")}
) : (
- +
@@ -260,7 +282,7 @@ export function DashboardPage() { {visibleModels.map((m) => (
{t("colModel")}
@@ -273,10 +295,10 @@ export function DashboardPage() { : "#6b7280", }} /> -
-
{m.modelDisplay || m.model}
+
+ {m.modelDisplay || m.model} {m.costEstimated && ( - {t("estimate")} + ({t("estimate")}) )}
@@ -316,8 +338,8 @@ export function DashboardPage() {
{t("noData")}
) : ( <> -
- +
+
@@ -330,7 +352,7 @@ export function DashboardPage() { {recentPageRows.map((r, i) => ( - + @@ -398,20 +420,23 @@ export function DashboardPage() { )} - {/* Attribution footer */} + {/* Footer — meta + attribution at the end of the content flow */}
- {t("footerScanned", { n: data.meta.filesScanned, m: fmtInt(data.meta.recordCount) })} ·{" "} - {data.meta.home} -
- {t("dashboardAttribution")}{" "} - {" "} - {t("attributionSuffix")} +
+ {t("footerScanned", { n: data.meta.filesScanned, m: fmtInt(data.meta.recordCount) })} ·{" "} + {data.meta.home} +
+
+ {t("dashboardAttribution")}{" "} + {" "} + {t("attributionSuffix")} +
); diff --git a/src/components/dashboard/Heatmap.tsx b/src/components/dashboard/Heatmap.tsx index 4ec8ef8..2c14d53 100644 --- a/src/components/dashboard/Heatmap.tsx +++ b/src/components/dashboard/Heatmap.tsx @@ -19,7 +19,7 @@ const LEVEL_BG = [ const DOW_LABELS_ZH = ["日", "一", "二", "三", "四", "五", "六"]; const DOW_LABELS_EN = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; -const CELL = 12; // px +const CELL = 16; // px const GAP = 3; // px const LABEL_W = 28; // dow label column width diff --git a/src/components/dashboard/TrendLineChart.tsx b/src/components/dashboard/TrendLineChart.tsx new file mode 100644 index 0000000..c64de41 --- /dev/null +++ b/src/components/dashboard/TrendLineChart.tsx @@ -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(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([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) => { + 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 ( +
+ {t("trendLineNoData")} +
+ ); + } + + const hover = hoverIndex != null ? daily[hoverIndex] : null; + + return ( +
+ {/* Legend + toggle */} +
+
+ + + {t("trendLineLegendTokens")} + + + + {t("trendLineLegendCacheHit")} + + {showRequests && ( + + + {t("trendLineLegendRequests")} + + )} +
+ +
+ + {/* Chart */} +
+ + {/* Gradient definitions */} + + + + + + + + {/* 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 ( + + ); + })} + + {/* Area fill for tokens */} + + + {/* Tokens line (area + line) */} + + + {/* Cache hit line */} + + + {/* Requests line (optional) */} + {showRequests && ( + + )} + + {/* Hover crosshair + dot */} + {hover && ( + + + + + {showRequests && ( + + )} + + )} + + + {/* 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 ( + + {fmtTokenAxis(maxTokens * p)} + + ); + })} + {/* Right Y-axis (cache hit %) */} + {[0, 0.5, 1].map((p) => { + const top = ((PAD_T + PLOT_H * (1 - p)) / H) * 100; + return ( + + {Math.round(p * 100)}% + + ); + })} + {/* X-axis labels */} + {xTicks.map((i) => ( + + {daily[i].date.slice(5)} + + ))} + + {/* Tooltip (HTML overlay for readability) */} + {hover && ( +
n / 2 ? "translateX(-100%)" : "translateX(8px)", + }} + > +
{hover.date}
+
+ {t("trendLineLegendTokens")}: {fmtTokens(hover.totalTokens)} +
+
+ {t("trendLineLegendCacheHit")}: {(hover.cacheHitRate * 100).toFixed(1)}% +
+ {showRequests && ( +
+ {t("trendLineLegendRequests")}: {fmtInt(hover.requests)} +
+ )} +
+ )} +
+
+ ); +} diff --git a/src/config/providerPresets.ts b/src/config/providerPresets.ts index 58f65a7..0fb958d 100644 --- a/src/config/providerPresets.ts +++ b/src/config/providerPresets.ts @@ -17,6 +17,9 @@ export type UsageKind = | "plan:zhipu" | "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). */ export const SUPPORTED_USAGE_KINDS: ReadonlySet = new Set([ "balance:deepseek", @@ -71,6 +74,12 @@ export interface ProviderPreset { /** Defaults to ["thinking"]. */ 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". * Persisted to SQLite settings by the Rust side on save, never to @@ -95,6 +104,7 @@ export const providerPresets: ProviderPreset[] = [ { model: "claude-sonnet-4-5" }, { model: "claude-haiku-4-5" }, ], + billingMode: "pay_as_you_go", }, { id: "kimi-coding", @@ -111,6 +121,7 @@ export const providerPresets: ProviderPreset[] = [ { model: "kimi-for-coding", displayName: "Kimi For Coding" }, { model: "kimi-k2.7-code" }, ], + billingMode: "subscription", usageKinds: ["plan:kimi_coding"], }, { @@ -127,6 +138,7 @@ export const providerPresets: ProviderPreset[] = [ { model: "kimi-k2-thinking-turbo" }, { model: "kimi-k2.6" }, ], + billingMode: "pay_as_you_go", }, { id: "deepseek", @@ -138,12 +150,13 @@ export const providerPresets: ProviderPreset[] = [ baseUrl: "https://api.deepseek.com/v1", icon: "deepseek", models: [{ model: "deepseek-chat" }, { model: "deepseek-reasoner" }], + billingMode: "pay_as_you_go", usageKinds: ["balance:deepseek"], }, { - id: "zhipu", - name: "Zhipu GLM", - nameKey: "presetNameZhipu", + id: "zhipu-api", + name: "Zhipu GLM (API)", + nameKey: "presetNameZhipuApi", websiteUrl: "https://open.bigmodel.cn", category: "cn_official", providerType: "openai", @@ -154,18 +167,51 @@ export const providerPresets: ProviderPreset[] = [ { model: "glm-4.6" }, { 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"], }, { - id: "zai", - name: "z.ai", - nameKey: "presetNameZai", + id: "zai-api", + name: "z.ai (API)", + nameKey: "presetNameZaiApi", websiteUrl: "https://z.ai", category: "third_party", providerType: "openai", baseUrl: "https://api.z.ai/api/paas/v4", icon: "zhipu", 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"], }, { @@ -182,6 +228,7 @@ export const providerPresets: ProviderPreset[] = [ { model: "qwen3-coder-plus" }, { model: "qwen3.5-plus" }, ], + billingMode: "pay_as_you_go", }, { id: "minimax", @@ -193,6 +240,25 @@ export const providerPresets: ProviderPreset[] = [ baseUrl: "https://api.minimaxi.com/v1", icon: "minimax", 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", + 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"], }, { @@ -205,6 +271,7 @@ export const providerPresets: ProviderPreset[] = [ baseUrl: "https://api.stepfun.com/v1", icon: "stepfun", models: [{ model: "step-3.7-flash" }, { model: "step-3.5-flash" }], + billingMode: "pay_as_you_go", usageKinds: ["balance:stepfun"], }, { @@ -229,6 +296,7 @@ export const providerPresets: ProviderPreset[] = [ }, ], usageKinds: ["balance:siliconflow"], + billingMode: "pay_as_you_go", }, { id: "novita", @@ -251,6 +319,7 @@ export const providerPresets: ProviderPreset[] = [ { model: "zai-org/glm-4.6" }, ], usageKinds: ["balance:novita"], + billingMode: "pay_as_you_go", }, { id: "openrouter", @@ -273,6 +342,7 @@ export const providerPresets: ProviderPreset[] = [ { model: "google/gemini-3-pro-preview" }, ], usageKinds: ["balance:openrouter"], + billingMode: "pay_as_you_go", }, { id: "openai", @@ -288,6 +358,7 @@ export const providerPresets: ProviderPreset[] = [ { model: "gpt-5.1-codex-max" }, { model: "gpt-5-mini" }, ], + billingMode: "pay_as_you_go", }, { id: "google-genai", @@ -303,6 +374,7 @@ export const providerPresets: ProviderPreset[] = [ { model: "gemini-3-flash-preview" }, { model: "gemini-2.5-flash" }, ], + billingMode: "pay_as_you_go", }, { id: "volcengine", @@ -321,6 +393,50 @@ export const providerPresets: ProviderPreset[] = [ }, { 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/zen/", + 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", }, ]; diff --git a/src/i18n/en.ts b/src/i18n/en.ts index d527329..a565823 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -73,6 +73,7 @@ export const enTranslations: Record = { fetchingModels: "Fetching...", discoveredModels: "Discovered {count} models", addSelected: "Add selected", + alreadyAdded: "Added", displayName: "Display name", actualModel: "Actual model", contextSize: "Context size", @@ -283,6 +284,7 @@ export const enTranslations: Record = { cardDailyTrendSubCapped: "Last 30 days · {n} days total", tabModelTrend: "Model usage trend", tabProviderTrend: "Provider–model usage trend", + tabDailyTrend: "Daily usage trend", providerUnknown: "Unknown provider", cardDailyTrendSub: "{n} days", cardModelUsage: "Model usage", @@ -339,10 +341,13 @@ export const enTranslations: Record = { presetNameKimiCoding: "Kimi For Coding", presetNameMoonshot: "Moonshot Platform", presetNameDeepseek: "DeepSeek", - presetNameZhipu: "Zhipu GLM", - presetNameZai: "z.ai (Zhipu Global)", + presetNameZhipuApi: "Zhipu GLM (Pay-as-you-go)", + presetNameZhipuCoding: "Zhipu GLM Coding Plan", + presetNameZaiApi: "z.ai (Pay-as-you-go)", + presetNameZaiCoding: "z.ai Coding Plan", presetNameBailian: "Alibaba Bailian", presetNameMinimax: "MiniMax", + presetNameMinimaxTokenPlan: "MiniMax Token Plan", presetNameStepfun: "StepFun", presetNameSiliconflow: "SiliconFlow", presetNameNovita: "Novita AI", @@ -350,6 +355,8 @@ export const enTranslations: Record = { presetNameOpenai: "OpenAI", presetNameGoogleGenai: "Google AI Studio", presetNameVolcengine: "Volcengine Ark", + presetNameOpencodeGo: "OpenCode Go", + presetNameOpencodeZen: "OpenCode Zen", // Provider usage usageBalance: "Balance", @@ -363,4 +370,21 @@ export const enTranslations: Record = { usageResetIn: "resets in {time}", usageResetDone: "reset", 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", }; diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index e400227..e7692be 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -71,6 +71,7 @@ export const zhTranslations = { fetchingModels: "获取中...", discoveredModels: "发现 {count} 个模型", addSelected: "添加选中的", + alreadyAdded: "已添加", displayName: "显示名称", actualModel: "实际请求模型", contextSize: "上下文长度", @@ -278,6 +279,7 @@ export const zhTranslations = { cardDailyTrendSubCapped: "近 30 天 · 共 {n} 天", tabModelTrend: "模型用量趋势", tabProviderTrend: "供应商模型用量趋势", + tabDailyTrend: "每日用量趋势", providerUnknown: "未知供应商", cardDailyTrendSub: "共 {n} 天", cardModelUsage: "模型用量", @@ -334,10 +336,13 @@ export const zhTranslations = { presetNameKimiCoding: "Kimi For Coding", presetNameMoonshot: "Moonshot 平台", presetNameDeepseek: "DeepSeek", - presetNameZhipu: "智谱 GLM", - presetNameZai: "z.ai(智谱海外)", + presetNameZhipuApi: "智谱 GLM(按量)", + presetNameZhipuCoding: "智谱 GLM Coding Plan", + presetNameZaiApi: "z.ai(按量)", + presetNameZaiCoding: "z.ai Coding Plan", presetNameBailian: "阿里百炼", presetNameMinimax: "MiniMax", + presetNameMinimaxTokenPlan: "MiniMax Token Plan", presetNameStepfun: "阶跃星辰", presetNameSiliconflow: "硅基流动", presetNameNovita: "Novita AI", @@ -345,6 +350,8 @@ export const zhTranslations = { presetNameOpenai: "OpenAI 官方", presetNameGoogleGenai: "Google AI Studio", presetNameVolcengine: "火山方舟", + presetNameOpencodeGo: "OpenCode Go", + presetNameOpencodeZen: "OpenCode Zen", // Provider usage usageBalance: "余额", @@ -358,6 +365,23 @@ export const zhTranslations = { usageResetIn: "{time} 后重置", usageResetDone: "已重置", 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; export type TranslationKey = keyof typeof zhTranslations; From f35924e15c7ef310604de81b80323c1c6f389ce5 Mon Sep 17 00:00:00 2001 From: KimiSwitch Dev Date: Thu, 30 Jul 2026 14:19:27 +0800 Subject: [PATCH 2/3] =?UTF-8?q?v0.6.3:=20=E6=B7=BB=E5=8A=A0=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E4=BE=9B=E5=BA=94=E5=95=86=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 StepFun Plan 套餐预设 - 供应商编辑页 API Key 下方添加获取 Key 链接(支持推广链接) - 修复链接无法打开浏览器的问题(Rust open_external_url) - 无推广链接时回退打开官网 - 必填字段(供应商名称/API Key/请求地址)添加红色 * 标注 - 用户自定义预设方案文档 --- _release_id.txt | 1 + docs/USER-PRESETS-PLAN.md | 144 ++++++++++++++++++++++++++++++++ package.json | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/commands.rs | 11 +++ src-tauri/src/lib.rs | 1 + src-tauri/tauri.conf.json | 2 +- src/components/ProviderEdit.tsx | 28 ++++++- src/config/providerPresets.ts | 35 +++++++- src/i18n/en.ts | 2 + src/i18n/zh.ts | 2 + 11 files changed, 223 insertions(+), 7 deletions(-) create mode 100644 _release_id.txt create mode 100644 docs/USER-PRESETS-PLAN.md diff --git a/_release_id.txt b/_release_id.txt new file mode 100644 index 0000000..ca7bf83 --- /dev/null +++ b/_release_id.txt @@ -0,0 +1 @@ +13 \ No newline at end of file diff --git a/docs/USER-PRESETS-PLAN.md b/docs/USER-PRESETS-PLAN.md new file mode 100644 index 0000000..365bdbb --- /dev/null +++ b/docs/USER-PRESETS-PLAN.md @@ -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(); + 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(涉及翻译文件修改,违背零代码原则)。 \ No newline at end of file diff --git a/package.json b/package.json index a8913b2..525a60d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "kimiswitch", "private": true, - "version": "0.6.2", + "version": "0.6.3", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7a8f359..44448e3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kimiswitch" -version = "0.6.2" +version = "0.6.3" description = "Kimi Switch - model config manager" authors = ["you"] edition = "2021" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index d41b85e..f75529c 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -856,4 +856,15 @@ pub fn open_installer(app: tauri::AppHandle, path: String) -> Result<(), String> app.opener() .open_path(&path, None::<&str>) .map_err(|e| e.to_string()) +} + +/// 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()) } \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7149c0e..ce1e1d1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -104,6 +104,7 @@ pub fn run() { commands::check_for_update, commands::download_update, commands::open_installer, + commands::open_external_url, dashboard::get_paths, dashboard::get_prices, dashboard::get_summary, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 760f57e..746eb1f 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "Kimi Switch", - "version": "0.6.2", + "version": "0.6.3", "identifier": "com.kimiswitch.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/ProviderEdit.tsx b/src/components/ProviderEdit.tsx index be5bff0..fc606d6 100644 --- a/src/components/ProviderEdit.tsx +++ b/src/components/ProviderEdit.tsx @@ -3,6 +3,7 @@ import { createPortal } from "react-dom"; import { invoke } from "@tauri-apps/api/core"; import { useTranslation } from "../i18n"; import type { TranslationKey } from "../i18n/zh"; +import { findPresetForProvider } from "../config/providerPresets"; import { getDefaultMaxContextSize } from "../lib/model-defaults"; import { capabilitiesFromRef, getModelRef } from "../lib/models-dev"; import { getIconMetadata } from "../icons/extracted/metadata"; @@ -117,6 +118,10 @@ export function ProviderEdit({ const [showApiKey, setShowApiKey] = 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(() => { const def = defaultBaseUrl(agent, provider.provider_type); if (!def) return; @@ -215,7 +220,7 @@ export function ProviderEdit({
+ {(() => { + const linkUrl = preset?.apiKeyUrl ?? provider.official_url ?? null; + if (!linkUrl) return null; + return ( +
+ +
+ ); + })()}
)}
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 // any preset is one the Rust `UsageKind` enum knows how to handle. The // runtime filter in `query_provider_usage` silently drops unknown kinds, diff --git a/src/i18n/en.ts b/src/i18n/en.ts index a565823..3a4fe38 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -60,6 +60,7 @@ export const enTranslations: Record = { apiFormat: "API Format", authField: "Auth Field", apiKey: "API Key", + getApiKeyLink: "Get API Key", show: "Show", hide: "Hide", requestUrl: "Request URL", @@ -349,6 +350,7 @@ export const enTranslations: Record = { presetNameMinimax: "MiniMax", presetNameMinimaxTokenPlan: "MiniMax Token Plan", presetNameStepfun: "StepFun", + presetNameStepfunPlan: "StepFun Plan", presetNameSiliconflow: "SiliconFlow", presetNameNovita: "Novita AI", presetNameOpenrouter: "OpenRouter", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index e7692be..a80d6bc 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -58,6 +58,7 @@ export const zhTranslations = { apiFormat: "API 格式", authField: "认证字段", apiKey: "API Key", + getApiKeyLink: "获取 API Key", show: "显示", hide: "隐藏", requestUrl: "请求地址", @@ -344,6 +345,7 @@ export const zhTranslations = { presetNameMinimax: "MiniMax", presetNameMinimaxTokenPlan: "MiniMax Token Plan", presetNameStepfun: "阶跃星辰", + presetNameStepfunPlan: "阶跃星辰 Step Plan", presetNameSiliconflow: "硅基流动", presetNameNovita: "Novita AI", presetNameOpenrouter: "OpenRouter", From 0889eeba24996a3dba0bb57a429d8b36e35a7357 Mon Sep 17 00:00:00 2001 From: KimiSwitch Dev Date: Thu, 30 Jul 2026 14:30:45 +0800 Subject: [PATCH 3/3] chore: update Cargo.lock for v0.6.3 --- src-tauri/Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e937bc3..e459fb9 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1958,7 +1958,7 @@ dependencies = [ [[package]] name = "kimiswitch" -version = "0.6.2" +version = "0.6.3" dependencies = [ "anyhow", "chrono",
{t("colTime")}
{fmtTime(r.time)}