Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97422440e1 |
No files matched your search
@@ -6,6 +6,21 @@
|
||||
|
||||
---
|
||||
|
||||
## v0.8.6 (2026-10-16)
|
||||
|
||||
供应商名称唯一性防护版:修复同一厂商配置第二把 Key 时,因名称撞车导致第一个供应商条目被静默覆盖、API Key 丢失的问题。
|
||||
|
||||
- **同名覆盖丢失修复**:供应商以名称为唯一存储键,此前新建/改名撞名会在内存无提示顶掉旧条目,随后任意保存(含 Ctrl+S 快捷键,其绕过被禁用的按钮)即把丢失写进 config.toml;现四个写入点全部拦截
|
||||
- **编辑页即时提示**:名称为空或重名 → 红框 + 错误说明 + 保存按钮禁用
|
||||
- **保存硬阻止**:Ctrl+S / 底部按钮 / 切换供应商三条入口统一过检,撞名未解除或空名条目弹窗阻止、绝不落盘
|
||||
- **被顶掉的供应商自动找回**:撞名瞬间暂存被覆盖条目,改名离开撞名态时自动还原(Key 与模型映射一并回来)
|
||||
- **新建占位名循环取号**:删除过 `provider-N` 后新建不再回退撞名
|
||||
- **「配置 JSON」源头拒绝**:JSON 内 `name` 与其他供应商重名时拒绝应用、零状态变更
|
||||
- **复制提示显示真实新名**(`-copy` / `-copy-2`),不再固定 `-copy`
|
||||
- 名称比较为大小写敏感全等、忽略首尾空白,与 config.toml 键语义一致;零 Rust 改动、无迁移成本
|
||||
- models.dev 快照同步至 2026-10-16(8,894 模型 / 230 供应商)
|
||||
- 测试:前端 157 项(新增 7 项名称校验)、Rust 212 项(无改动,全量回归);置换暂存算法另经 27 条独立断言推演验证
|
||||
|
||||
## v0.8.5 (2026-10-16)
|
||||
|
||||
设置界面重构:原来塞满所有选项的「设置」弹窗改为带返回头的全局设置页面,按「全局设置 / 高级设置 / 关于」分三页;同时修掉体检账单漏查与更新下载哑火两处问题。
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# KimiSwitch v0.8.6
|
||||
|
||||
供应商名称唯一性防护版:修复同一厂商配置第二把 Key 时,因供应商**名称撞车**导致第一个供应商条目被静默覆盖、API Key 随之丢失的问题。
|
||||
|
||||
## 同名覆盖丢失(事故修复)
|
||||
|
||||
- **事故复现路径**:Kimi Switch 里供应商以「名称」为唯一存储键,新建第二个供应商时若名称与已有厂商相同(例如两把 Key 都叫 `TokenHub`),应用会在内存里**无提示地用新条目顶掉旧条目**,随后任意一次保存(包括 Ctrl+S 快捷键与切换供应商)都会把丢失状态写进 config.toml——第一把 Key 就此消失,全程没有任何警告
|
||||
- **根因**:四个写入点(编辑改名、JSON 应用、新建占位名、保存校验)全部没有名称查重;且 Ctrl+S 是窗口级快捷键,会绕过任何被禁用的按钮,单纯「灰掉保存按钮」拦不住
|
||||
- **本次修复后**:名称撞车在每一层都会被拦下,被顶掉的条目还能自动找回(见下),同类丢失不再可能发生
|
||||
|
||||
## 防护机制
|
||||
|
||||
- **编辑页即时提示**:名称为空或与其他供应商重名时,名称输入框立即红框 + 错误说明(「供应商名称不能为空」/「名称与其他供应商重复」),顶部保存按钮同时禁用
|
||||
- **保存硬阻止**:三条保存入口(Ctrl+S、底部保存按钮、切换供应商)统一过检——撞名未解除或存在空名条目时弹窗阻止,绝不落盘
|
||||
- **被顶掉的供应商自动找回**:撞名瞬间被覆盖的原厂商会被暂存保护,改名离开撞名状态时**自动还原**回列表(例如把 `TokenHub-copy` 错改成 `TokenHub` 后再改成 `TokenHub-3`,原来的 `TokenHub` 连同它的 Key 和模型映射都会回来)
|
||||
- **新建占位名防撞**:`+ 自定义配置` 生成的 `provider-N` 占位名改为循环取号,删除过条目后不再回退撞名
|
||||
- **「配置 JSON」源头拒绝**:粘贴的 JSON 里 `name` 与其他供应商重名时直接拒绝应用,不产生任何变更
|
||||
- **复制提示修正**:复制供应商的提示文案显示真实新名(`-copy` / `-copy-2`),不再固定显示 `-copy`
|
||||
|
||||
## 其他
|
||||
|
||||
- 名称比较为大小写敏感全等(`TokenHub` 与 `tokenhub` 视为两个不同供应商,与 config.toml 键语义一致);首尾空白自动忽略
|
||||
- models.dev 快照同步至 2026-10-16(8,894 模型 / 230 供应商)
|
||||
- 本次零 Rust 改动,存储层与 config.toml 格式完全不变,升级 / 回退无迁移成本
|
||||
|
||||
## 测试
|
||||
|
||||
- 前端 157 项(新增 7 项名称校验:空名 / 纯空白 / 撞名 / 大小写不判撞 / 双侧空白归一 / 唯一名 / 空名优先级)
|
||||
- 置换暂存与自动还原算法经 27 条独立断言推演验证(含连续两次撞名、撞名后重载、撞名后返回丢弃等边界)
|
||||
- Rust 212 项(本次无改动,全量回归通过)
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "kimiswitch",
|
||||
"private": true,
|
||||
"version": "0.8.5",
|
||||
"version": "0.8.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+846
-582
File diff suppressed because it is too large.
Load diff
Generated
+1
-1
@@ -1978,7 +1978,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kimiswitch"
|
||||
version = "0.8.5"
|
||||
version = "0.8.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "kimiswitch"
|
||||
version = "0.8.5"
|
||||
version = "0.8.6"
|
||||
description = "Kimi Switch - model config manager"
|
||||
authors = ["codingplan.site"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"productName": "Kimi Switch",
|
||||
"version": "0.8.5",
|
||||
"version": "0.8.6",
|
||||
"identifier": "com.kimiswitch.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
+135
-22
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useReducer, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { useConfig } from "./hooks/useConfig";
|
||||
@@ -99,7 +99,20 @@ export default function App() {
|
||||
const [pendingNewProviders, setPendingNewProviders] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
// The provider an earlier name collision overwrote in memory, held so it
|
||||
// can be put back. Non-null means the in-memory config is missing an entry,
|
||||
// so trySave hard-blocks on it; the rename / delete paths restore it.
|
||||
const displacedRef = useRef<{ key: string; provider: Provider } | null>(null);
|
||||
|
||||
// Every "abandon the in-memory config" path goes through here. The reload
|
||||
// brings back the on-disk truth, which includes whatever a collision
|
||||
// displaced, so the stash is obsolete once the reload has actually
|
||||
// returned. Clearing it only afterwards keeps a *failed* reload (config
|
||||
// unchanged, still collided) from looking clean to trySave.
|
||||
const reloadFromDisk = useCallback(async () => {
|
||||
await refresh();
|
||||
displacedRef.current = null;
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
@@ -170,6 +183,26 @@ export default function App() {
|
||||
async (target?: string): Promise<boolean> => {
|
||||
if (!config) return false;
|
||||
if (dirty) {
|
||||
// Hard blocks ahead of the completeness confirm. Both describe a
|
||||
// config that loses data the moment it reaches disk, so there is
|
||||
// nothing here for the user to confirm.
|
||||
if (displacedRef.current) {
|
||||
alert(t("saveBlockedDisplaced"));
|
||||
return false;
|
||||
}
|
||||
const blankNames = Object.keys(config.providers).filter(
|
||||
(key) => key.trim() === ""
|
||||
);
|
||||
if (blankNames.length > 0) {
|
||||
// JSON.stringify keeps a blank name readable in the message —
|
||||
// joining the raw keys renders as nothing after the colon.
|
||||
alert(
|
||||
t("saveBlockedNameConflict", {
|
||||
names: blankNames.map((k) => JSON.stringify(k)).join(", "),
|
||||
})
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const issues = validateProviders(config, config.models, tGeneric, target);
|
||||
if (issues.length > 0) {
|
||||
const summary = issues
|
||||
@@ -203,7 +236,7 @@ export default function App() {
|
||||
case "R":
|
||||
e.preventDefault();
|
||||
if (dirty && !confirm(t("unsavedConfirm"))) return;
|
||||
refresh();
|
||||
void reloadFromDisk();
|
||||
break;
|
||||
case "o":
|
||||
case "O":
|
||||
@@ -216,7 +249,7 @@ export default function App() {
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [trySave, refresh, dirty, t, agent]);
|
||||
}, [trySave, reloadFromDisk, dirty, t, agent]);
|
||||
|
||||
// "Add provider" opens the preset picker; the empty form is reachable via
|
||||
// the picker's "+ custom config" entry (handleAddCustomProvider).
|
||||
@@ -228,7 +261,15 @@ export default function App() {
|
||||
const handleAddCustomProvider = () => {
|
||||
if (!config) return;
|
||||
setShowPresetPicker(false);
|
||||
const name = `provider-${providers.length + 1}`;
|
||||
// Counting alone can land on a taken name (delete `provider-2`, add:
|
||||
// `provider-3` may already exist), and that collision would silently
|
||||
// overwrite an existing provider before the user types anything.
|
||||
let n = providers.length + 1;
|
||||
let name = `provider-${n}`;
|
||||
while (name in config.providers) {
|
||||
n++;
|
||||
name = `provider-${n}`;
|
||||
}
|
||||
const defaultType = agent === "kimi_code" ? "kimi" : "openai";
|
||||
updateConfig((cfg) => ({
|
||||
...cfg,
|
||||
@@ -292,6 +333,16 @@ export default function App() {
|
||||
const oldEntry = Object.entries(providers).find(
|
||||
([, p]) => p.name === editingProvider
|
||||
);
|
||||
const oldKey = oldEntry ? oldEntry[0] : editingProvider;
|
||||
// Judged against the pre-change map: the name this write lands on is
|
||||
// exactly the entry the overwrite below would destroy without a trace.
|
||||
const displacedKey =
|
||||
provider.name !== oldKey && provider.name in cfg.providers
|
||||
? provider.name
|
||||
: null;
|
||||
// Both branches assign `next`; the initial value is only there so
|
||||
// TypeScript can infer its type.
|
||||
let next = cfg;
|
||||
if (oldEntry && oldEntry[0] !== provider.name) {
|
||||
delete providers[oldEntry[0]];
|
||||
// Re-key this provider's model aliases so the `<provider>/<model>`
|
||||
@@ -317,10 +368,37 @@ export default function App() {
|
||||
// pool entries.
|
||||
const raw_other = rekeySubagentPoolAliases(cfg.raw_other, aliasMap);
|
||||
setEditingProvider(provider.name);
|
||||
return { ...cfg, providers, models, default_model, raw_other };
|
||||
next = { ...cfg, providers, models, default_model, raw_other };
|
||||
} else {
|
||||
providers[provider.name] = provider;
|
||||
next = { ...cfg, providers };
|
||||
}
|
||||
providers[provider.name] = provider;
|
||||
return { ...cfg, providers };
|
||||
// Put back the entry an earlier collision displaced. This must run
|
||||
// AFTER the rename above moved the edited provider off that key: while
|
||||
// the edited entry still holds it, the key is present in `providers`
|
||||
// and the condition would never fire. Running it before the stashing
|
||||
// below also empties the ref, so a second collision in a row can stash
|
||||
// its own victim instead of being skipped.
|
||||
if (
|
||||
displacedRef.current &&
|
||||
provider.name !== displacedRef.current.key &&
|
||||
!(displacedRef.current.key in providers)
|
||||
) {
|
||||
providers[displacedRef.current.key] = displacedRef.current.provider;
|
||||
displacedRef.current = null;
|
||||
}
|
||||
// Stash whatever this write is about to overwrite. The write itself
|
||||
// still goes through: the name input is controlled and reports every
|
||||
// keystroke, so rejecting a colliding one would make the text jump back
|
||||
// as the user types. Keeping the loss out of config.toml is trySave's
|
||||
// job, not the input's.
|
||||
if (displacedKey && displacedRef.current == null) {
|
||||
displacedRef.current = {
|
||||
key: displacedKey,
|
||||
provider: cfg.providers[displacedKey],
|
||||
};
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -329,6 +407,14 @@ export default function App() {
|
||||
updateConfig((cfg) => {
|
||||
const providers = { ...cfg.providers };
|
||||
delete providers[name];
|
||||
// The stash holds a provider an earlier collision overwrote in memory.
|
||||
// Deleting frees the key the edited entry was squatting on, so the
|
||||
// displaced provider can go back instead of staying stranded — a
|
||||
// stranded stash blocks every later save until the user discards.
|
||||
if (displacedRef.current && !(displacedRef.current.key in providers)) {
|
||||
providers[displacedRef.current.key] = displacedRef.current.provider;
|
||||
displacedRef.current = null;
|
||||
}
|
||||
const models = { ...cfg.models };
|
||||
const removedAliases: string[] = [];
|
||||
for (const key of Object.keys(models)) {
|
||||
@@ -371,19 +457,25 @@ export default function App() {
|
||||
};
|
||||
|
||||
const handleDuplicateProvider = async (name: string) => {
|
||||
if (!config) return;
|
||||
const src = config.providers[name];
|
||||
if (!src) return;
|
||||
// Resolved outside the updater so the toast below can name the entry that
|
||||
// was actually created (`-copy-2` and up when `-copy` is taken). The
|
||||
// closure's config is the same map the updater starts from.
|
||||
let newName = `${name}-copy`;
|
||||
let n = 2;
|
||||
while (config.providers[newName]) {
|
||||
newName = `${name}-copy-${n}`;
|
||||
n++;
|
||||
}
|
||||
updateConfig((cfg) => {
|
||||
const src = cfg.providers[name];
|
||||
if (!src) return cfg;
|
||||
let newName = `${name}-copy`;
|
||||
let n = 2;
|
||||
while (cfg.providers[newName]) {
|
||||
newName = `${name}-copy-${n}`;
|
||||
n++;
|
||||
}
|
||||
const source = cfg.providers[name];
|
||||
if (!source) return cfg;
|
||||
// Clone the provider (deactivated, clear its remembered default model
|
||||
// since the model aliases will be re-keyed to the new name below).
|
||||
const copied = setProviderDefaultModel(
|
||||
{ ...src, name: newName, active: false },
|
||||
{ ...source, name: newName, active: false },
|
||||
null
|
||||
);
|
||||
const providers = { ...cfg.providers, [newName]: copied };
|
||||
@@ -395,7 +487,7 @@ export default function App() {
|
||||
// No immediate save: the copy lives in memory only, like every other
|
||||
// edit. Abandoning (back → discard) must leave the config unchanged;
|
||||
// the copy persists when the user saves (Ctrl+S / footer / switch).
|
||||
setSwitchMessage(t("copiedProvider", { name: `${name}-copy` }));
|
||||
setSwitchMessage(t("copiedProvider", { name: newName }));
|
||||
setTimeout(() => setSwitchMessage(null), 3000);
|
||||
};
|
||||
|
||||
@@ -468,7 +560,7 @@ export default function App() {
|
||||
if (!saved) {
|
||||
// Validation was cancelled: undo the in-memory switch by reloading
|
||||
// the on-disk state, and skip the activation + /reload nudge.
|
||||
await refresh();
|
||||
await reloadFromDisk();
|
||||
return;
|
||||
}
|
||||
await invoke("activate_agent_config_command", { agent });
|
||||
@@ -495,17 +587,29 @@ export default function App() {
|
||||
next.delete(target);
|
||||
return next;
|
||||
});
|
||||
await refresh();
|
||||
await reloadFromDisk();
|
||||
} 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();
|
||||
await reloadFromDisk();
|
||||
}
|
||||
setView("list");
|
||||
};
|
||||
|
||||
const handleApplyProviderJson = (provider: Provider, models: Model[]) => {
|
||||
if (!config) return;
|
||||
// Source-level rejection: applying is a single deliberate action, so
|
||||
// refusing here leaves both the panel and the config untouched (unlike
|
||||
// the name input, which takes every keystroke and stashes the loss).
|
||||
if (provider.name.trim() === "") {
|
||||
alert(t("validationNameEmpty"));
|
||||
return;
|
||||
}
|
||||
if (provider.name !== editingProvider && provider.name in config.providers) {
|
||||
alert(t("applyJsonNameConflict"));
|
||||
return;
|
||||
}
|
||||
updateConfig((cfg) => {
|
||||
const providers = { ...cfg.providers };
|
||||
const oldName = editingProvider;
|
||||
@@ -581,7 +685,7 @@ export default function App() {
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 px-2 py-1 border border-red-400/30 rounded hover:bg-red-900/30"
|
||||
onClick={() => refresh()}
|
||||
onClick={() => void reloadFromDisk()}
|
||||
>
|
||||
{t("retry")}
|
||||
</button>
|
||||
@@ -746,9 +850,18 @@ export default function App() {
|
||||
agent={agent}
|
||||
/>
|
||||
) : currentProvider ? (
|
||||
// `nameConflict` is read from the displaced-entry stash during
|
||||
// render: the collision write happens inside updateConfig's
|
||||
// updater, which sets the ref before React re-renders, so this pass
|
||||
// already sees the new value (same precedent as setEditingProvider
|
||||
// inside that updater).
|
||||
<ProviderEdit
|
||||
agent={agent}
|
||||
provider={currentProvider}
|
||||
allProviderNames={Object.keys(config.providers).filter(
|
||||
(k) => k !== editingProvider
|
||||
)}
|
||||
nameConflict={displacedRef.current != null}
|
||||
models={Object.values(config.models).filter(
|
||||
(m) => m.provider === currentProvider.name
|
||||
)}
|
||||
@@ -914,7 +1027,7 @@ export default function App() {
|
||||
if (dirty && !confirm(t("unsavedConfirm"))) {
|
||||
return;
|
||||
}
|
||||
refresh();
|
||||
void reloadFromDisk();
|
||||
}}
|
||||
disabled={loading}
|
||||
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:opacity-50"
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
withMaxOutputSize,
|
||||
} from "../lib/model-max-output";
|
||||
import { capabilitiesFromRef, getModelRef, modelsDevReady } from "../lib/models-dev";
|
||||
import { validateProviderName } from "../lib/validation";
|
||||
import { getIconMetadata } from "../icons/extracted/metadata";
|
||||
import { KimiOAuthDialog } from "./KimiOAuthDialog";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
@@ -87,6 +88,15 @@ function defaultBaseUrl(agent: Agent, type: ProviderType): string {
|
||||
interface ProviderEditProps {
|
||||
agent: Agent;
|
||||
provider: Provider;
|
||||
/** Names of every OTHER provider. Used to flag a colliding name as the
|
||||
* user types; the enforced gates live in App.tsx (write path + trySave),
|
||||
* because Ctrl+S is a window-level listener that bypasses this form. */
|
||||
allProviderNames: string[];
|
||||
/** Set while App holds a displaced entry: the name collides exactly with
|
||||
* another provider, a case `allProviderNames` cannot see (the edited
|
||||
* entry has already been re-keyed onto the colliding name and is
|
||||
* excluded there). */
|
||||
nameConflict?: boolean;
|
||||
models: Model[];
|
||||
defaultModel: string | null;
|
||||
onBack: () => void;
|
||||
@@ -104,6 +114,8 @@ interface ProviderEditProps {
|
||||
export function ProviderEdit({
|
||||
agent,
|
||||
provider,
|
||||
allProviderNames,
|
||||
nameConflict,
|
||||
models,
|
||||
defaultModel,
|
||||
onBack,
|
||||
@@ -129,6 +141,14 @@ export function ProviderEdit({
|
||||
const [showIconPicker, setShowIconPicker] = useState(false);
|
||||
const [kimiOAuthOpen, setKimiOAuthOpen] = useState(false);
|
||||
|
||||
// Display layer only. The enforced gates are the write path in App.tsx
|
||||
// (which stashes whatever a collision overwrites) and trySave; Ctrl+S is a
|
||||
// window-level listener, so a disabled button here cannot stop a save.
|
||||
const nameIssue = validateProviderName(allProviderNames, provider.name);
|
||||
// An exact collision is invisible to the check above (see `nameConflict`),
|
||||
// so the stash flag has to widen the highlight too.
|
||||
const nameError = nameIssue != null || nameConflict === true;
|
||||
|
||||
// Preset this provider was created from (if any) — provides the
|
||||
// "Get API Key" / referral links shown under the key input.
|
||||
const preset = findPresetForProvider(provider);
|
||||
@@ -170,7 +190,8 @@ export function ProviderEdit({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
className="px-3 py-1.5 text-sm bg-green-600 hover:bg-green-700 text-white rounded focus:ring-2 focus:ring-green-500 focus:outline-none"
|
||||
disabled={nameError}
|
||||
className="px-3 py-1.5 text-sm bg-green-600 hover:bg-green-700 text-white rounded focus:ring-2 focus:ring-green-500 focus:outline-none disabled:opacity-50"
|
||||
>
|
||||
{t("saveConfig")}
|
||||
</button>
|
||||
@@ -248,10 +269,21 @@ export function ProviderEdit({
|
||||
</label>
|
||||
<input
|
||||
id={nameId}
|
||||
className="w-full bg-input border border-border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
className={`w-full bg-input border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:outline-none ${
|
||||
nameError
|
||||
? "border-red-500 focus:ring-red-500"
|
||||
: "border-border focus:ring-blue-500"
|
||||
}`}
|
||||
value={provider.name}
|
||||
onChange={(e) => onChange({ ...provider, name: e.target.value })}
|
||||
/>
|
||||
{nameError && (
|
||||
<p className="mt-1.5 text-sm text-red-400">
|
||||
{nameIssue === "empty"
|
||||
? t("validationNameEmpty")
|
||||
: t("validationNameDuplicate")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={noteId} className="block text-sm text-content-muted mb-1.5">
|
||||
|
||||
@@ -17,6 +17,12 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
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.)",
|
||||
validationNameEmpty: "Provider name is required",
|
||||
validationNameDuplicate: "Name conflicts with another provider",
|
||||
saveBlockedNameConflict: "Save blocked: duplicate or empty provider names: {names}",
|
||||
applyJsonNameConflict: "Name in JSON conflicts with another provider; not applied",
|
||||
saveBlockedDisplaced:
|
||||
"Save blocked: a provider was overwritten by a name conflict during this edit; go back and discard changes, then retry",
|
||||
providerNotFound: "Provider not found",
|
||||
backToList: "Back to list",
|
||||
syncToOmp: "Sync to OMP",
|
||||
|
||||
@@ -15,6 +15,11 @@ export const zhTranslations = {
|
||||
validationBlankModels: "存在未填写 model id 的模型:{aliases}",
|
||||
saveValidationConfirm:
|
||||
"以下供应商尚未完成配置,仍要保存吗?\n\n{details}\n\n(点击「确定」仍会保存到 config.toml;点击「取消」可继续编辑)",
|
||||
validationNameEmpty: "供应商名称不能为空",
|
||||
validationNameDuplicate: "名称与其他供应商重复",
|
||||
saveBlockedNameConflict: "保存已阻止:以下供应商名称冲突或为空:{names}",
|
||||
applyJsonNameConflict: "JSON 中的名称与其他供应商重复,未应用",
|
||||
saveBlockedDisplaced: "保存已阻止:本次编辑曾发生同名覆盖,请返回并丢弃更改后重试",
|
||||
providerNotFound: "供应商不存在",
|
||||
backToList: "返回列表",
|
||||
syncToOmp: "同步到 OMP",
|
||||
|
||||
+898
-614
File diff suppressed because it is too large.
Load diff
+846
-582
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateProviderName } from "./validation";
|
||||
|
||||
describe("validateProviderName", () => {
|
||||
it("reports an empty name, including a whitespace-only one", () => {
|
||||
expect(validateProviderName(["TokenHub"], "")).toBe("empty");
|
||||
expect(validateProviderName(["TokenHub"], " ")).toBe("empty");
|
||||
expect(validateProviderName([], "")).toBe("empty");
|
||||
});
|
||||
|
||||
it("reports a duplicate when another provider already holds the name", () => {
|
||||
expect(validateProviderName(["TokenHub", "other"], "TokenHub")).toBe("duplicate");
|
||||
});
|
||||
|
||||
it("treats case-different names as distinct (case-sensitive exact match)", () => {
|
||||
expect(validateProviderName(["TokenHub"], "tokenhub")).toBe(null);
|
||||
expect(validateProviderName(["tokenhub"], "TokenHub")).toBe(null);
|
||||
});
|
||||
|
||||
it("trims the candidate name before comparing", () => {
|
||||
expect(validateProviderName(["TokenHub"], " TokenHub ")).toBe("duplicate");
|
||||
});
|
||||
|
||||
it("trims the existing names before comparing", () => {
|
||||
expect(validateProviderName([" Foo "], "Foo")).toBe("duplicate");
|
||||
});
|
||||
|
||||
it("accepts a unique name", () => {
|
||||
expect(validateProviderName(["TokenHub", "other"], "TokenHub-2")).toBe(null);
|
||||
expect(validateProviderName([], "TokenHub")).toBe(null);
|
||||
});
|
||||
|
||||
it("prefers 'empty' over 'duplicate' for a blank name", () => {
|
||||
expect(validateProviderName([" "], " ")).toBe("empty");
|
||||
});
|
||||
});
|
||||
@@ -87,3 +87,31 @@ export function validateProviders(
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export type ProviderNameIssue = "empty" | "duplicate";
|
||||
|
||||
/**
|
||||
* Check a single provider name against the names of all OTHER providers.
|
||||
* Providers are keyed by name in the config, so a name collision silently
|
||||
* overwrites the existing entry — this helper is the shared gate for both
|
||||
* the edit-page hint and the write-path rejection.
|
||||
*
|
||||
* Comparison is a case-sensitive exact match after trimming, on purpose:
|
||||
* the name IS the storage key, so `TokenHub` and `tokenhub` are two distinct
|
||||
* providers that can coexist. Normalizing (lowercasing, stripping inner
|
||||
* whitespace) would reject names the storage layer accepts, and would block
|
||||
* users who deliberately keep a case-variant copy of a provider.
|
||||
*
|
||||
* Returns "empty" for a blank name (checked first — a blank name has no
|
||||
* meaningful collision semantics), "duplicate" when `allNames` already holds
|
||||
* the trimmed name, and null otherwise.
|
||||
*/
|
||||
export function validateProviderName(
|
||||
allNames: string[], // names of all OTHER providers (current entry excluded)
|
||||
name: string, // the name being validated
|
||||
): ProviderNameIssue | null {
|
||||
const trimmed = name.trim();
|
||||
if (trimmed === "") return "empty";
|
||||
if (allNames.some((other) => other.trim() === trimmed)) return "duplicate";
|
||||
return null;
|
||||
}
|
||||
Reference in new issue
Block a user