Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0ad97deb2 |
No files matched your search
@@ -6,6 +6,20 @@
|
||||
|
||||
---
|
||||
|
||||
## 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 丢失的问题。
|
||||
|
||||
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "kimiswitch",
|
||||
"private": true,
|
||||
"version": "0.8.6",
|
||||
"version": "0.8.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+1714
-310
File diff suppressed because it is too large.
Load diff
Generated
+1
-1
@@ -1978,7 +1978,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kimiswitch"
|
||||
version = "0.8.6"
|
||||
version = "0.8.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "kimiswitch"
|
||||
version = "0.8.6"
|
||||
version = "0.8.7"
|
||||
description = "Kimi Switch - model config manager"
|
||||
authors = ["codingplan.site"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"productName": "Kimi Switch",
|
||||
"version": "0.8.6",
|
||||
"version": "0.8.7",
|
||||
"identifier": "com.kimiswitch.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
+156
-85
@@ -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,19 +106,76 @@ 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);
|
||||
// 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 afterwards keeps a *failed* reload (config
|
||||
// unchanged, still collided) from looking clean to trySave.
|
||||
// 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 () => {
|
||||
await refresh();
|
||||
displacedRef.current = null;
|
||||
if (await refresh()) {
|
||||
displacedRef.current.clear();
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -176,31 +240,17 @@ 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) {
|
||||
// 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(", "),
|
||||
})
|
||||
);
|
||||
const blocked = hardBlockMessage();
|
||||
if (blocked) {
|
||||
alert(blocked);
|
||||
return false;
|
||||
}
|
||||
const issues = validateProviders(config, config.models, tGeneric, target);
|
||||
@@ -219,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
|
||||
@@ -266,7 +316,7 @@ export default function App() {
|
||||
// overwrite an existing provider before the user types anything.
|
||||
let n = providers.length + 1;
|
||||
let name = `provider-${n}`;
|
||||
while (name in config.providers) {
|
||||
while (Object.hasOwn(config.providers, name)) {
|
||||
n++;
|
||||
name = `provider-${n}`;
|
||||
}
|
||||
@@ -337,7 +387,7 @@ export default function App() {
|
||||
// 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 !== oldKey && Object.hasOwn(cfg.providers, provider.name)
|
||||
? provider.name
|
||||
: null;
|
||||
// Both branches assign `next`; the initial value is only there so
|
||||
@@ -373,30 +423,24 @@ export default function App() {
|
||||
providers[provider.name] = provider;
|
||||
next = { ...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;
|
||||
}
|
||||
// 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.
|
||||
if (displacedKey && displacedRef.current == null) {
|
||||
displacedRef.current = {
|
||||
key: displacedKey,
|
||||
provider: cfg.providers[displacedKey],
|
||||
};
|
||||
//
|
||||
// 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;
|
||||
});
|
||||
@@ -407,14 +451,11 @@ 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;
|
||||
}
|
||||
// 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)) {
|
||||
@@ -450,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.
|
||||
@@ -606,7 +659,7 @@ export default function App() {
|
||||
alert(t("validationNameEmpty"));
|
||||
return;
|
||||
}
|
||||
if (provider.name !== editingProvider && provider.name in config.providers) {
|
||||
if (provider.name !== editingProvider && Object.hasOwn(config.providers, provider.name)) {
|
||||
alert(t("applyJsonNameConflict"));
|
||||
return;
|
||||
}
|
||||
@@ -629,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++;
|
||||
}
|
||||
@@ -645,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;
|
||||
}
|
||||
|
||||
@@ -653,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);
|
||||
|
||||
@@ -664,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) {
|
||||
@@ -697,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"
|
||||
@@ -714,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"
|
||||
@@ -725,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"
|
||||
@@ -736,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"
|
||||
@@ -771,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"
|
||||
>
|
||||
@@ -852,16 +921,18 @@ export default function App() {
|
||||
) : 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).
|
||||
// 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 != null}
|
||||
nameConflict={displacedRef.current.has(editingProvider)}
|
||||
models={Object.values(config.models).filter(
|
||||
(m) => m.provider === currentProvider.name
|
||||
)}
|
||||
@@ -891,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];
|
||||
@@ -943,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 {
|
||||
@@ -974,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++;
|
||||
}
|
||||
|
||||
@@ -92,10 +92,11 @@ interface ProviderEditProps {
|
||||
* 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). */
|
||||
/** 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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+1857
-317
File diff suppressed because it is too large.
Load diff
+1714
-310
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateProviderName } from "./validation";
|
||||
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", () => {
|
||||
@@ -34,3 +50,39 @@ describe("validateProviderName", () => {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
+35
-1
@@ -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.
|
||||
@@ -115,3 +115,37 @@ export function validateProviderName(
|
||||
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
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user