Compare commits

..
2 Commits
Author SHA1 Message Date
KimiSwitch Dev b0ad97deb2 fix(providers): 撞名防护加固(多槽暂存/用量弹窗守卫/重载失败保暂存/trim 重名拦截/导航拦截)— bump to v0.8.7
Release / Version consistency (push) Waiting to run
2026-10-18 01:21:11 +08:00
KimiSwitch Dev 97422440e1 fix(providers): 供应商名称唯一性防护,修复同名静默覆盖导致 API Key 丢失 — bump to v0.8.6
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s
2026-10-16 17:23:56 +08:00
18 changed files with 8037 additions and 2400 deletions

No files matched your search

+29
View File
@@ -6,6 +6,35 @@
---
## v0.8.7 (2026-10-18)
撞名防护加固版:对 v0.8.6 防线的一轮对抗性审查后补齐六处缺口,三个高危路径(连续撞名丢第二个受害者、用量面板绕过守卫落盘、重载失败守卫自溃)全部关闭。
- **多槽暂存**:被撞名顶掉的条目原为单槽暂存,连续两次撞名时第二个受害者无记录可找回;现任意多次撞名的受害者都会被记住,改名 / 删除 / JSON 应用时逐一还原
- **用量面板保存守卫**:「用量配置」弹窗的静默保存此前不经过撞名校验,撞名态下一次保存即把丢失状态落盘;现与 Ctrl+S 共用同一组硬阻止检查
- **重载失败不清暂存**:仅重载成功时清理被顶掉条目的暂存,失败时守卫保持有效
- **trim 重名拦截**:`TokenHub` / `TokenHub ` 变体不再能经 Ctrl+S 落盘(落盘后编辑页会永久红框)
- **撞名态导航拦截**:撞名未解决时顶栏导航弹窗提示先解决;普通未保存修改的导航不变
- **`Object.hasOwn` 替换 `in`**:命名 `constructor` 等原型键名不再幽灵撞名;类型库 ES2020→ES2022(仅类型声明,编译目标不变)
- 零 Rust 改动、无迁移成本;`subagent-settings.ts` 的 4 处 `in` 留待后续
- models.dev 快照同步至 2026-10-17(8,994 模型,+102)
- 测试:前端 162 项(新增 5 项 trim 重名扫描);六条路径经三路对抗性审查复核
## 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)
设置界面重构:原来塞满所有选项的「设置」弹窗改为带返回头的全局设置页面,按「全局设置 / 高级设置 / 关于」分三页;同时修掉体检账单漏查与更新下载哑火两处问题。
+30
View File
@@ -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 项(本次无改动,全量回归通过)
+30
View File
@@ -0,0 +1,30 @@
# KimiSwitch v0.8.7
撞名防护加固版:v0.8.6 建立了「同名供应商不再静默丢失」的基本防线,本版是针对该防线的一轮**对抗性审查**后补齐的六处缺口——审查发现的三个高危路径(连续撞名丢第二个受害者、用量面板绕过守卫落盘、重载失败后守卫自溃)全部关闭。
## 高危缺口修复
- **连续撞名不再丢第二个受害者**:被顶掉条目的暂存保护原来是单槽——先撞 `TokenHub`、未解除又去编辑别的供应商再撞一次时,第二个被顶掉的条目没有暂存记录、无人能找回。现改为多槽暂存,任意多次撞名的受害者都会被记住并在改名/删除时逐一还原
- **用量面板不再绕过守卫**:供应商列表的「用量配置」弹窗保存走的是静默保存通道,此前不经过撞名校验——撞名态下打开它点一次保存,整份丢失状态的配置就直接落盘了。现与 Ctrl+S 共用同一组硬阻止检查(撞名未解除、空名、重名变体),命中即弹窗阻止
- **重载失败不再自溃守卫**:Ctrl+R / 重新加载失败时(如配置文件暂时不可读),旧逻辑会把「被顶掉条目」的暂存记录清空而内存里仍是撞名态——守卫形同虚设,随后一次保存即丢数据。现仅在重载**成功**时清理暂存
## 其余加固
- **重名变体拦截**:`TokenHub` 与 `TokenHub `(尾随空格)这类 trim 后同名、存储键不同的变体,编辑页会红框但 Ctrl+S 原来拦不住;落盘后两条目在编辑页永久红框。现保存闸门增加 trim 级重名扫描,这类状态不再能落盘
- **撞名态导航拦截**:撞名未解决时点击顶栏「供应商 / 仪表盘 / 会话 / 高级设置 / 设置」会弹窗提示先回编辑页解决,防止带着不完整暂存游走触发边角路径;普通未保存修改的导航体验完全不变
- **JSON 应用接入还原协议**:撞名暂存态下通过「配置 JSON」改名应用后,被顶掉的条目现在同样会自动还原,保存不再被永久阻断
- **原型链误命中修复**:存在性判断全部改用 `Object.hasOwn`,把供应商命名为 `constructor` 之类的原型键名不再产生幽灵撞名
- 顺带修复:编辑页红框判定与落盘闸门口径不一致(trim 对全等)、复制供应商循环取号的 `in` 判断同源问题
## 其他
- models.dev 快照同步至 2026-10-17(8,994 模型,较上版 +102)
- TypeScript 类型库从 ES2020 提升至 ES2022(仅为 `Object.hasOwn` 的类型声明;编译目标不变,运行时 WebView2 原生支持)
- 零 Rust 改动,存储层与 config.toml 格式完全不变,升级 / 回退无迁移成本
- `src/lib/subagent-settings.ts` 尚有 4 处 `in` 判断(不在撞名落盘路径上),留待后续独立修复
## 测试
- 前端 162 项(新增 5 项 trim 重名扫描:无重复 / 空白变体成对 / 多组重复排序 / 大小写不判重 / 空名不计入)
- 六条修复路径经对抗性审查复核:三路独立审查(算法 / React 状态 / 交付质量)发现的 8 项结论逐一核实,误报 2 项已驳回
- tsc / vite build / check-version 全部通过
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "kimiswitch",
"private": true,
"version": "0.8.5",
"version": "0.8.7",
"type": "module",
"scripts": {
"dev": "vite",
+2436 -768
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1978,7 +1978,7 @@ dependencies = [
[[package]]
name = "kimiswitch"
version = "0.8.5"
version = "0.8.7"
dependencies = [
"anyhow",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "kimiswitch"
version = "0.8.5"
version = "0.8.7"
description = "Kimi Switch - model config manager"
authors = ["codingplan.site"]
edition = "2021"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"productName": "Kimi Switch",
"version": "0.8.5",
"version": "0.8.7",
"identifier": "com.kimiswitch.app",
"build": {
"beforeDevCommand": "npm run dev",
+231 -47
View File
@@ -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";
@@ -19,7 +19,7 @@ import {
presetToProviderAndModels,
type ProviderPreset,
} from "./config/providerPresets";
import { validateProviders } from "./lib/validation";
import { findTrimDuplicateProviderNames, validateProviders } from "./lib/validation";
import {
copyModelsOnProviderDuplicate,
rekeyModelsOnProviderRename,
@@ -32,6 +32,15 @@ import type { Agent, Model, Provider, UsageConfig } from "./types";
const AGENT_STORAGE_KEY = "kimi-switch-agent";
type View =
| "list"
| "edit"
| "subagent"
| "dashboard"
| "sessions"
| "plugins"
| "settings";
function getProviderDefaultModel(provider: Provider): string | null {
const raw = provider.raw_other as Record<string, unknown> | undefined;
const value = raw?.default_model;
@@ -80,9 +89,7 @@ export default function App() {
updateConfig,
} = useConfig(agent);
const [view, setView] = useState<
"list" | "edit" | "subagent" | "dashboard" | "sessions" | "plugins" | "settings"
>("list");
const [view, setView] = useState<View>("list");
const [editingProvider, setEditingProvider] = useState<string>("");
const [loadTimeout, setLoadTimeout] = useState(false);
const [switchMessage, setSwitchMessage] = useState<string | null>(null);
@@ -99,7 +106,77 @@ export default function App() {
const [pendingNewProviders, setPendingNewProviders] = useState<Set<string>>(
() => new Set(),
);
// Providers an earlier name collision overwrote in memory, keyed by the
// provider name the colliding entry squatted on, held so they can be put
// back. A non-empty map means the in-memory config is missing entries, so
// trySave and the usage-config save hard-block on it; the rename / delete /
// JSON-apply paths restore whatever they free. Multiple slots are possible
// (rename A→B, then rename C→D without resolving the first) — a single slot
// would drop the older victim on the floor.
const displacedRef = useRef<Map<string, Provider>>(new Map());
// Put every stashed provider back whose key is free again (mutates the map
// it is given, and drops the restored slots). Must run AFTER a rename has
// moved the edited entry off the squatted key: while the edited entry still
// holds it, the key is present and nothing can be restored. Running it also
// empties the restored slots, so a second collision in a row can stash its
// own victim.
const restoreDisplaced = useCallback(
(providers: Record<string, Provider>) => {
const stash = displacedRef.current;
for (const [key, provider] of [...stash]) {
if (!Object.hasOwn(providers, key)) {
providers[key] = provider;
stash.delete(key);
}
}
},
[],
);
// The reasons a config must not reach disk at all, shared by every write
// path (trySave for whole-config saves, handleSaveUsageConfig for the
// usage panel's silent save). Both describe a config that loses data the
// moment it is persisted, so there is nothing here for the user to confirm.
// Returns the alert message, or null when nothing blocks the save.
const hardBlockMessage = useCallback((): string | null => {
if (!config) return null;
if (displacedRef.current.size > 0) {
return t("saveBlockedDisplaced");
}
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.
return t("saveBlockedNameConflict", {
names: blankNames.map((k) => JSON.stringify(k)).join(", "),
});
}
// Names that collide once trimmed ("TokenHub" vs "TokenHub ") are
// invisible to the blank-name check above and to the edit page's per-name
// hint (which excludes the entry being edited), but both are on disk and
// the edit page paints both red with no way out. Block them here.
const trimDuplicates = findTrimDuplicateProviderNames(config.providers);
if (trimDuplicates.length > 0) {
return t("saveBlockedNameConflict", {
names: trimDuplicates.map((k) => JSON.stringify(k)).join(", "),
});
}
return null;
}, [config, t]);
// 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 on a *successful* reload keeps a failed one
// (config unchanged, still collided) from looking clean to trySave.
const reloadFromDisk = useCallback(async () => {
if (await refresh()) {
displacedRef.current.clear();
}
}, [refresh]);
useEffect(() => {
if (!loading) {
@@ -163,13 +240,19 @@ export default function App() {
// provider being switched to — only that one needs to be
// complete; sibling providers being merely listed in the config
// must not block the switch.
// 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).
// Returns `true` when the config was actually persisted, `false` when a
// hard block (see hardBlockMessage) or the validation confirm stopped it
// — 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 blocked = hardBlockMessage();
if (blocked) {
alert(blocked);
return false;
}
const issues = validateProviders(config, config.models, tGeneric, target);
if (issues.length > 0) {
const summary = issues
@@ -186,7 +269,7 @@ export default function App() {
setPendingNewProviders(new Set());
return true;
},
[config, dirty, save, t],
[config, dirty, hardBlockMessage, save, t],
);
// Keyboard shortcuts: Ctrl+S save, Ctrl+R reload, Ctrl+O open config dir
@@ -203,7 +286,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 +299,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 +311,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 (Object.hasOwn(config.providers, name)) {
n++;
name = `provider-${n}`;
}
const defaultType = agent === "kimi_code" ? "kimi" : "openai";
updateConfig((cfg) => ({
...cfg,
@@ -292,6 +383,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 && Object.hasOwn(cfg.providers, provider.name)
? 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 +418,31 @@ 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 every entry an earlier collision displaced whose key is free
// again. 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 nothing can be restored. Running it before
// the stashing below also drops the restored slots, so a second
// collision in a row can stash its own victim instead of being skipped.
restoreDisplaced(providers);
// 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.
//
// One slot per squatted name, and the first snapshot wins: re-colliding
// on the same name overwrites that slot's squatter again, so the
// original victim — the only copy of it — must not be replaced.
if (displacedKey && !displacedRef.current.has(displacedKey)) {
displacedRef.current.set(displacedKey, cfg.providers[displacedKey]);
}
return next;
});
};
@@ -329,6 +451,11 @@ export default function App() {
updateConfig((cfg) => {
const providers = { ...cfg.providers };
delete providers[name];
// The stash holds providers an earlier collision overwrote in memory.
// Deleting frees the keys they were squatting on, so they can go back
// instead of staying stranded — a stranded stash blocks every later
// save until the user discards.
restoreDisplaced(providers);
const models = { ...cfg.models };
const removedAliases: string[] = [];
for (const key of Object.keys(models)) {
@@ -364,6 +491,18 @@ export default function App() {
},
};
});
// Same hard blocks as trySave: this save is silent (no loading screen, no
// completeness confirm), but "silent" must not mean "unchecked" — a
// collided or blank-named config would otherwise reach disk through the
// usage panel, which the whole-config save path refuses to do.
//
// Returns without throwing on purpose: the panel's test flow awaits this
// and would report a rejection as a network error.
const blocked = hardBlockMessage();
if (blocked) {
alert(blocked);
return Promise.resolve();
}
// Silent save: a normal save flips App to its loading screen, which
// unmounts this panel and swallows the in-flight test query's result.
// Returned so the panel's test can await persistence before querying.
@@ -371,19 +510,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 +540,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 +613,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 +640,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 && Object.hasOwn(config.providers, provider.name)) {
alert(t("applyJsonNameConflict"));
return;
}
updateConfig((cfg) => {
const providers = { ...cfg.providers };
const oldName = editingProvider;
@@ -525,7 +682,7 @@ export default function App() {
for (const m of models) {
let alias = m.alias;
let n = 1;
while (alias in updatedModels) {
while (Object.hasOwn(updatedModels, alias)) {
alias = `${m.alias}-${n}`;
n++;
}
@@ -541,7 +698,7 @@ export default function App() {
}
let default_model = cfg.default_model;
if (default_model && !(default_model in updatedModels)) {
if (default_model && !Object.hasOwn(updatedModels, default_model)) {
default_model = null;
}
@@ -549,7 +706,8 @@ export default function App() {
// (provider renamed, models dropped) must not survive as zombies.
const removedAliases = Object.keys(cfg.models).filter(
(key) =>
cfg.models[key].provider === oldName && !(key in updatedModels)
cfg.models[key].provider === oldName &&
!Object.hasOwn(updatedModels, key)
);
const raw_other = purgeSubagentPoolAliases(cfg.raw_other, removedAliases);
@@ -560,6 +718,13 @@ export default function App() {
providers[provider.name] = setProviderDefaultModel(updatedProvider, default_model);
}
// Applying JSON is another way to move the edited entry off a name it
// squatted on, so the same restore applies: without it a stash left
// over from an earlier collision would be stranded, and every later
// save hard-blocked forever (the panel cannot be left with the stash
// intact — reloading clears it only via an explicit discard).
restoreDisplaced(providers);
return { ...cfg, providers, models: updatedModels, default_model, raw_other };
});
if (provider.name !== editingProvider) {
@@ -581,7 +746,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>
@@ -593,13 +758,27 @@ export default function App() {
const currentProvider = config.providers[editingProvider];
// Top-bar navigation guard. Only an *unresolved* collision blocks it: the
// stash is the one state the user can only fix from the provider edit page
// (rename away, delete the squatter, or discard), so wandering off to the
// dashboard leaves a config that refuses every save. Plain unsaved edits
// deliberately keep navigating — that flow is unchanged.
const guardedSetView = (next: View, prepare?: () => void) => {
if (displacedRef.current.size > 0) {
alert(t("saveBlockedDisplaced"));
return;
}
prepare?.();
setView(next);
};
return (
<div className="flex flex-col h-full bg-app text-content-primary">
<header className="flex items-center justify-between gap-3 px-4 py-3 border-b border-border bg-panel">
<div className="flex items-center bg-input border border-border rounded p-0.5">
<button
type="button"
onClick={() => setView("list")}
onClick={() => guardedSetView("list")}
className={`px-3 py-1 text-sm rounded transition-colors ${
view === "list" || view === "edit" || view === "subagent"
? "bg-blue-600 text-white"
@@ -610,7 +789,7 @@ export default function App() {
</button>
<button
type="button"
onClick={() => setView("dashboard")}
onClick={() => guardedSetView("dashboard")}
className={`px-3 py-1 text-sm rounded transition-colors ${
view === "dashboard"
? "bg-blue-600 text-white"
@@ -621,7 +800,7 @@ export default function App() {
</button>
<button
type="button"
onClick={() => setView("sessions")}
onClick={() => guardedSetView("sessions")}
className={`px-3 py-1 text-sm rounded transition-colors ${
view === "sessions"
? "bg-blue-600 text-white"
@@ -632,10 +811,7 @@ export default function App() {
</button>
<button
type="button"
onClick={() => {
setSettingsTab("advanced");
setView("settings");
}}
onClick={() => guardedSetView("settings", () => setSettingsTab("advanced"))}
className={`px-3 py-1 text-sm rounded transition-colors ${
view === "settings" && settingsTab === "advanced"
? "bg-blue-600 text-white"
@@ -667,10 +843,7 @@ export default function App() {
{/* Settings gear */}
<button
type="button"
onClick={() => {
setSettingsTab("global");
setView("settings");
}}
onClick={() => guardedSetView("settings", () => setSettingsTab("global"))}
title={t("settings")}
className="relative p-1.5 rounded border border-border hover:bg-hover-2 transition-colors"
>
@@ -746,9 +919,20 @@ 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 fills the stash before React re-renders, so this
// pass already sees the new value (same precedent as
// setEditingProvider inside that updater). Only the entry that is
// currently squatting on a stashed name gets the red border —
// another slot elsewhere in the table is not this form's problem.
<ProviderEdit
agent={agent}
provider={currentProvider}
allProviderNames={Object.keys(config.providers).filter(
(k) => k !== editingProvider
)}
nameConflict={displacedRef.current.has(editingProvider)}
models={Object.values(config.models).filter(
(m) => m.provider === currentProvider.name
)}
@@ -778,7 +962,7 @@ export default function App() {
(tail === "新模型" ||
tail.startsWith("新模型-") ||
(tail !== "" && modelId.startsWith(tail))) &&
!(targetAlias in models)
!Object.hasOwn(models, targetAlias)
) {
effective = { ...model, alias: targetAlias };
delete models[model.alias];
@@ -830,7 +1014,7 @@ export default function App() {
// silently overwrite it.
let alias = `${safeProvider}/新模型`;
let n = 1;
while (alias in cfg.models) {
while (Object.hasOwn(cfg.models, alias)) {
alias = `${safeProvider}/新模型-${++n}`;
}
return {
@@ -861,7 +1045,7 @@ export default function App() {
if (dup) continue;
let alias = m.alias;
let n = 1;
while (alias in updated) {
while (Object.hasOwn(updated, alias)) {
alias = `${m.alias}-${n}`;
n++;
}
@@ -914,7 +1098,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"
+35 -2
View File
@@ -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,16 @@ 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 *for this provider's name*: 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). A stash slot for some other name
* must not paint this form red. */
nameConflict?: boolean;
models: Model[];
defaultModel: string | null;
onBack: () => void;
@@ -104,6 +115,8 @@ interface ProviderEditProps {
export function ProviderEdit({
agent,
provider,
allProviderNames,
nameConflict,
models,
defaultModel,
onBack,
@@ -129,6 +142,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 +191,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 +270,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">
+9 -2
View File
@@ -8,7 +8,12 @@ interface UseConfigReturn {
dirty: boolean;
error: string | null;
loading: boolean;
refresh: (opts?: { silent?: boolean }) => Promise<void>;
// Resolves true when the config was actually re-read from disk, false when
// the load failed (the error is also surfaced via `error`). Callers that
// discard in-memory state as a consequence of reloading must gate on this:
// a failed reload leaves the old in-memory config — and whatever it is
// missing — untouched.
refresh: (opts?: { silent?: boolean }) => Promise<boolean>;
save: (opts?: { silent?: boolean }) => Promise<void>;
updateConfig: (updater: (config: Config) => Config) => void;
}
@@ -25,7 +30,7 @@ export function useConfig(agent: Agent): UseConfigReturn {
configRef.current = config;
}, [config]);
const refresh = useCallback(async (opts?: { silent?: boolean }) => {
const refresh = useCallback(async (opts?: { silent?: boolean }): Promise<boolean> => {
// silent: skip the loading flip — App returns a bare loading screen while
// `loading` is true, which would unmount any open panel (and swallow its
// in-flight async results).
@@ -37,10 +42,12 @@ export function useConfig(agent: Agent): UseConfigReturn {
});
setConfig(loadedConfig);
setDirty(false);
return true;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
console.error("useConfig refresh failed:", message);
return false;
} finally {
if (!opts?.silent) setLoading(false);
}
+6
View File
@@ -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",
+5
View File
@@ -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",
+2631 -807
View File
File diff suppressed because it is too large. Load diff
+2436 -768
View File
File diff suppressed because it is too large. Load diff
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { findTrimDuplicateProviderNames, validateProviderName } from "./validation";
import type { Provider } from "../types";
/** Minimal provider entry — `findTrimDuplicateProviderNames` only reads keys. */
function provider(name: string): Provider {
return {
name,
provider_type: "openai",
base_url: null,
api_key: null,
env: {},
};
}
function mapOf(...names: string[]): Record<string, Provider> {
return Object.fromEntries(names.map((name) => [name, provider(name)]));
}
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");
});
});
describe("findTrimDuplicateProviderNames", () => {
it("returns nothing when every name is distinct", () => {
expect(findTrimDuplicateProviderNames(mapOf("TokenHub", "other", "third"))).toEqual(
[],
);
expect(findTrimDuplicateProviderNames({})).toEqual([]);
});
it("flags a pair that only differs by surrounding whitespace", () => {
expect(findTrimDuplicateProviderNames(mapOf("TokenHub", "TokenHub "))).toEqual([
"TokenHub",
"TokenHub ",
]);
expect(findTrimDuplicateProviderNames(mapOf(" TokenHub", "TokenHub "))).toEqual([
" TokenHub",
"TokenHub ",
]);
});
it("reports every member of every colliding group, sorted", () => {
expect(
findTrimDuplicateProviderNames(mapOf("b", " b", "b ", "a", "a ", "solo")),
).toEqual([" b", "a", "a ", "b", "b "]);
});
it("does not treat case-different names as duplicates", () => {
expect(findTrimDuplicateProviderNames(mapOf("TokenHub", "tokenhub"))).toEqual([]);
});
it("ignores blank names (reported by the empty-name gate instead)", () => {
expect(findTrimDuplicateProviderNames(mapOf("", " ", "TokenHub"))).toEqual([]);
// A blank name must not drag a real name into the duplicate set either.
expect(findTrimDuplicateProviderNames(mapOf("TokenHub", " "))).toEqual([]);
});
});
+63 -1
View File
@@ -1,4 +1,4 @@
import type { Config, Model, ProviderType } from "../types";
import type { Config, Model, Provider, ProviderType } from "../types";
/**
* Provider types that must have a base_url set in order to function.
@@ -87,3 +87,65 @@ 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;
}
/**
* Scan a whole provider map for names that are duplicates once trimmed.
*
* `validateProviderName` applies exactly this comparison to the single name
* the user is typing, but the map itself may already hold two entries that
* only differ by surrounding whitespace (`"TokenHub"` and `"TokenHub "`).
* The name input excludes the entry being edited from its comparison list, so
* such a pair is invisible in the UI while both are persisted — the edit page
* then shows a permanent red border with nothing the user can do about it
* except rename one of them.
*
* Returns the offending original keys (NOT the trimmed names — the caller
* keys its lookups on the map's own keys), sorted for stable assertions.
* Blank names are excluded: they cannot collide semantically and are reported
* by their own empty-name gate.
*/
export function findTrimDuplicateProviderNames(
providers: Record<string, Provider>,
): string[] {
const keysByTrimmedName = new Map<string, string[]>();
for (const key of Object.keys(providers)) {
const trimmed = key.trim();
if (trimmed === "") continue;
const keys = keysByTrimmedName.get(trimmed);
if (keys) keys.push(key);
else keysByTrimmedName.set(trimmed, [key]);
}
const duplicates: string[] = [];
for (const keys of keysByTrimmedName.values()) {
if (keys.length > 1) duplicates.push(...keys);
}
return duplicates.sort();
}
+4 -1
View File
@@ -2,7 +2,10 @@
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
// ES2022 for Object.hasOwn: the code base uses it to test map membership
// without walking the prototype chain. `target` stays at ES2020 — the API
// is a runtime one, and every webview Tauri 2 supports has it.
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",