v0.6.0: 预设供应商 + 供应商账单查询
## 新增 - 15 条主流供应商预设(Kimi Coding / Moonshot / Anthropic / DeepSeek / 智谱 GLM / z.ai / 阿里百炼 / MiniMax / StepFun / SiliconFlow / Novita / OpenRouter / OpenAI / Google AI Studio / 火山方舟),通过 PresetPickerModal 一键填表 - 供应商余额/套餐查询(卡片底部 UsageFooter):余额类 (DeepSeek / SiliconFlow / OpenRouter / StepFun / Novita) + 套餐类(Kimi For Coding / 智谱 GLM / MiniMax),5min stale TTL + force_refresh + 并发 ≤3 - Rust 端 services/ 模块(balance / coding_plan / usage_types), 参考 cc-switch(MIT,© Jason Young)实现 - query_provider_usage 命令:Rust 端从 config 加载 key, 不经 IPC 序列化 API key - usageKinds 持久化到 SQLite settings(JSON 数组), load 时合并回 config,导出 config.toml 时不写 - detect_provider 启发式:旧用户升级后自动获得账单支持 - save-time 校验:未完成供应商(缺 api_key / base_url / 模型) 保存时弹 confirm 列出原因;新增未提交 back 时静默 drop - docs/PROPOSAL-presets-and-usage.md(v0.4-draft 实施档) - docs/VERIFICATION-CHECKLIST.md(70+ 项手动验证清单) ## 修复 - handleDuplicateProvider 的 alias.slice 漏洞:老数据 非规范 alias 复制不再错位 - handleSelectPreset 不再 auto-save,半成品不会落盘 - handleSwitchProvider 校验取消时自动 refresh 回滚, 不会出现内存改 / 盘没动的脏态 ## 文档 - README / README_EN 致谢段补 cc-switch(预设 + 账单) - README 已知限制与后续建议段(/reload 上游限制调研结论) - i18n 补 5 条校验 key + 11 条预设 UI key + 12 条账单 key 详见 docs/PROPOSAL-presets-and-usage.md 与 docs/VERIFICATION-CHECKLIST.md
This commit is contained in:
1 parent
a1fdc6c93d
commit
2614f80d0f
27 files changed
+3442
-67
No files matched your search
+181
-35
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { useConfig } from "./hooks/useConfig";
|
||||
@@ -8,9 +8,15 @@ import { ProviderEdit } from "./components/ProviderEdit";
|
||||
import { DashboardPage } from "./components/dashboard/DashboardPage";
|
||||
import { SessionsPage } from "./components/sessions/SessionsPage";
|
||||
import { SettingsModal } from "./components/SettingsModal";
|
||||
import { PresetPickerModal } from "./components/PresetPickerModal";
|
||||
import { useTranslation } from "./i18n";
|
||||
import { getDefaultMaxContextSize } from "./lib/model-defaults";
|
||||
import { getModelRef } from "./lib/models-dev";
|
||||
import {
|
||||
presetToProviderAndModels,
|
||||
type ProviderPreset,
|
||||
} from "./config/providerPresets";
|
||||
import { validateProviders } from "./lib/validation";
|
||||
import type { Agent, Model, Provider } from "./types";
|
||||
|
||||
const AGENT_STORAGE_KEY = "kimi-switch-agent";
|
||||
@@ -68,6 +74,14 @@ export default function App() {
|
||||
const [loadTimeout, setLoadTimeout] = useState(false);
|
||||
const [switchMessage, setSwitchMessage] = useState<string | null>(null);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showPresetPicker, setShowPresetPicker] = useState(false);
|
||||
// Provider names that have been added to the in-memory config but not
|
||||
// yet committed via save(). Pressing "back" in the edit form for one of
|
||||
// these drops it silently (no confirm); all other dirty navigation
|
||||
// keeps the existing unsavedConfirm flow.
|
||||
const [pendingNewProviders, setPendingNewProviders] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
@@ -79,35 +93,6 @@ export default function App() {
|
||||
return () => clearTimeout(timer);
|
||||
}, [loading]);
|
||||
|
||||
// Keyboard shortcuts: Ctrl+S save, Ctrl+R reload, Ctrl+O open config dir
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (!e.ctrlKey || e.metaKey) return;
|
||||
switch (e.key) {
|
||||
case "s":
|
||||
case "S":
|
||||
e.preventDefault();
|
||||
save();
|
||||
break;
|
||||
case "r":
|
||||
case "R":
|
||||
e.preventDefault();
|
||||
if (dirty && !confirm(t("unsavedConfirm"))) return;
|
||||
refresh();
|
||||
break;
|
||||
case "o":
|
||||
case "O":
|
||||
e.preventDefault();
|
||||
invoke("open_agent_config_dir", { agent }).catch((err) =>
|
||||
alert(err instanceof Error ? err.message : String(err))
|
||||
);
|
||||
break;
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [save, refresh, dirty, t, agent]);
|
||||
|
||||
// Update window title to reflect unsaved changes.
|
||||
useEffect(() => {
|
||||
const app = getCurrentWindow();
|
||||
@@ -138,8 +123,89 @@ export default function App() {
|
||||
[config]
|
||||
);
|
||||
|
||||
// trySave calls validateProviders, which takes a library-style
|
||||
// `(key: string) => string`; useTranslation's `t` is strictly typed to
|
||||
// the project's i18n key union, so we relax it locally for the call.
|
||||
const tGeneric = t as unknown as (
|
||||
key: string,
|
||||
vars?: Record<string, unknown>,
|
||||
) => string;
|
||||
// Wraps `save` with a completeness check. Three flavours of caller:
|
||||
// 1. Whole-config save (Ctrl+S, footer button, ProviderEdit onSave):
|
||||
// no `target` → validate every enabled provider, show a confirm
|
||||
// dialog listing the incomplete ones.
|
||||
// 2. Switching a specific provider as active: `target` is the
|
||||
// provider being switched to — only that one needs to be
|
||||
// complete; sibling providers being merely listed in the config
|
||||
// must not block the switch.
|
||||
// 3. Programmatic auto-saves (duplicate) call `save` directly with
|
||||
// no validation — duplicates inherit completeness from their
|
||||
// source.
|
||||
// Returns `true` when the config was actually persisted, `false` when
|
||||
// the user cancelled the validation confirm (callers can react to this
|
||||
// to roll back in-memory mutations made before the save).
|
||||
const trySave = useCallback(
|
||||
async (target?: string): Promise<boolean> => {
|
||||
if (!config) return false;
|
||||
if (dirty) {
|
||||
const issues = validateProviders(config, config.models, tGeneric, target);
|
||||
if (issues.length > 0) {
|
||||
const summary = issues
|
||||
.map((i) => `• ${i.name}: ${i.reasons.join("; ")}`)
|
||||
.join("\n");
|
||||
if (!confirm(t("saveValidationConfirm", { details: summary }))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
await save();
|
||||
// Successful save: clear the "in-memory only" markers — every
|
||||
// provider is now on disk.
|
||||
setPendingNewProviders(new Set());
|
||||
return true;
|
||||
},
|
||||
[config, dirty, save, t],
|
||||
);
|
||||
|
||||
// Keyboard shortcuts: Ctrl+S save, Ctrl+R reload, Ctrl+O open config dir
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (!e.ctrlKey || e.metaKey) return;
|
||||
switch (e.key) {
|
||||
case "s":
|
||||
case "S":
|
||||
e.preventDefault();
|
||||
void trySave();
|
||||
break;
|
||||
case "r":
|
||||
case "R":
|
||||
e.preventDefault();
|
||||
if (dirty && !confirm(t("unsavedConfirm"))) return;
|
||||
refresh();
|
||||
break;
|
||||
case "o":
|
||||
case "O":
|
||||
e.preventDefault();
|
||||
invoke("open_agent_config_dir", { agent }).catch((err) =>
|
||||
alert(err instanceof Error ? err.message : String(err))
|
||||
);
|
||||
break;
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [trySave, refresh, dirty, t, agent]);
|
||||
|
||||
// "Add provider" opens the preset picker; the empty form is reachable via
|
||||
// the picker's "+ custom config" entry (handleAddCustomProvider).
|
||||
const handleAddProvider = () => {
|
||||
if (!config) return;
|
||||
setShowPresetPicker(true);
|
||||
};
|
||||
|
||||
const handleAddCustomProvider = () => {
|
||||
if (!config) return;
|
||||
setShowPresetPicker(false);
|
||||
const name = `provider-${providers.length + 1}`;
|
||||
const defaultType = agent === "kimi_code" ? "kimi" : "openai";
|
||||
updateConfig((cfg) => ({
|
||||
@@ -159,10 +225,45 @@ export default function App() {
|
||||
},
|
||||
},
|
||||
}));
|
||||
// Mark as in-memory only — never persisted until the user explicitly
|
||||
// saves a complete config. Pressing "back" in the edit form will
|
||||
// silently drop this entry.
|
||||
setPendingNewProviders((prev) => new Set([...prev, name]));
|
||||
setEditingProvider(name);
|
||||
setView("edit");
|
||||
};
|
||||
|
||||
const handleSelectPreset = async (preset: ProviderPreset) => {
|
||||
if (!config) return;
|
||||
setShowPresetPicker(false);
|
||||
const { provider, models, defaultModel } = presetToProviderAndModels(preset, {
|
||||
existingProviderNames: new Set(Object.keys(config.providers)),
|
||||
existingModelAliases: new Set(Object.keys(config.models)),
|
||||
});
|
||||
updateConfig((cfg) => {
|
||||
const providers = { ...cfg.providers, [provider.name]: provider };
|
||||
const updatedModels = { ...cfg.models };
|
||||
for (const m of models) {
|
||||
updatedModels[m.alias] = m;
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
providers,
|
||||
models: updatedModels,
|
||||
default_model: defaultModel || cfg.default_model,
|
||||
};
|
||||
});
|
||||
// Do NOT auto-save: a freshly-picked preset is intentionally
|
||||
// incomplete (no api_key yet). The user lands in the edit form
|
||||
// next; if they fill the key and save, validation passes and the
|
||||
// config lands on disk. If they back out without saving, the
|
||||
// in-memory additions are discarded on the next refresh — config.toml
|
||||
// never receives a half-configured provider.
|
||||
setPendingNewProviders((prev) => new Set([...prev, provider.name]));
|
||||
setEditingProvider(provider.name);
|
||||
setView("edit");
|
||||
};
|
||||
|
||||
const handleUpdateProvider = (provider: Provider) => {
|
||||
updateConfig((cfg) => {
|
||||
const providers = { ...cfg.providers };
|
||||
@@ -227,7 +328,18 @@ export default function App() {
|
||||
const models = { ...cfg.models };
|
||||
for (const [alias, m] of Object.entries(cfg.models)) {
|
||||
if (m.provider === name) {
|
||||
const newAlias = newName + alias.slice(name.length);
|
||||
// The model belongs to this provider by `m.provider === name`. To
|
||||
// re-key it under the new provider, we want a `newName/...`
|
||||
// alias. The original code assumed `alias` is exactly
|
||||
// `${name}/${modelId}` and used `alias.slice(name.length)`; that
|
||||
// breaks for legacy / non-standard aliases (e.g. `kimi-k3` from
|
||||
// pre-v0.6 data) which would produce `newName-k3` — missing the
|
||||
// `/` separator and pointing at the wrong model. Use an explicit
|
||||
// prefix check and fall back to a full re-prefix.
|
||||
const prefix = `${name}/`;
|
||||
const newAlias = alias.startsWith(prefix)
|
||||
? newName + alias.slice(name.length)
|
||||
: `${newName}/${alias}`;
|
||||
models[newAlias] = { ...m, alias: newAlias, provider: newName };
|
||||
}
|
||||
}
|
||||
@@ -303,7 +415,13 @@ export default function App() {
|
||||
});
|
||||
|
||||
// Persist the full config to Kimi Switch's SQLite and activate the selected provider.
|
||||
await save();
|
||||
const saved = await trySave(name);
|
||||
if (!saved) {
|
||||
// Validation was cancelled: undo the in-memory switch by reloading
|
||||
// the on-disk state, and skip the activation + /reload nudge.
|
||||
await refresh();
|
||||
return;
|
||||
}
|
||||
await invoke("activate_agent_config_command", { agent });
|
||||
|
||||
// /reload is an interactive Kimi Code TUI command with no CLI equivalent,
|
||||
@@ -317,6 +435,27 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditBack = async () => {
|
||||
const target = editingProvider;
|
||||
if (target && pendingNewProviders.has(target)) {
|
||||
// This provider was added in-memory (via preset or "+ custom config")
|
||||
// but never committed. Drop it silently — no confirm — and reload
|
||||
// from disk so the list reflects the on-disk truth.
|
||||
setPendingNewProviders((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(target);
|
||||
return next;
|
||||
});
|
||||
await refresh();
|
||||
} else if (dirty) {
|
||||
// Real edits to a previously-saved provider — keep the existing
|
||||
// unsaved-changes confirm so the user doesn't lose work by accident.
|
||||
if (!confirm(t("unsavedConfirm"))) return;
|
||||
await refresh();
|
||||
}
|
||||
setView("list");
|
||||
};
|
||||
|
||||
const handleApplyProviderJson = (provider: Provider, models: Model[]) => {
|
||||
updateConfig((cfg) => {
|
||||
const providers = { ...cfg.providers };
|
||||
@@ -508,7 +647,7 @@ export default function App() {
|
||||
onRawOtherChange={(nextRawOther) =>
|
||||
updateConfig((cfg) => ({ ...cfg, raw_other: nextRawOther }))
|
||||
}
|
||||
onBack={() => setView("list")}
|
||||
onBack={handleEditBack}
|
||||
onChange={handleUpdateProvider}
|
||||
onDelete={() => handleDeleteProvider(currentProvider.name)}
|
||||
onModelChange={(model) => {
|
||||
@@ -577,7 +716,7 @@ export default function App() {
|
||||
}}
|
||||
onSetDefault={handleSetDefaultModel}
|
||||
onApplyJson={handleApplyProviderJson}
|
||||
onSave={save}
|
||||
onSave={trySave}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-8 text-center text-content-muted">
|
||||
@@ -603,7 +742,7 @@ export default function App() {
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
onClick={() => { void trySave(); }}
|
||||
disabled={!dirty || loading}
|
||||
className={`px-3 py-1.5 text-sm rounded focus:ring-2 focus:outline-none disabled:opacity-50 ${
|
||||
dirty
|
||||
@@ -651,6 +790,13 @@ export default function App() {
|
||||
onCheckUpdate={checkNow}
|
||||
lastChecked={lastChecked}
|
||||
/>
|
||||
|
||||
<PresetPickerModal
|
||||
open={showPresetPicker}
|
||||
onClose={() => setShowPresetPicker(false)}
|
||||
onSelect={handleSelectPreset}
|
||||
onCustom={handleAddCustomProvider}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "../i18n";
|
||||
import type { TranslationKey } from "../i18n/zh";
|
||||
import { providerPresets, type PresetCategory, type ProviderPreset } from "../config/providerPresets";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
|
||||
interface PresetPickerModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (preset: ProviderPreset) => void;
|
||||
onCustom: () => void;
|
||||
}
|
||||
|
||||
const CATEGORY_BADGE: Record<Exclude<PresetCategory, "custom">, string> = {
|
||||
official: "bg-blue-100 text-blue-700 dark:bg-blue-500/15 dark:text-blue-400",
|
||||
cn_official: "bg-green-100 text-green-700 dark:bg-green-500/15 dark:text-green-400",
|
||||
third_party: "bg-gray-200 text-gray-600 dark:bg-gray-500/15 dark:text-gray-400",
|
||||
aggregator: "bg-purple-100 text-purple-700 dark:bg-purple-500/15 dark:text-purple-400",
|
||||
};
|
||||
|
||||
const CATEGORY_LABEL: Record<Exclude<PresetCategory, "custom">, TranslationKey> = {
|
||||
official: "presetCategoryOfficial",
|
||||
cn_official: "presetCategoryCnOfficial",
|
||||
third_party: "presetCategoryThirdParty",
|
||||
aggregator: "presetCategoryAggregator",
|
||||
};
|
||||
|
||||
type SortMode = "original" | "alpha";
|
||||
|
||||
export function PresetPickerModal({ open, onClose, onSelect, onCustom }: PresetPickerModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
const [sort, setSort] = useState<SortMode>("original");
|
||||
|
||||
// Esc to close
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
// Scroll lock while open
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Reset transient state each time the modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery("");
|
||||
setSort("original");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const presetName = (p: ProviderPreset) =>
|
||||
p.nameKey ? t(p.nameKey as TranslationKey) : p.name;
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
let list = providerPresets;
|
||||
if (q) {
|
||||
list = list.filter(
|
||||
(p) => p.id.toLowerCase().includes(q) || presetName(p).toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
if (sort === "alpha") {
|
||||
list = [...list].sort((a, b) => presetName(a).localeCompare(presetName(b)));
|
||||
}
|
||||
return list;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query, sort, t]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("presetPickerTitle")}
|
||||
>
|
||||
<div
|
||||
className="flex max-h-[85vh] w-full max-w-2xl flex-col rounded-xl border border-border bg-panel shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-3.5">
|
||||
<div className="text-lg font-semibold text-content-primary">
|
||||
{t("presetPickerTitle")}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t("close")}
|
||||
className="rounded-md p-1 text-content-muted hover:bg-border hover:text-content-primary transition-colors"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search + sort */}
|
||||
<div className="flex items-center gap-2 border-b border-border px-5 py-3">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<div className="flex items-center rounded border border-border bg-input p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSort("original")}
|
||||
className={`px-2 py-1 text-xs rounded transition-colors ${
|
||||
sort === "original"
|
||||
? "bg-blue-600 text-white"
|
||||
: "text-content-muted hover:text-content-primary"
|
||||
}`}
|
||||
>
|
||||
{t("presetSortOriginal")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSort("alpha")}
|
||||
className={`px-2 py-1 text-xs rounded transition-colors ${
|
||||
sort === "alpha"
|
||||
? "bg-blue-600 text-white"
|
||||
: "text-content-muted hover:text-content-primary"
|
||||
}`}
|
||||
>
|
||||
{t("presetSortAlpha")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preset grid */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{visible.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-content-muted">
|
||||
{t("presetNoResults")}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="grid gap-2"
|
||||
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))" }}
|
||||
>
|
||||
{visible.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(p)}
|
||||
className="flex flex-col items-start gap-2 rounded-lg border border-border bg-input p-3 text-left transition-colors hover:border-blue-500 hover:bg-hover-2"
|
||||
>
|
||||
<ProviderIcon name={presetName(p)} icon={p.icon ?? null} color={p.iconColor ?? null} size={32} />
|
||||
<div className="w-full truncate text-sm font-medium text-content-primary">
|
||||
{presetName(p)}
|
||||
</div>
|
||||
{p.category !== "custom" && (
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${CATEGORY_BADGE[p.category]}`}
|
||||
>
|
||||
{t(CATEGORY_LABEL[p.category])}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom (empty form) entry */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCustom}
|
||||
className="mt-3 flex w-full items-center justify-center gap-1 rounded-lg border border-dashed border-border px-3 py-2.5 text-sm text-content-muted transition-colors hover:border-blue-500 hover:text-content-primary"
|
||||
>
|
||||
{t("presetCustomConfig")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Footer hint */}
|
||||
<div className="border-t border-border px-5 py-2.5 text-xs text-content-muted">
|
||||
{t("presetFooterHint")}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { Pencil, Copy, Activity, Loader2, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "../i18n";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { UsageFooter } from "./UsageFooter";
|
||||
import type { Agent, Model, Provider } from "../types";
|
||||
|
||||
interface ConnectivityResult {
|
||||
@@ -144,13 +145,14 @@ export function ProviderList({
|
||||
return (
|
||||
<div
|
||||
key={provider.name}
|
||||
className={`group relative flex items-center gap-4 p-4 rounded-xl border transition-colors cursor-pointer w-full ${
|
||||
className={`group relative flex flex-col gap-3 p-4 rounded-xl border transition-colors cursor-pointer w-full ${
|
||||
isActive
|
||||
? "bg-green-50 dark:bg-green-900/10 border-green-300 dark:border-green-500/30 hover:border-green-400 dark:border-green-500/50 hover:bg-green-100 dark:bg-green-900/20"
|
||||
: "bg-panel border-border hover:border-strong hover:bg-hover"
|
||||
}`}
|
||||
onClick={() => onEdit(provider.name)}
|
||||
>
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<ProviderIcon
|
||||
name={provider.name}
|
||||
icon={provider.icon}
|
||||
@@ -294,6 +296,12 @@ export function ProviderList({
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<UsageFooter
|
||||
agent={agent}
|
||||
providerName={provider.name}
|
||||
usageKinds={provider.usageKinds}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { Loader2, RefreshCw } from "lucide-react";
|
||||
import { useTranslation } from "../i18n";
|
||||
import type { Agent } from "../types";
|
||||
|
||||
interface UsageData {
|
||||
planName?: string | null;
|
||||
remaining?: number | null;
|
||||
total?: number | null;
|
||||
used?: number | null;
|
||||
unit?: string | null;
|
||||
isValid?: boolean | null;
|
||||
resetsAt?: string | null;
|
||||
}
|
||||
|
||||
interface UsageResult {
|
||||
success: boolean;
|
||||
data?: UsageData[] | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
type UsageStatus = "idle" | "loading" | "success" | "error";
|
||||
|
||||
interface CacheEntry {
|
||||
status: "success" | "error";
|
||||
data: UsageData[];
|
||||
/** Raw error text from Rust; null = transient failure (invoke rejected). */
|
||||
error: string | null;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
// Module-level cache shared across mounts: re-entering the list within the
|
||||
// stale TTL shows the last result without firing new requests.
|
||||
const STALE_TTL_MS = 5 * 60 * 1000;
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
|
||||
// Simple semaphore: at most MAX_CONCURRENT queries in flight at once.
|
||||
const MAX_CONCURRENT = 3;
|
||||
let running = 0;
|
||||
const waiters: Array<() => void> = [];
|
||||
async function acquireSlot(): Promise<void> {
|
||||
if (running >= MAX_CONCURRENT) {
|
||||
await new Promise<void>((resolve) => waiters.push(resolve));
|
||||
}
|
||||
running += 1;
|
||||
}
|
||||
function releaseSlot(): void {
|
||||
running -= 1;
|
||||
waiters.shift()?.();
|
||||
}
|
||||
|
||||
interface UsageFooterProps {
|
||||
agent: Agent;
|
||||
providerName: string;
|
||||
usageKinds?: string[];
|
||||
}
|
||||
|
||||
export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProps) {
|
||||
const { t } = useTranslation();
|
||||
const supported = (usageKinds?.length ?? 0) > 0;
|
||||
// Cache key includes the agent so a Kimi Code provider and a Pi provider
|
||||
// with the same name do not clobber each other's cached result.
|
||||
const cacheKey = `${agent}:${providerName}`;
|
||||
|
||||
const [status, setStatus] = useState<UsageStatus>("idle");
|
||||
const [data, setData] = useState<UsageData[]>([]);
|
||||
/** undefined = no error; null = network error; string = Rust error text. */
|
||||
const [error, setError] = useState<string | null | undefined>(undefined);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
// Generation counter: stale responses (unmounted / superseded query) are ignored.
|
||||
const genRef = useRef(0);
|
||||
|
||||
const runQuery = useCallback(
|
||||
async (forceRefresh: boolean) => {
|
||||
const gen = ++genRef.current;
|
||||
setStatus("loading");
|
||||
setError(undefined);
|
||||
const finish = (entry: CacheEntry) => {
|
||||
cache.set(cacheKey, entry);
|
||||
if (genRef.current !== gen) return;
|
||||
setStatus(entry.status);
|
||||
setData(entry.data);
|
||||
setError(entry.error);
|
||||
};
|
||||
try {
|
||||
await acquireSlot();
|
||||
let result: UsageResult;
|
||||
try {
|
||||
result = await invoke<UsageResult>("query_provider_usage", {
|
||||
agent,
|
||||
providerName,
|
||||
forceRefresh,
|
||||
});
|
||||
} finally {
|
||||
releaseSlot();
|
||||
}
|
||||
if (genRef.current !== gen) return;
|
||||
if (result.success) {
|
||||
finish({
|
||||
status: "success",
|
||||
data: result.data ?? [],
|
||||
error: null,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
} else {
|
||||
// Deterministic failure: keep last good data for ghost display.
|
||||
finish({
|
||||
status: "error",
|
||||
data: cache.get(cacheKey)?.data ?? [],
|
||||
error: result.error ?? "",
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Transient failure (network / timeout): invoke rejected.
|
||||
finish({
|
||||
status: "error",
|
||||
data: cache.get(cacheKey)?.data ?? [],
|
||||
error: null,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
},
|
||||
[agent, providerName, cacheKey]
|
||||
);
|
||||
|
||||
// On mount / provider change: serve fresh cache, otherwise query once.
|
||||
useEffect(() => {
|
||||
if (!supported) return;
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) {
|
||||
setStatus(cached.status);
|
||||
setData(cached.data);
|
||||
setError(cached.error);
|
||||
if (Date.now() - cached.updatedAt < STALE_TTL_MS) return;
|
||||
}
|
||||
void runQuery(false);
|
||||
}, [supported, cacheKey, runQuery]);
|
||||
|
||||
// Ignore late responses after unmount.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
genRef.current += 1;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Tick the reset countdown once a minute while showing data.
|
||||
useEffect(() => {
|
||||
if (status !== "success") return;
|
||||
const id = setInterval(() => setNow(Date.now()), 60_000);
|
||||
return () => clearInterval(id);
|
||||
}, [status]);
|
||||
|
||||
if (!supported || status === "idle") return null;
|
||||
|
||||
const percentOf = (d: UsageData): number | null => {
|
||||
if (d.used != null && d.total != null && d.total > 0)
|
||||
return (d.used / d.total) * 100;
|
||||
if (d.remaining != null && d.total != null && d.total > 0)
|
||||
return (1 - d.remaining / d.total) * 100;
|
||||
return null;
|
||||
};
|
||||
|
||||
const formatAmount = (
|
||||
value: number | null | undefined,
|
||||
unit?: string | null
|
||||
): string => {
|
||||
if (value == null) return "—";
|
||||
const symbol =
|
||||
unit === "CNY" ? "¥" : unit === "USD" ? "$" : unit ? `${unit} ` : "";
|
||||
const num = Number.isInteger(value) ? String(value) : value.toFixed(2);
|
||||
return `${symbol}${num}`;
|
||||
};
|
||||
|
||||
const formatReset = (resetsAt?: string | null): string | null => {
|
||||
if (!resetsAt) return null;
|
||||
const ms = new Date(resetsAt).getTime() - now;
|
||||
if (Number.isNaN(ms)) return null;
|
||||
if (ms <= 0) return t("usageResetDone");
|
||||
const mins = Math.floor(ms / 60_000);
|
||||
if (mins < 60) return t("usageResetIn", { time: `${Math.max(mins, 1)}m` });
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return t("usageResetIn", { time: `${hours}h` });
|
||||
return t("usageResetIn", { time: `${Math.floor(hours / 24)}d` });
|
||||
};
|
||||
|
||||
const refreshBtn = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void runQuery(true);
|
||||
}}
|
||||
disabled={status === "loading"}
|
||||
title={t("usageRefresh")}
|
||||
aria-label={t("usageRefresh")}
|
||||
className="ml-auto w-6 h-6 flex items-center justify-center rounded text-content-muted hover:text-content-primary hover:bg-hover-2 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{status === "loading" ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
// Loading with no prior data: skeleton bar.
|
||||
if (status === "loading" && data.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="w-full border-t border-border pt-2 mt-1"
|
||||
aria-label={t("usageLoading")}
|
||||
>
|
||||
<div className="h-3.5 w-2/3 rounded bg-hover-2 animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "error" && data.length === 0) {
|
||||
const text =
|
||||
error == null
|
||||
? t("usageNetworkError")
|
||||
: /401|api[ -]?key|invalid key/i.test(error)
|
||||
? t("usageInvalidKey")
|
||||
: error
|
||||
? `${t("usageQueryFailed")} · ${error}`
|
||||
: t("usageQueryFailed");
|
||||
return (
|
||||
<div className="w-full border-t border-border pt-2 mt-1 flex items-center gap-2 text-xs">
|
||||
<span className="text-red-500 dark:text-red-400 truncate" title={text}>
|
||||
{text}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void runQuery(true);
|
||||
}}
|
||||
className="ml-auto shrink-0 px-2 py-0.5 rounded border border-red-300 dark:border-red-500/30 text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||
>
|
||||
{t("usageRetry")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// success, or error/loading with last-good data (ghost).
|
||||
const ghost = status !== "success";
|
||||
return (
|
||||
<div
|
||||
className={`w-full border-t border-border pt-2 mt-1 flex flex-col gap-1 text-xs ${
|
||||
ghost ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
{data.map((d, i) => {
|
||||
const isPlan = !!d.planName;
|
||||
const pct = isPlan ? percentOf(d) : null;
|
||||
const color =
|
||||
pct == null
|
||||
? "text-content-muted"
|
||||
: pct < 70
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: pct < 90
|
||||
? "text-orange-600 dark:text-orange-400"
|
||||
: "text-red-500 dark:text-red-400";
|
||||
const resetText = isPlan ? formatReset(d.resetsAt) : null;
|
||||
|
||||
let main: string;
|
||||
if (d.isValid === false) {
|
||||
main = t("usageInvalidKey");
|
||||
} else if (isPlan) {
|
||||
const label = data.length > 1 && d.planName ? `${d.planName} · ` : "";
|
||||
main =
|
||||
pct != null
|
||||
? `${label}${Math.round(pct)}% ${t("usageUsed")}`
|
||||
: d.remaining != null
|
||||
? `${label}${t("usageRemaining")} ${formatAmount(d.remaining, d.unit)}`
|
||||
: label || "—";
|
||||
} else {
|
||||
main = `${t("usageBalance")} ${formatAmount(d.remaining, d.unit)}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-1.5 min-w-0">
|
||||
<span aria-hidden="true">{isPlan ? "⚡" : "💰"}</span>
|
||||
<span
|
||||
className={`tabular-nums truncate ${
|
||||
d.isValid === false ? "text-red-500 dark:text-red-400" : color
|
||||
}`}
|
||||
title={main}
|
||||
>
|
||||
{main}
|
||||
</span>
|
||||
{resetText && (
|
||||
<span className="text-content-muted shrink-0">{resetText}</span>
|
||||
)}
|
||||
{i === data.length - 1 && refreshBtn}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{data.length === 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-content-muted">—</span>
|
||||
{refreshBtn}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
// Provider presets: one-click form filling for well-known providers.
|
||||
// Preset data structure inspired by cc-switch (MIT, © Jason Young)
|
||||
// https://github.com/farion1231/cc-switch
|
||||
|
||||
import type { Model, Provider, ProviderType } from "../types";
|
||||
import { getModelRef, capabilitiesFromRef } from "../lib/models-dev";
|
||||
import { getDefaultMaxContextSize } from "../lib/model-defaults";
|
||||
|
||||
/** Usage query kinds supported by the Rust usage layer (v1 union). */
|
||||
export type UsageKind =
|
||||
| "balance:deepseek"
|
||||
| "balance:siliconflow"
|
||||
| "balance:openrouter"
|
||||
| "balance:stepfun"
|
||||
| "balance:novita"
|
||||
| "plan:kimi_coding"
|
||||
| "plan:zhipu"
|
||||
| "plan:minimax";
|
||||
|
||||
/** Runtime set used to catch TS/Rust enum drift (see dev-assert below). */
|
||||
export const SUPPORTED_USAGE_KINDS: ReadonlySet<string> = new Set<UsageKind>([
|
||||
"balance:deepseek",
|
||||
"balance:siliconflow",
|
||||
"balance:openrouter",
|
||||
"balance:stepfun",
|
||||
"balance:novita",
|
||||
"plan:kimi_coding",
|
||||
"plan:zhipu",
|
||||
"plan:minimax",
|
||||
]);
|
||||
|
||||
export type PresetCategory =
|
||||
| "official"
|
||||
| "cn_official"
|
||||
| "third_party"
|
||||
| "aggregator"
|
||||
| "custom";
|
||||
|
||||
export interface ProviderPreset {
|
||||
/** Unique key, also used as the default Provider name, e.g. "deepseek". */
|
||||
id: string;
|
||||
/** Fallback display name (English). */
|
||||
name: string;
|
||||
/** i18n key; when present the UI prefers t(nameKey). */
|
||||
nameKey?: string;
|
||||
/** Official website / console URL. */
|
||||
websiteUrl?: string;
|
||||
apiKeyUrl?: string;
|
||||
/** Category: sorting & badge. */
|
||||
category: PresetCategory;
|
||||
/** Written directly to provider.provider_type. */
|
||||
providerType: ProviderType;
|
||||
/** Preset base_url; `null` means no default (e.g. Anthropic). */
|
||||
baseUrl: string | null;
|
||||
/** Icon key from src/icons/extracted (falls back to initials when absent). */
|
||||
icon?: string;
|
||||
iconColor?: string;
|
||||
/**
|
||||
* Pre-filled model mappings. The first entry becomes the default model.
|
||||
* `model` holds the real model id; the alias is NOT hand-written here —
|
||||
* presetToProviderAndModels() forces `${providerName}/${modelId}` so that
|
||||
* handleDuplicateProvider's `alias.slice(name.length)` keeps working.
|
||||
*/
|
||||
models: Array<{
|
||||
/** Real model id sent to the provider API. */
|
||||
model: string;
|
||||
/** Optional display name override; defaults to models.dev derivation. */
|
||||
displayName?: string;
|
||||
/** Optional context override; defaults to the priority chain below. */
|
||||
maxContextSize?: number;
|
||||
/** Defaults to ["thinking"]. */
|
||||
capabilities?: string[];
|
||||
}>;
|
||||
/**
|
||||
* Billing/usage query kinds; omitted means "do not query".
|
||||
* Persisted to SQLite settings by the Rust side on save, never to
|
||||
* config.toml.
|
||||
*/
|
||||
usageKinds?: ReadonlyArray<UsageKind>;
|
||||
}
|
||||
|
||||
export const providerPresets: ProviderPreset[] = [
|
||||
{
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
nameKey: "presetNameAnthropic",
|
||||
websiteUrl: "https://www.anthropic.com",
|
||||
category: "official",
|
||||
providerType: "anthropic",
|
||||
// No default base_url; Kimi Code automatically targets api.anthropic.com.
|
||||
baseUrl: null,
|
||||
icon: "anthropic",
|
||||
models: [
|
||||
{ model: "claude-opus-4-5" },
|
||||
{ model: "claude-sonnet-4-5" },
|
||||
{ model: "claude-haiku-4-5" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "kimi-coding",
|
||||
name: "Kimi For Coding",
|
||||
nameKey: "presetNameKimiCoding",
|
||||
websiteUrl: "https://www.kimi.com/code",
|
||||
category: "official",
|
||||
providerType: "kimi",
|
||||
baseUrl: "https://api.kimi.com/coding/v1",
|
||||
icon: "kimi",
|
||||
models: [
|
||||
// kimi-for-coding is not in the models.dev snapshot; context comes
|
||||
// from the regex fallback in model-defaults (262144).
|
||||
{ model: "kimi-for-coding", displayName: "Kimi For Coding" },
|
||||
{ model: "kimi-k2.7-code" },
|
||||
],
|
||||
usageKinds: ["plan:kimi_coding"],
|
||||
},
|
||||
{
|
||||
id: "moonshot",
|
||||
name: "Moonshot AI",
|
||||
nameKey: "presetNameMoonshot",
|
||||
websiteUrl: "https://platform.moonshot.ai",
|
||||
category: "cn_official",
|
||||
providerType: "kimi",
|
||||
baseUrl: "https://api.moonshot.ai/v1",
|
||||
icon: "kimi",
|
||||
models: [
|
||||
{ model: "kimi-k2.7-code" },
|
||||
{ model: "kimi-k2-thinking-turbo" },
|
||||
{ model: "kimi-k2.6" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "deepseek",
|
||||
name: "DeepSeek",
|
||||
nameKey: "presetNameDeepseek",
|
||||
websiteUrl: "https://platform.deepseek.com",
|
||||
category: "cn_official",
|
||||
providerType: "openai",
|
||||
baseUrl: "https://api.deepseek.com/v1",
|
||||
icon: "deepseek",
|
||||
models: [{ model: "deepseek-chat" }, { model: "deepseek-reasoner" }],
|
||||
usageKinds: ["balance:deepseek"],
|
||||
},
|
||||
{
|
||||
id: "zhipu",
|
||||
name: "Zhipu GLM",
|
||||
nameKey: "presetNameZhipu",
|
||||
websiteUrl: "https://open.bigmodel.cn",
|
||||
category: "cn_official",
|
||||
providerType: "openai",
|
||||
baseUrl: "https://open.bigmodel.cn/api/paas/v4",
|
||||
icon: "zhipu",
|
||||
models: [
|
||||
{ model: "glm-4.7" },
|
||||
{ model: "glm-4.6" },
|
||||
{ model: "glm-4.5-air" },
|
||||
],
|
||||
usageKinds: ["plan:zhipu"],
|
||||
},
|
||||
{
|
||||
id: "zai",
|
||||
name: "z.ai",
|
||||
nameKey: "presetNameZai",
|
||||
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" }],
|
||||
usageKinds: ["plan:zhipu"],
|
||||
},
|
||||
{
|
||||
id: "bailian",
|
||||
name: "Alibaba Bailian",
|
||||
nameKey: "presetNameBailian",
|
||||
websiteUrl: "https://bailian.console.aliyun.com",
|
||||
category: "cn_official",
|
||||
providerType: "openai",
|
||||
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
icon: "bailian",
|
||||
models: [
|
||||
{ model: "qwen3-max" },
|
||||
{ model: "qwen3-coder-plus" },
|
||||
{ model: "qwen3.5-plus" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "minimax",
|
||||
name: "MiniMax",
|
||||
nameKey: "presetNameMinimax",
|
||||
websiteUrl: "https://platform.minimaxi.com",
|
||||
category: "cn_official",
|
||||
providerType: "openai",
|
||||
baseUrl: "https://api.minimaxi.com/v1",
|
||||
icon: "minimax",
|
||||
models: [{ model: "MiniMax-M3" }, { model: "MiniMax-M2.7" }],
|
||||
usageKinds: ["plan:minimax"],
|
||||
},
|
||||
{
|
||||
id: "stepfun",
|
||||
name: "StepFun",
|
||||
nameKey: "presetNameStepfun",
|
||||
websiteUrl: "https://platform.stepfun.com",
|
||||
category: "cn_official",
|
||||
providerType: "openai",
|
||||
baseUrl: "https://api.stepfun.com/v1",
|
||||
icon: "stepfun",
|
||||
models: [{ model: "step-3.7-flash" }, { model: "step-3.5-flash" }],
|
||||
usageKinds: ["balance:stepfun"],
|
||||
},
|
||||
{
|
||||
id: "siliconflow",
|
||||
name: "SiliconFlow",
|
||||
nameKey: "presetNameSiliconflow",
|
||||
websiteUrl: "https://siliconflow.cn",
|
||||
category: "cn_official",
|
||||
providerType: "openai",
|
||||
baseUrl: "https://api.siliconflow.cn/v1",
|
||||
icon: "siliconflow",
|
||||
// The extracted icon has no metadata defaultColor; set it explicitly.
|
||||
iconColor: "#6E29F6",
|
||||
models: [
|
||||
{ model: "Qwen/Qwen3-235B-A22B" },
|
||||
{ model: "moonshotai/Kimi-K2.5" },
|
||||
// Not covered by the snapshot; fill context/displayName explicitly.
|
||||
{
|
||||
model: "deepseek-ai/DeepSeek-V3.2",
|
||||
displayName: "DeepSeek V3.2",
|
||||
maxContextSize: 163840,
|
||||
},
|
||||
],
|
||||
usageKinds: ["balance:siliconflow"],
|
||||
},
|
||||
{
|
||||
id: "novita",
|
||||
name: "Novita AI",
|
||||
nameKey: "presetNameNovita",
|
||||
websiteUrl: "https://novita.ai",
|
||||
category: "third_party",
|
||||
providerType: "openai",
|
||||
// Note: /v3, not /v1.
|
||||
baseUrl: "https://api.novita.ai/v3",
|
||||
icon: "novita",
|
||||
models: [
|
||||
// Not covered by the snapshot; fill context/displayName explicitly.
|
||||
{
|
||||
model: "deepseek/deepseek-v3.2",
|
||||
displayName: "DeepSeek V3.2",
|
||||
maxContextSize: 163840,
|
||||
},
|
||||
{ model: "moonshotai/kimi-k2-thinking" },
|
||||
{ model: "zai-org/glm-4.6" },
|
||||
],
|
||||
usageKinds: ["balance:novita"],
|
||||
},
|
||||
{
|
||||
id: "openrouter",
|
||||
name: "OpenRouter",
|
||||
nameKey: "presetNameOpenrouter",
|
||||
websiteUrl: "https://openrouter.ai",
|
||||
category: "aggregator",
|
||||
providerType: "openai",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
icon: "openrouter",
|
||||
models: [
|
||||
// OpenRouter uses dotted versions which the snapshot does not index;
|
||||
// fill context/displayName explicitly for this one.
|
||||
{
|
||||
model: "anthropic/claude-opus-4.5",
|
||||
displayName: "Claude Opus 4.5",
|
||||
maxContextSize: 200000,
|
||||
},
|
||||
{ model: "openai/gpt-5.2" },
|
||||
{ model: "google/gemini-3-pro-preview" },
|
||||
],
|
||||
usageKinds: ["balance:openrouter"],
|
||||
},
|
||||
{
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
nameKey: "presetNameOpenai",
|
||||
websiteUrl: "https://platform.openai.com",
|
||||
category: "official",
|
||||
providerType: "openai",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
icon: "openai",
|
||||
models: [
|
||||
{ model: "gpt-5.2" },
|
||||
{ model: "gpt-5.1-codex-max" },
|
||||
{ model: "gpt-5-mini" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "google-genai",
|
||||
name: "Google AI Studio",
|
||||
nameKey: "presetNameGoogleGenai",
|
||||
websiteUrl: "https://aistudio.google.com",
|
||||
category: "official",
|
||||
providerType: "google-genai",
|
||||
baseUrl: "https://generativelanguage.googleapis.com",
|
||||
icon: "google",
|
||||
models: [
|
||||
{ model: "gemini-3-pro-preview" },
|
||||
{ model: "gemini-3-flash-preview" },
|
||||
{ model: "gemini-2.5-flash" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "volcengine",
|
||||
name: "Volcengine Ark",
|
||||
nameKey: "presetNameVolcengine",
|
||||
websiteUrl: "https://www.volcengine.com/product/ark",
|
||||
category: "cn_official",
|
||||
providerType: "openai",
|
||||
baseUrl: "https://ark.cn-beijing.volces.com/api/v3",
|
||||
icon: "huoshan",
|
||||
// v1: inference only — plan queries need separate AK/SK (P2).
|
||||
models: [
|
||||
{
|
||||
model: "doubao-seed-1-6-250615",
|
||||
displayName: "Doubao Seed 1.6",
|
||||
},
|
||||
{ model: "kimi-k2-250905", displayName: "Kimi K2" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export interface PresetConversionResult {
|
||||
provider: Provider;
|
||||
models: Model[];
|
||||
/** Alias to write into config.default_model ("" when no models). */
|
||||
defaultModel: string;
|
||||
usageKinds: ReadonlyArray<string> | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a preset into concrete Provider + Model entries.
|
||||
*
|
||||
* - Provider name collisions get a "-2" / "-3" suffix.
|
||||
* - Model aliases are forced to `${name}/${modelId}` and deduped one by one
|
||||
* against existing aliases (also with "-2" / "-3" suffixes).
|
||||
* - max_context_size priority: preset override → models.dev snapshot →
|
||||
* regex rules → DEFAULT_MAX_CONTEXT_SIZE (the last two are both handled
|
||||
* inside getDefaultMaxContextSize).
|
||||
*/
|
||||
export function presetToProviderAndModels(
|
||||
preset: ProviderPreset,
|
||||
options: {
|
||||
existingProviderNames: Set<string>;
|
||||
existingModelAliases: Set<string>;
|
||||
},
|
||||
): PresetConversionResult {
|
||||
let name = preset.id;
|
||||
let n = 2;
|
||||
while (options.existingProviderNames.has(name)) {
|
||||
name = `${preset.id}-${n}`;
|
||||
n++;
|
||||
}
|
||||
|
||||
const models: Model[] = preset.models.map((m) => {
|
||||
let alias = `${name}/${m.model}`;
|
||||
let k = 2;
|
||||
while (options.existingModelAliases.has(alias)) {
|
||||
alias = `${name}/${m.model}-${k}`;
|
||||
k++;
|
||||
}
|
||||
|
||||
const ref = getModelRef(m.model);
|
||||
const refCaps = ref ? capabilitiesFromRef(ref) : [];
|
||||
const maxContextSize =
|
||||
m.maxContextSize ?? ref?.context ?? getDefaultMaxContextSize(m.model);
|
||||
|
||||
return {
|
||||
alias,
|
||||
provider: name,
|
||||
model: m.model,
|
||||
max_context_size: maxContextSize,
|
||||
display_name: m.displayName ?? ref?.name ?? alias,
|
||||
capabilities: m.capabilities ?? (refCaps.length > 0 ? refCaps : ["thinking"]),
|
||||
supports_1m: maxContextSize >= 1_000_000,
|
||||
raw_other: {},
|
||||
};
|
||||
});
|
||||
|
||||
const provider: Provider = {
|
||||
name,
|
||||
provider_type: preset.providerType,
|
||||
base_url: preset.baseUrl,
|
||||
api_key: null,
|
||||
env: {},
|
||||
note: null,
|
||||
official_url: preset.websiteUrl ?? null,
|
||||
managed: false,
|
||||
enabled: true,
|
||||
icon: preset.icon ?? null,
|
||||
icon_color: preset.iconColor ?? null,
|
||||
usageKinds: preset.usageKinds ? [...preset.usageKinds] : undefined,
|
||||
raw_other: {},
|
||||
};
|
||||
|
||||
return {
|
||||
provider,
|
||||
models,
|
||||
defaultModel: models[0]?.alias ?? "",
|
||||
usageKinds: preset.usageKinds,
|
||||
};
|
||||
}
|
||||
|
||||
// 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,
|
||||
// so without this guard a typo or schema drift would only surface as a
|
||||
// missing usage footer with no error message. Gated by Vite's DEV flag so
|
||||
// it costs nothing in production bundles.
|
||||
if (import.meta.env.DEV) {
|
||||
for (const preset of providerPresets) {
|
||||
if (!preset.usageKinds) continue;
|
||||
for (const kind of preset.usageKinds) {
|
||||
if (!SUPPORTED_USAGE_KINDS.has(kind)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`[providerPresets] preset "${preset.id}" references unknown usage kind "${kind}". ` +
|
||||
`Add it to src-tauri/src/services/mod.rs UsageKind + SUPPORTED_USAGE_KINDS, or remove it from the preset.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,12 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
reloadConfig: "Reload Config",
|
||||
openConfigDir: "Open Config Dir",
|
||||
unsavedConfirm: "You have unsaved changes. Are you sure you want to reload?",
|
||||
validationNoApiKey: "Missing API key",
|
||||
validationNoBaseUrl: "Missing base URL",
|
||||
validationNoModels: "No models configured",
|
||||
validationBlankModels: "Model entries with empty model id: {aliases}",
|
||||
saveValidationConfirm:
|
||||
"The following providers are not fully configured. Save anyway?\n\n{details}\n\n(Click OK to save to config.toml as-is; click Cancel to keep editing.)",
|
||||
providerNotFound: "Provider not found",
|
||||
backToList: "Back to list",
|
||||
syncToOmp: "Sync to OMP",
|
||||
@@ -316,4 +322,45 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
// Sessions — preview
|
||||
previewCollapseChars: "Collapse · {n} chars",
|
||||
previewExpandChars: "Expand · {n} chars",
|
||||
|
||||
// Provider presets
|
||||
presetPickerTitle: "Choose a provider preset",
|
||||
presetSearchPlaceholder: "Search presets...",
|
||||
presetSortOriginal: "Default",
|
||||
presetSortAlpha: "A-Z",
|
||||
presetNoResults: "No matching presets",
|
||||
presetCustomConfig: "+ Custom configuration",
|
||||
presetFooterHint: "Presets only pre-fill the form; every field stays editable afterwards.",
|
||||
presetCategoryOfficial: "Official",
|
||||
presetCategoryCnOfficial: "China",
|
||||
presetCategoryThirdParty: "Third-party",
|
||||
presetCategoryAggregator: "Aggregator",
|
||||
presetNameAnthropic: "Anthropic",
|
||||
presetNameKimiCoding: "Kimi For Coding",
|
||||
presetNameMoonshot: "Moonshot Platform",
|
||||
presetNameDeepseek: "DeepSeek",
|
||||
presetNameZhipu: "Zhipu GLM",
|
||||
presetNameZai: "z.ai (Zhipu Global)",
|
||||
presetNameBailian: "Alibaba Bailian",
|
||||
presetNameMinimax: "MiniMax",
|
||||
presetNameStepfun: "StepFun",
|
||||
presetNameSiliconflow: "SiliconFlow",
|
||||
presetNameNovita: "Novita AI",
|
||||
presetNameOpenrouter: "OpenRouter",
|
||||
presetNameOpenai: "OpenAI",
|
||||
presetNameGoogleGenai: "Google AI Studio",
|
||||
presetNameVolcengine: "Volcengine Ark",
|
||||
|
||||
// Provider usage
|
||||
usageBalance: "Balance",
|
||||
usageUsed: "used",
|
||||
usageRemaining: "left",
|
||||
usageRefresh: "Refresh",
|
||||
usageRetry: "Retry",
|
||||
usageQueryFailed: "Query failed",
|
||||
usageNetworkError: "Network error",
|
||||
usageInvalidKey: "Invalid API key",
|
||||
usageResetIn: "resets in {time}",
|
||||
usageResetDone: "reset",
|
||||
usageLoading: "Querying…",
|
||||
};
|
||||
@@ -9,6 +9,12 @@ export const zhTranslations = {
|
||||
reloadConfig: "读取配置",
|
||||
openConfigDir: "打开配置目录",
|
||||
unsavedConfirm: "有未保存修改,确定要重新读取配置吗?",
|
||||
validationNoApiKey: "缺少 API Key",
|
||||
validationNoBaseUrl: "缺少 Base URL",
|
||||
validationNoModels: "未配置模型",
|
||||
validationBlankModels: "存在未填写 model id 的模型:{aliases}",
|
||||
saveValidationConfirm:
|
||||
"以下供应商尚未完成配置,仍要保存吗?\n\n{details}\n\n(点击「确定」仍会保存到 config.toml;点击「取消」可继续编辑)",
|
||||
providerNotFound: "供应商不存在",
|
||||
backToList: "返回列表",
|
||||
syncToOmp: "同步到 OMP",
|
||||
@@ -311,6 +317,47 @@ export const zhTranslations = {
|
||||
// Sessions — preview
|
||||
previewCollapseChars: "收起 · {n} 字符",
|
||||
previewExpandChars: "展开全文 · {n} 字符",
|
||||
|
||||
// Provider presets
|
||||
presetPickerTitle: "选择预设供应商",
|
||||
presetSearchPlaceholder: "搜索预设供应商...",
|
||||
presetSortOriginal: "原始",
|
||||
presetSortAlpha: "A-Z",
|
||||
presetNoResults: "没有匹配的预设",
|
||||
presetCustomConfig: "+ 自定义配置",
|
||||
presetFooterHint: "预设只是一键填表,后续可继续修改所有字段。",
|
||||
presetCategoryOfficial: "官方",
|
||||
presetCategoryCnOfficial: "国产",
|
||||
presetCategoryThirdParty: "第三方",
|
||||
presetCategoryAggregator: "聚合",
|
||||
presetNameAnthropic: "Anthropic 官方",
|
||||
presetNameKimiCoding: "Kimi For Coding",
|
||||
presetNameMoonshot: "Moonshot 平台",
|
||||
presetNameDeepseek: "DeepSeek",
|
||||
presetNameZhipu: "智谱 GLM",
|
||||
presetNameZai: "z.ai(智谱海外)",
|
||||
presetNameBailian: "阿里百炼",
|
||||
presetNameMinimax: "MiniMax",
|
||||
presetNameStepfun: "阶跃星辰",
|
||||
presetNameSiliconflow: "硅基流动",
|
||||
presetNameNovita: "Novita AI",
|
||||
presetNameOpenrouter: "OpenRouter",
|
||||
presetNameOpenai: "OpenAI 官方",
|
||||
presetNameGoogleGenai: "Google AI Studio",
|
||||
presetNameVolcengine: "火山方舟",
|
||||
|
||||
// Provider usage
|
||||
usageBalance: "余额",
|
||||
usageUsed: "已使用",
|
||||
usageRemaining: "剩余",
|
||||
usageRefresh: "刷新",
|
||||
usageRetry: "重试",
|
||||
usageQueryFailed: "查询失败",
|
||||
usageNetworkError: "网络异常",
|
||||
usageInvalidKey: "API Key 无效",
|
||||
usageResetIn: "{time} 后重置",
|
||||
usageResetDone: "已重置",
|
||||
usageLoading: "查询中…",
|
||||
} as const;
|
||||
|
||||
export type TranslationKey = keyof typeof zhTranslations;
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { Config, Model, ProviderType } from "../types";
|
||||
|
||||
/**
|
||||
* Provider types that must have a base_url set in order to function.
|
||||
* Anthropic and Vertexai intentionally have no default — they read the
|
||||
* agent's CLI environment / SDK defaults, so leaving base_url empty is
|
||||
* a valid (and the only) state.
|
||||
*/
|
||||
const TYPES_REQUIRING_BASE_URL: ReadonlySet<ProviderType> = new Set<ProviderType>([
|
||||
"openai",
|
||||
"openai_responses",
|
||||
"kimi",
|
||||
"google-genai",
|
||||
]);
|
||||
|
||||
export interface ProviderIssue {
|
||||
name: string;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect a config and report any enabled providers that are not ready to
|
||||
* use. Used by the save flow to warn the user before persisting an
|
||||
* incomplete state to config.toml / SQLite.
|
||||
*
|
||||
* "Incomplete" means any of:
|
||||
* - missing API key (and not a managed OAuth provider)
|
||||
* - missing base_url for provider types that need one
|
||||
* - zero configured models
|
||||
*
|
||||
* Disabled providers are skipped — the user explicitly turned them off.
|
||||
*/
|
||||
export function validateProviders(
|
||||
config: Config,
|
||||
modelIndex: Record<string, Model>,
|
||||
// The i18n `t` from useTranslation() is strictly typed to its known key
|
||||
// union; a generic (key: string) signature is the right shape for a
|
||||
// library-style helper that doesn't want to import the project's i18n
|
||||
// module directly.
|
||||
t: (key: string, vars?: Record<string, unknown>) => string,
|
||||
// When set, only inspect this single provider. Used when activating a
|
||||
// specific provider: sibling providers merely listed in the config
|
||||
// must not block the switch.
|
||||
target?: string,
|
||||
): ProviderIssue[] {
|
||||
const issues: ProviderIssue[] = [];
|
||||
for (const provider of Object.values(config.providers)) {
|
||||
if (target && provider.name !== target) continue;
|
||||
if (!provider.enabled) continue;
|
||||
const reasons: string[] = [];
|
||||
|
||||
const apiKey = provider.api_key?.trim();
|
||||
if (!apiKey && !provider.managed) {
|
||||
reasons.push(t("validationNoApiKey"));
|
||||
}
|
||||
|
||||
const baseUrl = provider.base_url?.trim();
|
||||
if (!baseUrl && TYPES_REQUIRING_BASE_URL.has(provider.provider_type)) {
|
||||
reasons.push(t("validationNoBaseUrl"));
|
||||
}
|
||||
|
||||
const providerModels = Object.values(modelIndex).filter(
|
||||
(m) => m.provider === provider.name,
|
||||
);
|
||||
if (providerModels.length === 0) {
|
||||
reasons.push(t("validationNoModels"));
|
||||
} else {
|
||||
// A model entry exists but its `model` field may be blank (e.g. the
|
||||
// user hit "+ 添加模型" in the edit page and saved before filling
|
||||
// in the upstream model id). Those still break the provider.
|
||||
const blankAliases = providerModels
|
||||
.filter((m) => !m.model || !m.model.trim())
|
||||
.map((m) => m.alias);
|
||||
if (blankAliases.length > 0) {
|
||||
reasons.push(
|
||||
t("validationBlankModels", { aliases: blankAliases.join(", ") }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (reasons.length > 0) {
|
||||
issues.push({ name: provider.name, reasons });
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
@@ -28,6 +28,12 @@ export interface Provider {
|
||||
icon?: string | null;
|
||||
/** Custom icon color; falls back to icon metadata defaultColor. */
|
||||
icon_color?: string | null;
|
||||
/**
|
||||
* Usage query kinds (e.g. "balance:deepseek", "plan:kimi_coding").
|
||||
* Merged in by the Rust side on load (SQLite settings / base_url detect)
|
||||
* and persisted back to SQLite on save; never written to config.toml.
|
||||
*/
|
||||
usageKinds?: string[];
|
||||
/** Extra agent-specific provider fields preserved across edits. */
|
||||
raw_other?: unknown;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user