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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user