diff --git a/CHANGELOG.md b/CHANGELOG.md index 91e9f74..ee18a50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ --- +## v0.8.5 (2026-10-16) + +设置界面重构:原来塞满所有选项的「设置」弹窗改为带返回头的全局设置页面,按「全局设置 / 高级设置 / 关于」分三页;同时修掉体检账单漏查与更新下载哑火两处问题。 + +- **设置弹窗改为全局设置页面**:`SettingsModal` 已删除,改由 `SettingsPage` 承载——无返回头,与「供应商 / 仪表盘 / 会话」平级,由顶部导航组直接切换,宽度与上下文比弹窗充裕得多 +- **三个 Tab**:**全局设置**(写回 `config.toml` 的代理设置:思考开关与五档等级、循环控制、权限规则、钩子)、**高级设置**(KimiWebUI、models.dev 参考数据、子代理模型池、实验功能)、**关于**(外观三态、语言、更新检查、kimi-code 版本核验、关于信息) +- **「高级设置」入口移入顶部导航组**:此前藏在页面底部 footer,现在与「供应商 / 仪表盘 / 会话」并排 +- **代理设置单一入口**:供应商编辑页不再内嵌代理设置,避免两处各写一半 +- **旧 `view=subagent` 路由保留为兼容外壳**;非活动 Tab 只隐藏不卸载,更新下载进度在切 Tab 间不丢 +- **一键体检账单缺口修复**:仅配置了 NewAPI / Sub2API 查询模板(`usageKinds` 为空)的供应商,体检时账单项不再被静默跳过——这两条模板路径直接查网关自己的用量端点,本就不走 `usageKinds`;面板里主动关闭用量查询时仍不查 +- **更新下载修复**:release 无当前平台安装包资产时,「下载」按钮回退为打开 release 页面而不是点了没反应;自动更新显式过滤 draft 与 prerelease 版本,不再把未发布 / 预发布版本当成更新推送 +- **关于区新增 OpenTerminal 推荐链接**(`billowliu2.github.io/OpenTerminal`) +- **启动瞬间色差修复**:CSS 变量默认深色而 Tailwind `dark:` 变体要等 React 挂载才生效,启动瞬间激活供应商卡片会先闪浅色;`index.html` 首帧前内联脚本即设置主题 class +- models.dev 快照同步至 2026-10-15(8,873 模型 / 230 供应商) +- 测试:前端 150 项、Rust 212 项(新增 7 项:网关模板应查 / 面板关闭不查 / 空 kinds 不查、draft 与 prerelease 过滤、全稳定取最新、空与非数组不 panic) + ## v0.8.4 (2026-10-15) 用量查询增强:NewAPI 中转站现在同时展示**订阅额度占比**与**钱包余额**两套数据,并修掉配置面板把钱包金额误算成百分比的问题。 diff --git a/README.md b/README.md index e4b1d37..0fcb613 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ │ │ ProviderList │ │ ├── main.rs │ │ │ │ ProviderEdit │ │ ├── commands.rs │ │ │ │ AgentSettingsPanel │ │ ├── db.rs │ │ -│ │ SettingsModal │ │ ├── kimi_code_io.rs │ │ +│ │ SettingsPage │ │ ├── kimi_code_io.rs │ │ │ │ ProviderIcon / │ │ ├── pi_io.rs (legacy) │ │ │ │ IconPicker │ │ ├── config_io.rs │ │ │ │ dashboard/ │ │ ├── models.rs │ │ @@ -334,7 +334,11 @@ npm run dev │ │ ├── ProviderList.tsx # 供应商列表 + 切换 / 复制 / 测试连通 / 编辑 / 删除 │ │ ├── ProviderEdit.tsx # 供应商编辑 + 模型映射 + JSON 直编 + 能力 │ │ ├── AgentSettingsPanel.tsx # Kimi Code 全局设置(思考/循环/权限/钩子) -│ │ ├── SettingsModal.tsx # 设置弹窗(主题 / 语言 / 版本 / 检查更新) +│ │ ├── settings/ # 设置页(返回头 + 设置/高级功能 Tab) +│ │ │ ├── SettingsPage.tsx +│ │ │ ├── SettingsGeneralTab.tsx +│ │ │ ├── SettingsGlobalTab.tsx +│ │ │ └── SettingsAdvancedTab.tsx │ │ ├── ProviderIcon.tsx # 供商品牌图标(带首字母兜底) │ │ ├── IconPicker.tsx # 图标选择器(brands / inference 分类) │ │ ├── dashboard/ # 用量仪表盘 @@ -454,7 +458,7 @@ npm run dev - 翻译源:`src/i18n/zh.ts`(源) + `src/i18n/en.ts`(目标) - 新增 key **先在 `zh.ts` 里加**,`en.ts` 的 `Record` 类型会自动校验缺失条目(编译时报错) - `useTranslation` hook 暴露 `{ t, lang, setLang }` -- 运行时切换由 `SettingsModal` 提供,持久化到前端 `localStorage`(`kimi-switch-lang`) +- 运行时切换在设置页「设置」Tab 中提供,持久化到前端 `localStorage`(`kimi-switch-lang`) ## 测试 diff --git a/README_EN.md b/README_EN.md index 3ed89e0..1990866 100644 --- a/README_EN.md +++ b/README_EN.md @@ -133,7 +133,7 @@ Per-workspace Kimi Code session browsing, active / archived / all filters; strea │ │ ProviderList │ │ ├── main.rs │ │ │ │ ProviderEdit │ │ ├── commands.rs │ │ │ │ AgentSettingsPanel │ │ ├── db.rs │ │ -│ │ SettingsModal │ │ ├── kimi_code_io.rs │ │ +│ │ SettingsPage │ │ ├── kimi_code_io.rs │ │ │ │ ProviderIcon / │ │ ├── pi_io.rs (legacy) │ │ │ │ IconPicker │ │ ├── config_io.rs │ │ │ │ dashboard/ │ │ ├── models.rs │ │ @@ -324,7 +324,11 @@ Good for pure UI debugging. │ │ ├── ProviderList.tsx # Provider list + switch / duplicate / test / edit / delete │ │ ├── ProviderEdit.tsx # Edit provider + model mapping + raw JSON + capabilities │ │ ├── AgentSettingsPanel.tsx # Kimi Code global settings (thinking / loop / permissions / hooks) -│ │ ├── SettingsModal.tsx # Settings modal (theme / language / version / update check) +│ │ ├── settings/ # Settings page (back header + Settings / Advanced tabs) +│ │ │ ├── SettingsPage.tsx +│ │ │ ├── SettingsGeneralTab.tsx +│ │ │ ├── SettingsGlobalTab.tsx +│ │ │ └── SettingsAdvancedTab.tsx │ │ ├── ProviderIcon.tsx # Provider brand icons (with initial-letter fallback) │ │ ├── IconPicker.tsx # Icon picker (brands / inference groups) │ │ ├── dashboard/ # Usage dashboard @@ -444,7 +448,7 @@ Good for pure UI debugging. - Sources: `src/i18n/zh.ts` (source) + `src/i18n/en.ts` (target) - When adding a key, **add it to `zh.ts` first** — the `Record` type in `en.ts` will flag missing entries at compile time - `useTranslation` hook exposes `{ t, lang, setLang }` -- Runtime switch is provided by `SettingsModal`; persisted to frontend `localStorage` (`kimi-switch-lang`) +- Runtime switch lives in the settings page's "Settings" tab; persisted to frontend `localStorage` (`kimi-switch-lang`) ## Testing diff --git a/docs/PROPOSAL-settings-global-page.md b/docs/PROPOSAL-settings-global-page.md new file mode 100644 index 0000000..c51c723 --- /dev/null +++ b/docs/PROPOSAL-settings-global-page.md @@ -0,0 +1,186 @@ +# 提案:设置改为全局页面(设置 + 高级功能 合一) + +状态:**待评审**(本文档为主代理自查 + 交叉审核用,未开始实现) +日期:2026-10-15 + +--- + +## 1. 目标 + +取消「设置」弹窗,改为带返回按钮的全局页面;三个 Tab 从同一入口打开同一页面(顺序 **全局设置 | 高级设置 | 关于**): + +- **全局设置 Tab**:全局默认模型 / 供应商级别的默认项 +- **高级设置 Tab**(原「高级设置」页内容 + 代理设置):代理设置(thinking/循环控制/权限规则/hooks 等)、Kimi Code WebUI 快速打开、models.dev 模型数据、子代理副模型/模型池、实验开关 +- **关于 Tab**(id 仍为 `general`,原 `SettingsModal` 内容):外观(主题/语言)、更新(版本/检查/下载/安装)、Kimi Code CLI 版本与兼容性、关于 + +已确认的三个决策点: + +| 决策 | 选择 | 含义 | +| --- | --- | --- | +| A | 1 | `AgentSettingsPanel` 从供应商编辑页(`ProviderEdit.tsx`)**移除**,只保留在设置页高级 Tab —— 单一入口 | +| B | 2 | **保留**旧 `view === "subagent"` 渲染分支作为兼容外壳,不删除 | +| C | 1 | 所有 section **抽成独立可复用组件**,不复制内容,避免两处分叉 | + +## 2. 现状核实(已完成调研,行号为当前 main 上的位置) + +| 文件 | 关键事实 | +| --- | --- | +| `src/App.tsx:83-85` | `view` 类型:`"list" \| "edit" \| "subagent" \| "dashboard" \| "sessions" \| "plugins"`,需新增 `"settings"` | +| `src/App.tsx:89` | `showSettings` state(改造后删除) | +| `src/App.tsx:600-638` | 顶部导航(供应商/数据看板/会话),**无 dirty 拦截**,直接 `setView` | +| `src/App.tsx:652-665` | 齿轮按钮 → `setShowSettings(true)`;含「有更新」绿点徽标(保留) | +| `src/App.tsx:682-696` | 渲染分支:`subagent` → ``(保留,即决策 B) | +| `src/App.tsx:697-724` | `ProviderEdit` 入参含 `rawOther` / `onRawOtherChange` / `defaultModel` | +| `src/App.tsx:906-914` | footer「高级设置」按钮(仅 `view === "list"` 显示)→ `setView("subagent")`,改为打开设置页高级 Tab | +| `src/App.tsx:919-926` | `` 挂载(改造后删除) | +| `src/components/SettingsModal.tsx`(589 行) | 4 个 section:外观 `:257`、更新 `:302`、Kimi Code `:434`、关于 `:500`;弹窗专属:`createPortal`、`fixed inset-0` 蒙层、Esc 关闭、`body.overflow` 锁定;`handleDownload`(`:103-130`)含「无安装包 → 打开 release 页」回退(**近期 bug 修复,必须逐字保留**);全仓库仅 `App.tsx` 引用 | +| `src/components/SubagentSettingsPage.tsx`(710 行) | props `{ rawOther, models, onChange, onBack }`;返回头 `:402-414`,内容区 `:416-707`(masterOn 提示条 + WebUI Card + models.dev Card + 副模型/模型池 Card + 实验开关 Card);仅 `App.tsx` 引用 | +| `src/components/AgentSettingsPanel.tsx`(451 行) | props `{ rawOther, onChange, models?, defaultModel? }`;读写全局 `config.raw_other`;自带 `mt-6` 外壳和 `agentSettings` 小标题,页面内可直接复用,**该文件本身不改**;目前唯一挂载点 `ProviderEdit.tsx:478-484`(`agent === "kimi_code"` 时) | +| `src/i18n/zh.ts` / `en.ts` | 已有:`settings`、`settingsTitle`、`settingsAppearance`、`settingsUpdates`、`settingsAbout`、`subagentSettings`、`back`;三个 Tab 直接复用 `settingsTabGlobal` / `subagentSettings` / `settingsAbout` | + +数据流结论(合并无数据层改动):`AgentSettingsPanel`、`SubagentSettingsPage` 都只读写全局 `config.raw_other`,`App.tsx` 已有现成回调 `onRawOtherChange={(next) => updateConfig((cfg) => ({ ...cfg, raw_other: next }))}`(`App.tsx:722-724`)。 + +## 3. 目标结构 + +``` +src/components/settings/ +├── SettingsPage.tsx 新增:返回头 + Tab 切换 + 内容容器(本次唯一的"页面") +├── SettingsGeneralTab.tsx 新增:SettingsModal 的 4 个 section,去掉弹窗外壳 +└── SettingsAdvancedTab.tsx 新增:AgentSettingsPanel + SubagentSettingsPage 内容区,去掉返回头 + +src/components/ +├── SettingsModal.tsx 删除(仅 App.tsx 引用,一并摘除) +└── SubagentSettingsPage.tsx 改为薄壳:返回头 + (决策 B 的兼容入口) +``` + +### 3.1 组件接口契约 + +```tsx +// SettingsGeneralTab.tsx —— 自包含(主题/语言/版本/下载状态都内置) +interface SettingsGeneralTabProps { + updateInfo: UpdateInfo | null; + checking: boolean; + onCheckUpdate: () => void; + lastChecked: number | null; +} + +// SettingsAdvancedTab.tsx —— 纯内容,无返回头 +interface SettingsAdvancedTabProps { + rawOther: unknown; + models: Record; + defaultModel: string | null; // 供 AgentSettingsPanel 判断 effort 支持 + onChange: (nextRawOther: unknown) => void; +} + +// SettingsPage.tsx +// 返回头与 SubagentSettingsPage 同款:`← {t("back")}` + 标题 `t("settingsTitle")`("设置") +interface SettingsPageProps { + // Tab 受控(不用 initialTab + 内部 state)——齿轮按钮在 header 常驻, + // 停留在设置页时再点齿轮,必须能切回第一个 Tab(全局设置);非受控则 prop 变化不生效。 + activeTab: "global" | "advanced" | "general"; + onTabChange: (tab: "global" | "advanced" | "general") => void; + onBack: () => void; + // 透传给三个 Tab: + updateInfo / checking / onCheckUpdate / lastChecked + rawOther / models / defaultModel / onRawOtherChange +} +``` + +### 3.2 Tab 挂载策略(审核补充的关键点) + +原 `SettingsModal` **常驻挂载**(`open=false` 时返回 null 但 hooks 仍在跑),下载进度监听、`downloadedPath` 状态在弹窗开关间不丢失。改造后若用条件渲染 `{tab === "general" && ...}`,下载进行中切到高级 Tab(或按返回离开页面)会丢失 `downloadedPath`,「安装」按钮消失——这是行为回归。 + +**方案:三个 Tab 面板都常驻挂载,非激活 Tab 用 `className="hidden"` 隐藏。** 代价仅是高级 Tab 提前挂载(一次 `getAgentSettings` IPC 读,无副作用),换来下载状态与滚动位置不丢。 + +## 4. App.tsx 接线 + +1. `view` 类型加 `"settings"`;删除 `showSettings` state 与 `` 挂载、import;删除 `SettingsModal.tsx`。 +2. 新增 `settingsTab` state(`"global" | "advanced" | "general"`)。 +3. 齿轮按钮(`App.tsx:670`)→ `setSettingsTab("global"); setView("settings")`(第一个 Tab 即全局设置);绿点徽标保留。 +4. header「高级设置」按钮 → `setSettingsTab("advanced"); setView("settings")`,文案键不变。 +5. 渲染分支加(Tab 受控,state 放 App): + ```tsx + ) : view === "settings" ? ( + setView("list")} + updateInfo={updateInfo} checking={checking} onCheckUpdate={checkNow} lastChecked={lastChecked} + rawOther={config.raw_other} models={config.models} defaultModel={config.default_model} + onRawOtherChange={(next) => updateConfig((cfg) => ({ ...cfg, raw_other: next }))} + /> + ``` +6. 兼容分支(决策 B):`view === "subagent"` 保留,仅多传 `defaultModel={config.default_model}`(`SubagentSettingsPage` 新增可选 prop)。此后 UI 上无入口指向它,代码保留。 + +## 5. ProviderEdit.tsx 摘除(决策 A) + +- 删除 `AgentSettingsPanel` 挂载块(`:477-484`)与 import(`:16`)。 +- 连锁清理(`noUnusedLocals` / `noUnusedParameters` 开启,tsc 会逼出来):`rawOther` / `onRawOtherChange` 两个 props 若无其他使用则从 `ProviderEditProps`(`:93` 附近)和 `App.tsx:721-724` 调用处一并删除;`modelsByAlias` 等仅为其存在的中间变量同步清理。以 `npx tsc --noEmit` 零错误为准。 + +## 6. i18n + +三个 Tab 文案键(zh / en): + +| key | zh | en | +| --- | --- | --- | +| `settingsTabGlobal` | 全局设置 | Global Settings | +| `subagentSettings` | 高级设置 | Advanced Settings | +| `settingsAbout` | 关于 | About | + +不新增其他 key;`SubagentSettingsPage` 薄壳继续复用 `subagentSettings` / `back`。 + +## 7. 行为差异清单(有意为之,评审确认) + +| # | 差异 | 说明 | +| --- | --- | --- | +| 1 | Esc 不再关闭设置 | 弹窗行为;页面用「← 返回」。如需保留可加,默认不加 | +| 2 | 设置不再遮挡底层界面 | 弹窗 → 页面,符合本次需求本意 | +| 3 | footer 出现在设置页 | footer 显示条件不含 `settings`,保存/重载按钮仍可用(高级 Tab 改 `raw_other` 会标 dirty,**能在页内直接保存**,是改善而非回归) | +| 4 | 顶部导航「供应商」高亮条件 | `list/edit/subagent` 高亮(`App.tsx:604`),`settings` 不加入高亮,返回后仍正确 | +| 5 | 齿轮/高级按钮进入设置页**不做 dirty 拦截** | 与现有 header 导航(数据看板/会话)一致;配置在内存中不丢失 | +| 6 | 停留在设置页时再点齿轮 | 切回第一个 Tab(全局设置),受控 Tab 保证 prop 变化生效,见 3.1 | +| 7 | footer 按钮文案 | 与高级设置 Tab 共用 `subagentSettings`(高级设置),文案一致 | + +## 8. 不做的事(边界) + +- 不动 dashboard / sessions / plugins 页面,不动 Rust 侧任何代码。 +- 不改 `AgentSettingsPanel.tsx` 内部(含 `mt-6` 外壳与「代理设置」小标题,在高级 Tab 中观感正常);高级 Tab 不再按 `agent === "kimi_code"` 判断——当前仅支持 kimi_code(`getInitialAgent` 恒返回 `"kimi_code"`),判断无意义。 +- Tab 选择不持久化到 localStorage:每次进入按入口决定初始 Tab(齿轮 → 全局设置,header「高级设置」→ 高级设置)。 +- 不新增组件测试:项目测试先例仅覆盖 `src/lib` / `src/hooks`,无组件测试;本次跟随先例。 +- `SettingsGeneralTab` 只在设置页打开时挂载(原弹窗常驻挂载)——下载监听只能由设置页内的按钮发起,无实际影响。 +- 工作区里已有的未提交改动(更新下载修复、OpenTerminal 链接、models-dev 数据)与本提案无关,实现时**不混入**,分开提交。 + +## 9. 实施步骤(依赖顺序) + +1. **并行抽取**(两个子代理,文件不相交): + a. `SettingsGeneralTab.tsx`(含下载回退修复逐字保留) + b. `SettingsAdvancedTab.tsx` + `SubagentSettingsPage.tsx` 改薄壳 +2. **接线**(主代理):`SettingsPage.tsx` + `App.tsx` + `ProviderEdit.tsx` + i18n 两键 + 删除 `SettingsModal.tsx`。 +3. **验证**:`npx tsc --noEmit` 零错误 → `npm test`(150 例基线)→ `cargo test`(应无影响,跑一遍确认 208 例基线)。 +4. **dev 实测**(注意先 `taskkill` 关正式版,单实例锁冲突):见 10。 + +## 10. 验收标准 + +**自动化**:`npx tsc --noEmit` 零错误;`npm test` 全绿;`cargo test` 全绿。 + +**手动清单**(`npm run tauri-dev`): + +- [ ] 齿轮 → 打开设置页,初始停在「全局设置」Tab,四个 section(外观/更新/KimiCode/关于)在「关于」Tab 中原弹窗内容一致(含 OpenTerminal 链接) +- [ ] header「高级设置」→ 同一页面,初始停在「高级设置」Tab,含代理设置 + 原 4 个 Card +- [ ] 「← 返回」回到供应商列表 +- [ ] 供应商编辑页模型 Tab **不再出现**代理设置区块 +- [ ] 高级 Tab 改动(如实验开关)→ footer 出现未保存标记 → 可在页内保存 → 配置文件落盘正确 +- [ ] 检查更新 / 下载 / 安装流程可用;下载中切到高级 Tab 再切回,进度与「安装」按钮不丢 +- [ ] 主题、语言切换即时生效 +- [ ] 有新版本时齿轮绿点徽标仍显示 +- [ ] 停留在设置页时再点齿轮 → 切回第一个 Tab(全局设置) +- [ ] header「高级设置」按钮文案与高级设置 Tab 名一致 + +## 11. 风险与回退 + +| 风险 | 缓解 | +| --- | --- | +| 抽取时漏搬/改错下载回退逻辑 | 验收清单专项覆盖;diff 逐行 review | +| 高级 Tab 提前挂载引发意外 IPC | 仅一次只读 `getAgentSettings`,无写操作 | +| `ProviderEdit` props 清理波及其他用法 | 已核实 `rawOther` 仅 `AgentSettingsPanel` 消费,tsc 兜底 | +| 回退 | 单独 commit,`git revert` 即可整体回退,不碰数据格式 | diff --git a/docs/release-notes/v0.8.5.md b/docs/release-notes/v0.8.5.md new file mode 100644 index 0000000..f8bb269 --- /dev/null +++ b/docs/release-notes/v0.8.5.md @@ -0,0 +1,37 @@ +# KimiSwitch v0.8.5 + +设置界面重构版:原来那个塞满所有选项的「设置」弹窗变成了一整页带返回头的全局设置页面,按「全局设置 / 高级设置 / 关于」分页;同时修掉两处体检与更新下载的哑火问题。 + +## 设置弹窗改为全局设置页面(重构) + +- **弹窗改成页面**:原 `SettingsModal` 只能在弹窗那一小块区域里滚动,选项一多就要反复上下翻。现在从右上角齿轮或顶部导航进入的是一整页设置,与「供应商 / 仪表盘 / 会话」平级、由顶部导航直接切换,宽度与上下文都充裕 +- **三个 Tab 各管一摊**: + - **全局设置**——写回 `config.toml` 的代理设置,思考开关与五档思考等级、循环控制(单步最大尝试次数、上下文预留、压缩重试上限)、权限规则、钩子 + - **高级设置**——KimiWebUI、models.dev 参考数据同步/回滚、子代理模型池、实验功能开关 + - **关于**——外观三态(浅色 / 深色 / 跟随系统)、语言、更新检查、kimi-code CLI 版本核验、关于信息 +- **「高级设置」入口移入顶部导航组**:此前要从页面底部 footer 的按钮进;现在和「供应商 / 仪表盘 / 会话」并排,位置固定好找 +- **代理设置单一入口**:供应商编辑页里内嵌的那份代理设置已移除,全局设置页是唯一入口,不会出现两处各写一半的困惑 +- **旧 `view=subagent` 路由保留**:作为兼容外壳继续存在,书签或内部跳转不会失效 +- **Tab 切换不丢状态**:非活动面板只是隐藏而非卸载,更新下载的进度与安装包路径在切 Tab 之间保持不丢 + +## 一键体检:账单缺口修复 + +- **配了 NewAPI / Sub2API 查询模板但没勾 usageKinds 的供应商不再被静默跳过**:这两条模板路径直接查网关自己的用量端点,本来就不走 `usageKinds`,此前体检却因为它为空而判定「不需要查账单」,把这一行整个漏掉 +- **用户在配置面板里主动关掉用量查询时仍然不查**:关掉是明确意图,不该被记成失败项 +- 其余情况(无配置、`auto` 模板 + 空 kinds)行为不变,仍不发起账单请求 + +## 更新下载修复 + +- **release 没有当前平台安装包资产时,「下载」按钮回退为打开 release 页面**:此前 `downloadUrl` 缺失就静默返回,点了没反应像是按钮坏了;现在直接跳 release 页,用户可自行选资产。连 release 页地址都没有时才提示原因 +- **自动更新过滤 draft 与 prerelease**:两个托管站都按「最新在前」返回,此前直接取数组第一个;若最新一条是 draft,或正式版之后又发了一个 rc,就会被当成更新推给用户。现在显式跳过未发布与预发布,取最新的正式版,全被过滤掉时按「无已发布版本」处理而不是 panic + +## 其他 + +- **启动瞬间的色差修复**:CSS 变量默认深色,而 Tailwind 的 `dark:` 变体要等 React 挂载后才生效,启动瞬间激活的供应商卡片会先闪一下浅色;现在 `index.html` 首帧前内联脚本即按 localStorage + 系统偏好设置主题 class,不再闪 +- **关于区新增 OpenTerminal 推荐链接**(`billowliu2.github.io/OpenTerminal`),与 KimiSwitch 官网、Kimi Eyes 插件站并列 +- models.dev 快照同步至 2026-10-15(8,873 模型 / 230 供应商),本次含 14 条模型价格调整与 3 条已下线模型清理 + +## 测试 + +- 前端 150 项 +- Rust 212 项(新增 7 项:网关模板应查 / 面板关闭不查 / 空 kinds 不查、draft 与 prerelease 过滤、全稳定取最新、空与非数组不 panic) diff --git a/index.html b/index.html index f04854c..0c7afc1 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,23 @@ Kimi Switch +
diff --git a/package.json b/package.json index 6a39423..c6b5929 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "kimiswitch", "private": true, - "version": "0.8.4", + "version": "0.8.5", "type": "module", "scripts": { "dev": "vite", diff --git a/public/models-dev.json b/public/models-dev.json index d4e50e6..9aad7de 100644 --- a/public/models-dev.json +++ b/public/models-dev.json @@ -7166,9 +7166,9 @@ "tool_call": true, "structured_output": true, "cost": { - "input": 0.8957, - "output": 0.99, - "cache_read": 0.1866 + "input": 1.1, + "output": 2.5, + "cache_read": 0.04 } }, "nano-gpt/deepseek/deepseek-v4-flash-0423:thinking": { @@ -7205,9 +7205,9 @@ "tool_call": true, "structured_output": true, "cost": { - "input": 0.8957, - "output": 0.99, - "cache_read": 0.1866 + "input": 1.1, + "output": 2.5, + "cache_read": 0.04 } }, "nano-gpt/deepseek/deepseek-v4-pro-0813": { @@ -7218,9 +7218,9 @@ "tool_call": true, "structured_output": true, "cost": { - "input": 0.8957, - "output": 0.99, - "cache_read": 0.1866 + "input": 1.1, + "output": 2.5, + "cache_read": 0.04 } }, "nano-gpt/deepseek/deepseek-v4-flash-0731:thinking": { @@ -7262,19 +7262,6 @@ "cache_read": 0.013 } }, - "nano-gpt/deepseek/deepseek-v4-flash-vision-exp-cybersecurity": { - "name": "DeepSeek V4 Flash Vision Exp Cybersecurity", - "context": 1048576, - "output": 32768, - "reasoning": true, - "tool_call": true, - "image": true, - "cost": { - "input": 0.15, - "output": 0.6, - "cache_read": 0.075 - } - }, "nano-gpt/deepseek/deepseek-v3.2": { "name": "DeepSeek V3.2", "context": 163000, @@ -10032,9 +10019,9 @@ "tool_call": true, "image": true, "cost": { - "input": 0.441, - "output": 2.45, - "cache_read": 0.099 + "input": 0.5, + "output": 2.6, + "cache_read": 0.125 } }, "nano-gpt/moonshotai/kimi-k2.5:thinking": { @@ -10095,9 +10082,9 @@ "tool_call": true, "image": true, "cost": { - "input": 0.441, - "output": 2.45, - "cache_read": 0.099 + "input": 0.5, + "output": 2.6, + "cache_read": 0.125 } }, "nano-gpt/moonshotai/kimi-k2-instruct-0711": { @@ -23499,20 +23486,6 @@ "cache_write": 12.5 } }, - "kilo/anthropic/claude-sonnet-4": { - "name": "Anthropic: Claude Sonnet 4 (retires Oct 15)", - "context": 200000, - "output": 64000, - "reasoning": true, - "tool_call": true, - "image": true, - "cost": { - "input": 3, - "output": 15, - "cache_read": 0.3, - "cache_write": 3.75 - } - }, "kilo/anthropic/claude-sonnet-5": { "name": "Claude Sonnet 5", "context": 1000000, @@ -24653,9 +24626,9 @@ "tool_call": true, "structured_output": true, "cost": { - "input": 0.063, + "input": 0.0608, "output": 4.4, - "cache_read": 0.0586 + "cache_read": 0.0565 } }, "kilo/arcee-ai/trinity-large-thinking": { @@ -25003,9 +24976,9 @@ "tool_call": true, "structured_output": true, "cost": { - "input": 0.225, + "input": 0.1307, "output": 8, - "cache_read": 0.23 + "cache_read": 0.13 } }, "kilo/~deepseek/deepseek-v4-flash-latest": { @@ -25629,7 +25602,7 @@ }, "kilo/mistralai/mistral-small-3.2-24b-instruct": { "name": "Mistral Small 3.2 24B", - "context": 128000, + "context": 256000, "output": 16384, "tool_call": true, "structured_output": true, @@ -27111,7 +27084,7 @@ "kilo/qwen/qwen3.6-27b": { "name": "Qwen3.6 27B", "context": 262144, - "output": 81920, + "output": 65536, "reasoning": true, "tool_call": true, "structured_output": true, @@ -35752,20 +35725,6 @@ "cache_write": 12.5 } }, - "openrouter/anthropic/claude-sonnet-4": { - "name": "Claude Sonnet 4", - "context": 200000, - "output": 64000, - "reasoning": true, - "tool_call": true, - "image": true, - "cost": { - "input": 3, - "output": 15, - "cache_read": 0.3, - "cache_write": 3.75 - } - }, "openrouter/anthropic/claude-sonnet-5": { "name": "Claude Sonnet 5", "context": 1000000, @@ -36038,9 +35997,9 @@ "tool_call": true, "structured_output": true, "cost": { - "input": 0.95526, - "output": 1.91052, - "cache_read": 0.079605 + "input": 0.43935, + "output": 0.8787, + "cache_read": 0.036613 } }, "openrouter/deepseek/deepseek-r1-0528": { @@ -36507,9 +36466,9 @@ "tool_call": true, "structured_output": true, "cost": { - "input": 0.02, + "input": 0.06, "output": 7, - "cache_read": 0.019 + "cache_read": 0.059 } }, "openrouter/z-ai/glm-5.1": { @@ -36557,9 +36516,9 @@ "tool_call": true, "structured_output": true, "cost": { - "input": 0.079, - "output": 4.8, - "cache_read": 0.078 + "input": 0.4, + "output": 8, + "cache_read": 0.399 } }, "openrouter/inference-net/schematron-v2-small": { @@ -36914,9 +36873,9 @@ "tool_call": true, "structured_output": true, "cost": { - "input": 0.063, + "input": 0.0608, "output": 4.4, - "cache_read": 0.0586 + "cache_read": 0.0565 } }, "openrouter/arcee-ai/trinity-large-thinking": { @@ -37264,9 +37223,9 @@ "tool_call": true, "structured_output": true, "cost": { - "input": 0.225, + "input": 0.1307, "output": 8, - "cache_read": 0.23 + "cache_read": 0.13 } }, "openrouter/~deepseek/deepseek-v4-flash-latest": { @@ -37920,8 +37879,8 @@ "structured_output": true, "image": true, "cost": { - "input": 0.075, - "output": 0.2 + "input": 0.09375, + "output": 0.25 } }, "openrouter/mistralai/mistral-large": { @@ -39313,15 +39272,15 @@ "openrouter/qwen/qwen3.6-27b": { "name": "Qwen3.6 27B", "context": 262144, - "output": 81920, + "output": 65536, "reasoning": true, "tool_call": true, "structured_output": true, "image": true, "video": true, "cost": { - "input": 0.32, - "output": 3.2 + "input": 0.45, + "output": 2.7 } }, "openrouter/qwen/qwen3.6-plus": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c6f2374..6176edd 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1978,7 +1978,7 @@ dependencies = [ [[package]] name = "kimiswitch" -version = "0.8.4" +version = "0.8.5" dependencies = [ "anyhow", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f15fc83..74f23d6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kimiswitch" -version = "0.8.4" +version = "0.8.5" description = "Kimi Switch - model config manager" authors = ["codingplan.site"] edition = "2021" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 8861480..116cae1 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -974,18 +974,29 @@ fn is_health_target(provider: &Provider) -> bool { provider.enabled && provider.provider_type.is_openai_compatible() } -/// 是否顺带查账单:配置了 usageKinds,且用户没有在配置面板关掉用量查询 +/// 是否顺带查账单:配置了 usageKinds,或配了 NewAPI / Sub2API 模板——这两条 +/// 模板路径直接查网关自己的用量端点(见 [`query_provider_usage`]),绕过了 +/// usageKinds,所以 kinds 为空也要查。前提是用户没有在配置面板关掉用量查询 /// (关掉是明确意图,不该记成失败)。 fn wants_usage_check(provider: &Provider) -> bool { - provider + let Some(cfg) = provider.usage_config.as_ref() else { + return provider + .usage_kinds + .as_ref() + .is_some_and(|v| !v.is_empty()); + }; + if !cfg.enabled { + return false; + } + let has_kinds = provider .usage_kinds .as_ref() - .is_some_and(|v| !v.is_empty()) - && provider - .usage_config - .as_ref() - .map(|c| c.enabled) - .unwrap_or(true) + .is_some_and(|v| !v.is_empty()); + let has_gateway_template = matches!( + cfg.template_type.as_str(), + UsageConfig::TEMPLATE_NEWAPI | UsageConfig::TEMPLATE_SUB2API + ); + has_kinds || has_gateway_template } /// 探活 404 的判定前缀,与 services/probe.rs 的 `endpoint not found` 对应。 @@ -1291,6 +1302,66 @@ mod health_check_tests { assert!(!wants_usage_check(&p)); } + /// 构造 usage_config;`enabled` 之外的字段对本组用例无意义。 + fn usage_config(enabled: bool, template_type: &str) -> UsageConfig { + UsageConfig { + enabled, + template_type: template_type.to_string(), + base_url: None, + access_token: None, + user_id: None, + auto_query_interval_minutes: None, + timeout_seconds: None, + threshold: None, + } + } + + #[test] + fn usage_check_runs_for_gateway_template_without_kinds() { + // NewAPI / Sub2API 模板走网关自己的用量端点,绕过 usageKinds: + // 未知域名(detect_provider 不命中)+ 只配模板时必须照查。 + for template in [UsageConfig::TEMPLATE_NEWAPI, UsageConfig::TEMPLATE_SUB2API] { + let mut p = provider(ProviderType::Openai, true); + p.usage_kinds = Some(vec![]); + p.usage_config = Some(usage_config(true, template)); + assert!(wants_usage_check(&p), "template {template} 应触发账单查询"); + } + } + + #[test] + fn usage_check_skips_gateway_template_when_disabled() { + // 面板里关了用量查询 → 模板再全也不查 + for template in [UsageConfig::TEMPLATE_NEWAPI, UsageConfig::TEMPLATE_SUB2API] { + let mut p = provider(ProviderType::Openai, true); + p.usage_kinds = Some(vec![]); + p.usage_config = Some(usage_config(false, template)); + assert!(!wants_usage_check(&p), "template {template} 关闭时不应查询"); + } + } + + #[test] + fn usage_check_skips_empty_kinds_with_auto_or_no_template() { + let mut p = provider(ProviderType::Openai, true); + p.usage_kinds = Some(vec![]); + // 无 usage_config + assert!(!wants_usage_check(&p)); + // auto 模板 + enabled:仍然没有可查的东西 + p.usage_config = Some(usage_config(true, UsageConfig::TEMPLATE_AUTO)); + assert!(!wants_usage_check(&p)); + } + + #[test] + fn usage_check_runs_for_kinds_with_enabled_panel() { + let mut p = provider(ProviderType::Openai, true); + p.usage_kinds = Some(vec!["balance:deepseek".to_string()]); + p.usage_config = Some(usage_config(true, UsageConfig::TEMPLATE_AUTO)); + assert!(wants_usage_check(&p)); + + // kinds 非空 + 模板也是网关模板 → 依然查 + p.usage_config = Some(usage_config(true, UsageConfig::TEMPLATE_NEWAPI)); + assert!(wants_usage_check(&p)); + } + #[test] fn probe_404_is_neutral_other_errors_fail() { assert_eq!(classify_probe(Ok(())).state, HealthCheck::Pass); @@ -1499,6 +1570,11 @@ pub async fn check_for_update() -> Result { /// GET a releases API (Gitea or GitHub, same array shape) and extract the /// latest tag, release page URL and the installer asset for the current OS. +/// +/// The *first* published (non-draft, non-prerelease) release wins. Both hosts +/// return releases newest-first, but a draft or an rc published after a stable +/// release would otherwise be picked and offered as the update — so filter +/// them out explicitly and take the newest survivor. async fn fetch_latest_release( client: &reqwest::Client, url: &str, @@ -1512,10 +1588,7 @@ async fn fetch_latest_release( .await .map_err(|e| format!("parse failed: {e}"))?; - let first = releases - .as_array() - .and_then(|a| a.first()) - .ok_or("no releases found")?; + let first = pick_published_release(&releases).ok_or("no published releases found")?; let latest = first .get("tag_name") @@ -1537,6 +1610,17 @@ async fn fetch_latest_release( Ok((latest, release_url, download_url)) } +/// Newest release that is neither a draft nor a prerelease. Both hosts return +/// releases newest-first, so this is the first array element that survives the +/// filter. Drafts are invisible to users anyway; an rc published after a +/// stable release must not be offered as the update. +fn pick_published_release(releases: &serde_json::Value) -> Option<&serde_json::Value> { + releases.as_array()?.iter().find(|r| { + !r.get("draft").and_then(|v| v.as_bool()).unwrap_or(false) + && !r.get("prerelease").and_then(|v| v.as_bool()).unwrap_or(false) + }) +} + // --------------------------------------------------------------------------- // Silent download with progress events // --------------------------------------------------------------------------- @@ -2162,6 +2246,48 @@ mod asset_picker_tests { use super::*; use serde_json::json; + #[test] + fn published_release_skips_draft_and_prerelease() { + // Gitea/GitHub 顺序:最新在前。若最新是 draft 或 rc,必须跳到下一个 + // 正式版,否则会把未发布/预发布版本当成更新推给用户。 + let releases = json!([ + { "tag_name": "v0.9.0", "draft": false, "prerelease": true }, + { "tag_name": "v0.8.5", "draft": true, "prerelease": false }, + { "tag_name": "v0.8.4", "draft": false, "prerelease": false }, + ]); + assert_eq!( + pick_published_release(&releases) + .and_then(|r| r.get("tag_name")) + .and_then(|v| v.as_str()), + Some("v0.8.4") + ); + } + + #[test] + fn published_release_takes_the_newest_when_all_are_stable() { + let releases = json!([ + { "tag_name": "v0.8.5", "draft": false, "prerelease": false }, + { "tag_name": "v0.8.4", "draft": false, "prerelease": false }, + ]); + assert_eq!( + pick_published_release(&releases) + .and_then(|r| r.get("tag_name")) + .and_then(|v| v.as_str()), + Some("v0.8.5") + ); + } + + #[test] + fn published_release_none_when_empty_or_all_unpublished() { + assert!(pick_published_release(&json!([])).is_none()); + assert!(pick_published_release(&json!([ + { "tag_name": "v1.0.0-rc1", "prerelease": true } + ])) + .is_none()); + // 非数组(错误响应体)不得 panic + assert!(pick_published_release(&json!({ "message": "rate limited" })).is_none()); + } + fn assets(names: &[&str]) -> Vec { names .iter() diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index bccd8b7..7cb2963 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "Kimi Switch", - "version": "0.8.4", + "version": "0.8.5", "identifier": "com.kimiswitch.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index 6216624..e0dd278 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,7 +9,7 @@ import { SubagentSettingsPage } from "./components/SubagentSettingsPage"; import { DashboardPage } from "./components/dashboard/DashboardPage"; import { SessionsPage } from "./components/sessions/SessionsPage"; import { PluginMarketplacePage } from "./components/plugins/PluginMarketplacePage"; -import { SettingsModal } from "./components/SettingsModal"; +import { SettingsPage } from "./components/settings/SettingsPage"; import { UsageConfigModal } from "./components/UsageConfigModal"; import { PresetPickerModal } from "./components/PresetPickerModal"; import { useTranslation } from "./i18n"; @@ -81,12 +81,14 @@ export default function App() { } = useConfig(agent); const [view, setView] = useState< - "list" | "edit" | "subagent" | "dashboard" | "sessions" | "plugins" + "list" | "edit" | "subagent" | "dashboard" | "sessions" | "plugins" | "settings" >("list"); const [editingProvider, setEditingProvider] = useState(""); const [loadTimeout, setLoadTimeout] = useState(false); const [switchMessage, setSwitchMessage] = useState(null); - const [showSettings, setShowSettings] = useState(false); + const [settingsTab, setSettingsTab] = useState<"general" | "global" | "advanced">( + "general", + ); const [showPresetPicker, setShowPresetPicker] = useState(false); /** Provider whose usage-config panel is open; null = closed. */ const [usageConfigProvider, setUsageConfigProvider] = useState(null); @@ -628,6 +630,20 @@ export default function App() { > {t("sessions")} + {/* 插件市场功能尚未成熟,导航入口暂时隐藏;页面代码保留, 恢复时把下面的按钮加回即可(view/渲染分支均未移除)。 - {view === "list" && ( - - )} )} - setShowSettings(false)} - updateInfo={updateInfo} - checking={checking} - onCheckUpdate={checkNow} - lastChecked={lastChecked} - /> - setShowPresetPicker(false)} diff --git a/src/components/ProviderEdit.tsx b/src/components/ProviderEdit.tsx index 2ad0a58..8526aa5 100644 --- a/src/components/ProviderEdit.tsx +++ b/src/components/ProviderEdit.tsx @@ -1,4 +1,4 @@ -import { useEffect, useId, useMemo, useReducer, useRef, useState } from "react"; +import { useEffect, useId, useReducer, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { invoke } from "@tauri-apps/api/core"; import { useTranslation } from "../i18n"; @@ -13,7 +13,6 @@ import { } from "../lib/model-max-output"; import { capabilitiesFromRef, getModelRef, modelsDevReady } from "../lib/models-dev"; import { getIconMetadata } from "../icons/extracted/metadata"; -import { AgentSettingsPanel } from "./AgentSettingsPanel"; import { KimiOAuthDialog } from "./KimiOAuthDialog"; import { ProviderIcon } from "./ProviderIcon"; import { IconPicker } from "./IconPicker"; @@ -90,8 +89,6 @@ interface ProviderEditProps { provider: Provider; models: Model[]; defaultModel: string | null; - rawOther: unknown; - onRawOtherChange: (nextRawOther: unknown) => void; onBack: () => void; onChange: (provider: Provider) => void; onDelete: () => void; @@ -109,8 +106,6 @@ export function ProviderEdit({ provider, models, defaultModel, - rawOther, - onRawOtherChange, onBack, onChange, onDelete, @@ -151,13 +146,6 @@ export function ProviderEdit({ const apiKeyEnvSupported = agent === "kimi_code"; const apiKeyEnvMode = apiKeyEnvSupported && provider.api_key_env != null; - // Alias → entry lookup for panels that resolve a model by alias - // (AgentSettingsPanel reads the default model's support_efforts). - const modelsByAlias = useMemo( - () => Object.fromEntries(models.map((m) => [m.alias, m])), - [models] - ); - useEffect(() => { const def = defaultBaseUrl(agent, provider.provider_type); if (!def) return; @@ -474,14 +462,6 @@ export function ProviderEdit({ onBulkAdd={onBulkAdd} onSetDefault={onSetDefault} /> - {agent === "kimi_code" && ( - - )} )} diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx deleted file mode 100644 index 2e8d833..0000000 --- a/src/components/SettingsModal.tsx +++ /dev/null @@ -1,563 +0,0 @@ -import { useEffect, useState, type ReactNode } from "react"; -import { createPortal } from "react-dom"; -import { invoke } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; -import { useTranslation, type Language } from "../i18n"; -import { useTheme, type Theme } from "../hooks/useTheme"; -import type { UpdateInfo } from "../hooks/useUpdateCheck"; -import { - useKimiCodeVersion, - isNewerVersion, - type KimiCodeStatus, -} from "../hooks/useKimiCodeVersion"; - -function formatVersion(version: string): string { - const v = version.trim(); - return v.startsWith("v") || v.startsWith("V") ? v : `v${v}`; -} - -/** 上游 kimi-code releases 页面。 */ -const KIMI_CODE_RELEASES_URL = "https://github.com/MoonshotAI/kimi-code/releases"; - -interface SettingsModalProps { - open: boolean; - onClose: () => void; - updateInfo: UpdateInfo | null; - checking: boolean; - onCheckUpdate: () => void; - lastChecked: number | null; -} - -export function SettingsModal({ - open, - onClose, - updateInfo, - checking, - onCheckUpdate, - lastChecked, -}: SettingsModalProps) { - const { t, lang, setLang } = useTranslation(); - const { theme, setTheme } = useTheme(); - const { - info: kimiCodeInfo, - checking: kimiCodeChecking, - checkNow: checkKimiCode, - } = useKimiCodeVersion(); - const [appVersion, setAppVersion] = useState(""); - const [downloadProgress, setDownloadProgress] = useState(null); - const [downloadedPath, setDownloadedPath] = useState(null); - const [downloadError, setDownloadError] = useState(null); - - useEffect(() => { - if (open) { - invoke("get_app_version").then(setAppVersion).catch(() => {}); - } - }, [open]); - - // Listen to download progress events - useEffect(() => { - const unlisteners: (() => void)[] = []; - - const setupProgress = async () => { - const un1 = await listen<{ downloaded: number; total: number; progress: number }>( - "download-progress", - (event) => { - setDownloadProgress(event.payload.progress); - } - ); - unlisteners.push(() => un1()); - - const un2 = await listen<{ path: string }>( - "download-complete", - (event) => { - setDownloadedPath(event.payload.path); - setDownloadProgress(100); - } - ); - unlisteners.push(() => un2()); - }; - - setupProgress(); - - return () => unlisteners.forEach((fn) => fn()); - }, []); - - useEffect(() => { - if (!open) return; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [open, onClose]); - - useEffect(() => { - if (!open) return; - const prev = document.body.style.overflow; - document.body.style.overflow = "hidden"; - return () => { - document.body.style.overflow = prev; - }; - }, [open]); - - const handleDownload = async () => { - if (!updateInfo?.downloadUrl) return; - setDownloadProgress(0); - setDownloadError(null); - setDownloadedPath(null); - try { - const path = await invoke("download_update", { - url: updateInfo.downloadUrl, - }); - setDownloadedPath(path); - setDownloadProgress(100); - } catch (e) { - setDownloadError(e instanceof Error ? e.message : String(e)); - setDownloadProgress(null); - } - }; - - const handleInstall = () => { - if (!downloadedPath) return; - invoke("open_installer", { path: downloadedPath }).catch(() => {}); - }; - - // Open an external link via the Rust-side opener command (bypasses the JS - // plugin scope); fall back to a new tab when even that fails. - const openExternal = (url: string) => { - invoke("open_external_url", { url }).catch(() => { - const w = window.open(url, "_blank"); - if (!w) console.error(`failed to open ${url}`); - }); - }; - - if (!open) return null; - - const themeOptions: { value: Theme; icon: ReactNode; label: string }[] = [ - { - value: "light", - icon: ( - - - - - ), - label: t("themeLight"), - }, - { - value: "dark", - icon: ( - - - - ), - label: t("themeDark"), - }, - { - value: "auto", - icon: ( - - - - - ), - label: t("themeAuto"), - }, - ]; - - const fmtLastChecked = (ts: number | null) => { - if (!ts) return "—"; - const d = new Date(ts); - return d.toLocaleString(lang === "zh" ? "zh-CN" : "en-US"); - }; - - // 状态徽标:绿=已适配 / 黄=建议升级 KimiSwitch / 橙=部分兼容 / 灰=未知或未安装 - const kimiCodeBadge = (status: KimiCodeStatus) => { - switch (status) { - case "current": - return { - label: t("kimiCodeStatusCurrent"), - className: - "bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 border-green-300 dark:border-green-500/30", - }; - case "outdated": - return { - label: t("kimiCodeStatusOutdated"), - className: - "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 border-yellow-300 dark:border-yellow-500/30", - }; - case "partial": - return { - label: t("kimiCodeStatusPartial"), - className: - "bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 border-orange-300 dark:border-orange-500/30", - }; - case "unknown": - return { - label: t("kimiCodeStatusUnknown"), - className: "bg-hover-2 text-content-muted border-border", - }; - default: - return { - label: t("kimiCodeStatusNotInstalled"), - className: "bg-hover-2 text-content-muted border-border", - }; - } - }; - - const kimiCodeUpdateAvailable = - !!kimiCodeInfo?.installed && - !!kimiCodeInfo?.latest && - isNewerVersion(kimiCodeInfo.latest, kimiCodeInfo.installed); - - const kimiCodeBadgeInfo = kimiCodeInfo ? kimiCodeBadge(kimiCodeInfo.status) : null; - - return createPortal( -
-
e.stopPropagation()} - > - {/* Header */} -
-

- {t("settingsTitle")} -

- -
- -
- {/* Appearance */} -
-

- {t("settingsAppearance")} -

-
- {/* Theme */} -
- - {t("themeLight")} / {t("themeDark")} - -
- {themeOptions.map((opt) => ( - - ))} -
-
- {/* Language */} -
- {t("language")} - -
-
-
- - {/* Updates */} -
-

- {t("settingsUpdates")} -

-
-
- {t("currentVersion")} - - v{appVersion || "—"} - -
-
- {t("latestVersion")} - - {updateInfo ? formatVersion(updateInfo.latest) : "—"} - -
-
- {t("lastChecked")} - - {fmtLastChecked(lastChecked)} - -
-
- - {updateInfo?.updateAvailable && downloadProgress === null && !downloadedPath && ( - - )} - {downloadedPath && ( - - )} -
- {/* Download progress bar */} - {downloadProgress !== null && downloadProgress < 100 && ( -
-
- {t("downloading")} - {downloadProgress}% -
-
-
-
-
- )} - {downloadedPath && ( -
- ✓ {t("downloadComplete")} -
- )} - {downloadError && ( -
- {t("updateCheckFailed")}: {downloadError} -
- )} - {updateInfo?.updateAvailable && ( -
- ✦ {t("updateAvailable")}: {formatVersion(updateInfo.latest)} -
- )} - {updateInfo && !updateInfo.updateAvailable && ( -
- ✓ {t("upToDate")} -
- )} -
-
- - {/* Kimi Code CLI version & compatibility */} -
-

- {t("kimiCodeSection")} -

-
-
- {t("kimiCodeInstalled")} - - {kimiCodeInfo?.installed ? formatVersion(kimiCodeInfo.installed) : "—"} - -
-
- {t("kimiCodeLatest")} - - {kimiCodeInfo?.latest ? formatVersion(kimiCodeInfo.latest) : "—"} - -
-
- {t("kimiCodeStatusLabel")} - {kimiCodeBadgeInfo ? ( - - {kimiCodeBadgeInfo.label} - - ) : ( - — - )} -
-
- - {kimiCodeUpdateAvailable && ( - - )} -
-
-
- - {/* About */} -
-

- {t("settingsAbout")} -

-
-
Kimi Switch
-
{t("appDescription")}
-
v{appVersion} · MIT License
-
-
- {t("settingsWebsite")}:{" "} - -
-
- {t("kimiEyesSite")}:{" "} - -
-
- {t("attributionPorted")}{" "} - - {t("attributionSuffix")} -
-
- {t("modelDataSourcePrefix")}{" "} - -
-
- {t("referenceProject")}: - - (MIT, © Jason Young) -
-
-
-
-
-
-
, - document.body - ); -} diff --git a/src/components/SubagentSettingsPage.tsx b/src/components/SubagentSettingsPage.tsx index c96590d..66d0246 100644 --- a/src/components/SubagentSettingsPage.tsx +++ b/src/components/SubagentSettingsPage.tsx @@ -1,403 +1,32 @@ -import { useEffect, useState } from "react"; -import { invoke } from "@tauri-apps/api/core"; import { useTranslation } from "../i18n"; -import type { TranslationKey } from "../i18n/zh"; -import type { ExperimentalEnvStatus, Model } from "../types"; -import { - EXPERIMENTAL_FLAGS, - clearSecondaryModel, - forcedEnvValue, - getExperimentalFlags, - getSubagentModelPool, - isExperimentalFlagSet, - isFlagLockedByEnv, - isMasterEnvOn, - removeSubagentPoolEntry, - setExperimentalFlag, - setSecondaryModelOnly, - setSubagentDefault, - setSubagentForce, - upsertSubagentPoolEntry, - validateSubagentPool, - type ExperimentalFlagDef, - type PoolValidationError, - type ValidationErrorKey, -} from "../lib/subagent-settings"; -import { Card, Toggle } from "./ui/controls"; -import { applyModelsDevSnapshot, reloadModelsDev } from "../lib/models-dev"; - -/** Mirror of models_dev::ModelsDevStatus (camelCase over IPC). */ -interface ModelsDevStatus { - source: "synced" | "builtin"; - lastUpdated: string; - modelCount: number; - providerCount: number; -} +import type { Model } from "../types"; +import { SettingsAdvancedTab } from "./settings/SettingsAdvancedTab"; +import { SettingsGlobalTab } from "./settings/SettingsGlobalTab"; interface SubagentSettingsPageProps { /** config.raw_other — hosts the `[experimental]` and `[secondary_model]` sections. */ rawOther: unknown; /** All configured models (alias → entry) for the secondary-model picker. */ models: Record; + /** Alias of the model the effort tier actually applies to. */ + defaultModel?: string | null; onChange: (nextRawOther: unknown) => void; onBack: () => void; } -// Effort tiers the CLI accepts; the value is forwarded verbatim upstream, and -// per-model support comes from `[models.""] support_efforts`. -const EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const; -const EFFORT_LABELS: Record<(typeof EFFORTS)[number], TranslationKey> = { - low: "thinkingLow", - medium: "thinkingMedium", - high: "thinkingHigh", - max: "thinkingMax", - xhigh: "thinkingXHigh", -}; - -const FLAG_LABELS: Record = { - wait_for: { name: "flagWaitFor", desc: "flagWaitForDesc" }, - "tool-select": { name: "flagToolSelect", desc: "flagToolSelectDesc" }, - notify_user: { name: "flagNotifyUser", desc: "flagNotifyUserDesc" }, - tower: { name: "flagTower", desc: "flagTowerDesc" }, - subagent_fork: { name: "flagSubagentFork", desc: "flagSubagentForkDesc" }, -}; - -/** Validation-error i18n key per engine rule, for the pre-write self-check. */ -const POOL_ERROR_KEYS: Record = { - primaryReserved: "secondaryPoolErrorPrimaryReserved", - defaultRequired: "secondaryPoolErrorDefaultRequired", - defaultNotInPool: "secondaryPoolErrorDefaultNotInPool", - aliasNotInModels: "secondaryPoolErrorAliasNotInModels", - forceRequiresDefault: "secondaryPoolErrorForceRequiresDefault", - forceExcludesModels: "secondaryPoolErrorForceExcludesModels", -}; - /** - * One row of the subagent model pool: default radio + alias + editable - * description (saved on blur/Enter via the parent's write chain) + remove. - * The description input keeps a local draft so typing is not persisted per - * keystroke; everything else is derived from `rawOther` on each render. + * Standalone full-page shell around the global + advanced settings tabs; kept + * for the legacy route into the subagent settings. The settings page renders + * both tabs separately instead. */ -function PoolEntryRow({ - alias, - displayName, - description, - isDefault, - canRemove, - onSetDefault, - onSave, - onRemove, -}: { - alias: string; - displayName: string | null; - description: string; - isDefault: boolean; - canRemove: boolean; - onSetDefault: (alias: string) => void; - onSave: (alias: string, description: string) => void; - onRemove: (alias: string) => void; -}) { - const { t } = useTranslation(); - const [draft, setDraft] = useState(description); - - const commit = () => { - if (draft === description) return; - onSave(alias, draft); - }; - - return ( -
- onSetDefault(alias)} - className="shrink-0 accent-blue-600 focus:ring-blue-500" - /> - {alias} - {displayName && ( - {displayName} - )} - {isDefault && ( - - {t("default")} - - )} - setDraft(e.target.value)} - onBlur={commit} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - (e.currentTarget as HTMLInputElement).blur(); - } else if (e.key === "Escape") { - setDraft(description); - (e.currentTarget as HTMLInputElement).blur(); - } - }} - className="flex-1 min-w-0 bg-input border border-border rounded px-2 py-1 text-xs focus:ring-2 focus:ring-blue-500 focus:outline-none" - /> - {canRemove && ( - - )} -
- ); -} - export function SubagentSettingsPage({ rawOther, models, + defaultModel, onChange, onBack, }: SubagentSettingsPageProps) { const { t } = useTranslation(); - const [env, setEnv] = useState(null); - /** Pre-write validation failures (fallback guard; normal flows stay clear). */ - const [poolErrors, setPoolErrors] = useState([]); - /** Local draft for the "add pool entry" select (reset after each pick). */ - const [addSelection, setAddSelection] = useState(""); - /** WebUI-open button in flight; disables both buttons while non-null. */ - const [webuiBusy, setWebuiBusy] = useState<"embedded" | "browser" | null>(null); - /** models.dev snapshot provenance (synced copy vs bundled asset). */ - const [modelsDevStatus, setModelsDevStatus] = useState(null); - /** Sync/restore button in flight; blocks the card's buttons. */ - const [modelsDevBusy, setModelsDevBusy] = useState(false); - /** Last sync error (inline) and success flag (auto-cleared on next action). */ - const [syncError, setSyncError] = useState(null); - const [syncOk, setSyncOk] = useState(false); - - useEffect(() => { - invoke("get_experimental_env_status") - .then(setEnv) - .catch(() => setEnv({})); - invoke("get_models_dev_status") - .then(setModelsDevStatus) - .catch(() => setModelsDevStatus(null)); - }, []); - - const refreshModelsDevStatus = () => { - invoke("get_models_dev_status") - .then(setModelsDevStatus) - .catch(() => {}); - }; - - const handleModelsDevSync = async () => { - setModelsDevBusy(true); - setSyncError(null); - setSyncOk(false); - try { - const st = await invoke("sync_models_dev"); - setModelsDevStatus(st); - // Hot-swap the frontend index; the dashboard price index rebuilds on - // its next query (mtime-triggered on the Rust side). - const raw = await invoke("get_models_dev_snapshot"); - if (raw) applyModelsDevSnapshot(JSON.parse(raw) as Record); - setSyncOk(true); - } catch (err) { - setSyncError(err instanceof Error ? err.message : String(err)); - } finally { - setModelsDevBusy(false); - } - }; - - const handleModelsDevRestore = async () => { - if (!confirm(t("modelsDataRestoreConfirm"))) return; - setModelsDevBusy(true); - setSyncError(null); - setSyncOk(false); - try { - const st = await invoke("reset_models_dev"); - setModelsDevStatus(st); - await reloadModelsDev(); - } catch (err) { - setSyncError(err instanceof Error ? err.message : String(err)); - refreshModelsDevStatus(); - } finally { - setModelsDevBusy(false); - } - }; - - const flags = getExperimentalFlags(rawOther); - const pool = getSubagentModelPool(rawOther); - const poolKeys = pool ? Object.keys(pool.models) : []; - // View selection: no/empty section or a bare legacy `model` → simple view; - // a `models` table or `default_model` → pool management view. - const isPoolView = - !!pool && (poolKeys.length > 0 || pool.defaultModel !== undefined); - // Effective default: v2 pool key first, legacy v1 recipe key as fallback. - const effectiveDefault = pool ? pool.defaultModel ?? pool.model : undefined; - // "Upgrade" only makes sense for the bare legacy form and when `force` is - // off — upgrading with force would create an invalid force+pool-table combo. - const canUpgrade = !!pool && !isPoolView && pool.model !== undefined && !pool.force; - const masterOn = isMasterEnvOn(env); - - const aliasList = Object.keys(models).sort(); - - const aliasLabel = (alias: string): string => - models[alias]?.display_name - ? `${alias}(${models[alias].display_name})` - : alias; - - // Rows of the pool view: every `models` table key plus the effective default - // when it is not already in the table (the implicit single-entry form). - const implicitDefault = - pool && effectiveDefault && !(effectiveDefault in pool.models) - ? [effectiveDefault] - : []; - const poolEntries = [...poolKeys, ...implicitDefault]; - // Aliases that can still be added to the pool (not already in the table). - const addableAliases = aliasList.filter((a) => !(a in (pool?.models ?? {}))); - - // Inherited settings shown for the picked model: subagents bind the pointed - // entry directly (no patch fields), so context size and effort come from the - // model entry / global [thinking], not from a `[secondary_model]` patch. - const selectedEntry = effectiveDefault ? models[effectiveDefault] : undefined; - const inheritedContext = selectedEntry?.max_context_size; - const modelEffort = - selectedEntry && - typeof (selectedEntry.raw_other as Record | undefined) - ?.default_effort === "string" - ? ((selectedEntry.raw_other as Record).default_effort as string) - : undefined; - const thinkingEffort = (() => { - const ro = rawOther as Record | undefined; - const thinking = ro?.thinking as Record | undefined; - return thinking && typeof thinking.effort === "string" && thinking.effort - ? thinking.effort - : undefined; - })(); - const inheritedEffort = modelEffort ?? thinkingEffort; - - const fmtContext = (n: number): string => - n >= 1_000_000 - ? `${(n / 1_000_000).toFixed(1)}M` - : n >= 1000 - ? `${Math.round(n / 1000)}K` - : String(n); - const effortLabel = (e: string): string => { - // Unknown values (hand-written config) are shown verbatim. - const key = EFFORT_LABELS[e as (typeof EFFORTS)[number]]; - return key ? t(key) : e; - }; - - /** - * Fallback guard before any pool-related write: validate the *target* state - * against the engine rules (mirrored by `validateSubagentPool`) and block the - * write when it would produce an invalid config. Normal UI flows never trip - * this — it protects against stale/divergent configs. - */ - const validateWrite = (nextRaw: unknown): boolean => { - const nextPool = getSubagentModelPool(nextRaw); - const errors = nextPool ? validateSubagentPool(nextPool, aliasList) : []; - setPoolErrors(errors); - return errors.length === 0; - }; - - const handleUpgrade = () => { - if (!effectiveDefault) return; - const nextRaw = setSubagentDefault(rawOther, effectiveDefault); - if (validateWrite(nextRaw)) onChange(nextRaw); - }; - - const handleSetDefault = (alias: string) => { - if (alias === effectiveDefault) return; - const nextRaw = setSubagentDefault(rawOther, alias); - if (validateWrite(nextRaw)) onChange(nextRaw); - }; - - const handleSaveDescription = (alias: string, description: string) => { - const nextRaw = upsertSubagentPoolEntry(rawOther, alias, description); - if (validateWrite(nextRaw)) onChange(nextRaw); - }; - - const handleRemoveEntry = (alias: string) => { - const nextRaw = removeSubagentPoolEntry(rawOther, alias); - if (validateWrite(nextRaw)) onChange(nextRaw); - }; - - const handleAddEntry = (alias: string) => { - const nextRaw = upsertSubagentPoolEntry(rawOther, alias, ""); - if (validateWrite(nextRaw)) onChange(nextRaw); - }; - - const handleForceChange = (next: boolean) => { - if (!pool) return; - // Turning force on drops the `models` table — confirm when it is non-empty. - if (next && Object.keys(pool.models).length > 0) { - if (!confirm(t("secondaryPoolForceConfirm"))) return; - } - const nextRaw = setSubagentForce(rawOther, next); - if (validateWrite(nextRaw)) onChange(nextRaw); - }; - - const handleClear = () => { - setPoolErrors([]); - onChange(clearSecondaryModel(rawOther)); - }; - - const renderFlagRow = (flag: ExperimentalFlagDef) => { - const labels = FLAG_LABELS[flag.id]; - // Only the flag's own env var locks the toggle. The master env force-ons - // undefined flags but an explicit config entry outranks it, so the user - // can still flip values here while it is set. - const locked = isFlagLockedByEnv(env, flag); - // Resolution order mirrors kimi-code 0.40.1 flagService: - // single-flag env > explicit [experimental] value (true *or* false) > - // master env (on-only) > upstream default (`defaultEnabled`). - const explicitlySet = isExperimentalFlagSet(rawOther, flag.id); - const on = locked - ? forcedEnvValue(env, flag) - : explicitlySet - ? flags[flag.id] === true - : masterOn - ? true - : (flag.defaultEnabled ?? false); - // Turning the flag off only sticks via an explicit `false` when the - // fallback (master env or the upstream default) would otherwise keep it on. - const explicitFalse = masterOn || flag.defaultEnabled === true; - return ( -
-
-
- {t(labels.name)} - {!locked && !explicitlySet && flag.defaultEnabled && ( - - {t("flagDefaultOn")} - - )} -
-
- {t(labels.desc)} - {flag.envVar} -
-
- {locked && ( - - 🔒 {t("lockedByEnv")} - - )} - - onChange( - setExperimentalFlag(rawOther, flag.id, checked, { explicitFalse }) - ) - } - /> -
- ); - }; return (
@@ -414,296 +43,17 @@ export function SubagentSettingsPage({
- {masterOn && ( -
- {t("masterEnvOnHint")} -
- )} - - {/* Kimi Code WebUI quick open */} - -

{t("webuiDesc")}

-
- - -
-
- - {/* models.dev reference data: online sync / restore bundled */} - -

{t("modelsDataDesc")}

- {modelsDevStatus && ( -
- - {modelsDevStatus.source === "synced" - ? t("modelsDataSourceSynced") - : t("modelsDataSourceBuiltin")} - - - {t("modelsDataStats", { - date: modelsDevStatus.lastUpdated, - models: modelsDevStatus.modelCount.toLocaleString(), - providers: modelsDevStatus.providerCount.toLocaleString(), - })} - -
- )} - {syncOk && ( -
- {t("modelsDataSyncOk")} -
- )} - {syncError && ( -
- {t("modelsDataSyncFailed")}: {syncError} -
- )} -
- - {modelsDevStatus?.source === "synced" && ( - - )} -
-
- - {/* Subagent: secondary model picker / pool + inherited settings */} - - {isPoolView && pool ? ( - <> - {pool.force ? ( - /* Force on: the `models` table was dropped — only the pinned - default is shown, entries and "add" are hidden. */ -
-
- 🔒{" "} - {t("secondaryPoolForceOn", { - alias: aliasLabel(effectiveDefault ?? ""), - })} -
-
- ) : ( - <> -
- {poolEntries.map((alias) => ( - - ))} -
-

- {t("secondaryPoolDescHint")} -

- - - )} - -
-
-
- {t("secondaryPoolForce")} -
-
- {t("secondaryPoolForceDesc")} -
-
- -
- - - - ) : ( - <> - - - {canUpgrade && ( -
- - - {t("secondaryPoolUpgradeDesc")} - -
- )} - - )} - - {poolErrors.length > 0 && ( -
- {poolErrors.map((err, i) => ( -
{t(POOL_ERROR_KEYS[err.key], err.params)}
- ))} -
- )} - -
- - {effectiveDefault !== undefined && ( -
-
- - {t("secondaryModelInheritedContext")} - - - {inheritedContext ? fmtContext(inheritedContext) : "—"} - -
-
- - {t("secondaryModelInheritedEffort")} - - - {inheritedEffort ? effortLabel(inheritedEffort) : "—"} - -
-

- {t("secondaryModelInheritedHint")} -

-
- )} - -

{t("secondaryModelPriorityHint")}

- - - {/* Experimental feature flags — one unified list */} - - {EXPERIMENTAL_FLAGS.map(renderFlagRow)} -

{t("experimentalFlagsHint")}

-
+ +
); diff --git a/src/components/settings/SettingsAdvancedTab.tsx b/src/components/settings/SettingsAdvancedTab.tsx new file mode 100644 index 0000000..fab246e --- /dev/null +++ b/src/components/settings/SettingsAdvancedTab.tsx @@ -0,0 +1,694 @@ +import { useEffect, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { useTranslation } from "../../i18n"; +import type { TranslationKey } from "../../i18n/zh"; +import type { ExperimentalEnvStatus, Model } from "../../types"; +import { + EXPERIMENTAL_FLAGS, + clearSecondaryModel, + forcedEnvValue, + getExperimentalFlags, + getSubagentModelPool, + isExperimentalFlagSet, + isFlagLockedByEnv, + isMasterEnvOn, + removeSubagentPoolEntry, + setExperimentalFlag, + setSecondaryModelOnly, + setSubagentDefault, + setSubagentForce, + upsertSubagentPoolEntry, + validateSubagentPool, + type ExperimentalFlagDef, + type PoolValidationError, + type ValidationErrorKey, +} from "../../lib/subagent-settings"; +import { Card, Toggle } from "../ui/controls"; +import { applyModelsDevSnapshot, reloadModelsDev } from "../../lib/models-dev"; + +/** Mirror of models_dev::ModelsDevStatus (camelCase over IPC). */ +interface ModelsDevStatus { + source: "synced" | "builtin"; + lastUpdated: string; + modelCount: number; + providerCount: number; +} + +interface SettingsAdvancedTabProps { + /** config.raw_other — hosts the `[experimental]` and `[secondary_model]` sections. */ + rawOther: unknown; + /** All configured models (alias → entry) for the secondary-model picker. */ + models: Record; + onChange: (nextRawOther: unknown) => void; +} + +// Effort tiers the CLI accepts; the value is forwarded verbatim upstream, and +// per-model support comes from `[models.""] support_efforts`. +const EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const; +const EFFORT_LABELS: Record<(typeof EFFORTS)[number], TranslationKey> = { + low: "thinkingLow", + medium: "thinkingMedium", + high: "thinkingHigh", + max: "thinkingMax", + xhigh: "thinkingXHigh", +}; + +const FLAG_LABELS: Record = { + wait_for: { name: "flagWaitFor", desc: "flagWaitForDesc" }, + "tool-select": { name: "flagToolSelect", desc: "flagToolSelectDesc" }, + notify_user: { name: "flagNotifyUser", desc: "flagNotifyUserDesc" }, + tower: { name: "flagTower", desc: "flagTowerDesc" }, + subagent_fork: { name: "flagSubagentFork", desc: "flagSubagentForkDesc" }, +}; + +/** Validation-error i18n key per engine rule, for the pre-write self-check. */ +const POOL_ERROR_KEYS: Record = { + primaryReserved: "secondaryPoolErrorPrimaryReserved", + defaultRequired: "secondaryPoolErrorDefaultRequired", + defaultNotInPool: "secondaryPoolErrorDefaultNotInPool", + aliasNotInModels: "secondaryPoolErrorAliasNotInModels", + forceRequiresDefault: "secondaryPoolErrorForceRequiresDefault", + forceExcludesModels: "secondaryPoolErrorForceExcludesModels", +}; + +/** + * One row of the subagent model pool: default radio + alias + editable + * description (saved on blur/Enter via the parent's write chain) + remove. + * The description input keeps a local draft so typing is not persisted per + * keystroke; everything else is derived from `rawOther` on each render. + */ +function PoolEntryRow({ + alias, + displayName, + description, + isDefault, + canRemove, + onSetDefault, + onSave, + onRemove, +}: { + alias: string; + displayName: string | null; + description: string; + isDefault: boolean; + canRemove: boolean; + onSetDefault: (alias: string) => void; + onSave: (alias: string, description: string) => void; + onRemove: (alias: string) => void; +}) { + const { t } = useTranslation(); + const [draft, setDraft] = useState(description); + + const commit = () => { + if (draft === description) return; + onSave(alias, draft); + }; + + return ( +
+ onSetDefault(alias)} + className="shrink-0 accent-blue-600 focus:ring-blue-500" + /> + {alias} + {displayName && ( + {displayName} + )} + {isDefault && ( + + {t("default")} + + )} + setDraft(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + (e.currentTarget as HTMLInputElement).blur(); + } else if (e.key === "Escape") { + setDraft(description); + (e.currentTarget as HTMLInputElement).blur(); + } + }} + className="flex-1 min-w-0 bg-input border border-border rounded px-2 py-1 text-xs focus:ring-2 focus:ring-blue-500 focus:outline-none" + /> + {canRemove && ( + + )} +
+ ); +} + +export function SettingsAdvancedTab({ + rawOther, + models, + onChange, +}: SettingsAdvancedTabProps) { + const { t } = useTranslation(); + const [env, setEnv] = useState(null); + /** Pre-write validation failures (fallback guard; normal flows stay clear). */ + const [poolErrors, setPoolErrors] = useState([]); + /** Local draft for the "add pool entry" select (reset after each pick). */ + const [addSelection, setAddSelection] = useState(""); + /** WebUI-open button in flight; disables both buttons while non-null. */ + const [webuiBusy, setWebuiBusy] = useState<"embedded" | "browser" | null>(null); + /** models.dev snapshot provenance (synced copy vs bundled asset). */ + const [modelsDevStatus, setModelsDevStatus] = useState(null); + /** Sync/restore button in flight; blocks the card's buttons. */ + const [modelsDevBusy, setModelsDevBusy] = useState(false); + /** Last sync error (inline) and success flag (auto-cleared on next action). */ + const [syncError, setSyncError] = useState(null); + const [syncOk, setSyncOk] = useState(false); + + useEffect(() => { + invoke("get_experimental_env_status") + .then(setEnv) + .catch(() => setEnv({})); + invoke("get_models_dev_status") + .then(setModelsDevStatus) + .catch(() => setModelsDevStatus(null)); + }, []); + + const refreshModelsDevStatus = () => { + invoke("get_models_dev_status") + .then(setModelsDevStatus) + .catch(() => {}); + }; + + const handleModelsDevSync = async () => { + setModelsDevBusy(true); + setSyncError(null); + setSyncOk(false); + try { + const st = await invoke("sync_models_dev"); + setModelsDevStatus(st); + // Hot-swap the frontend index; the dashboard price index rebuilds on + // its next query (mtime-triggered on the Rust side). + const raw = await invoke("get_models_dev_snapshot"); + if (raw) applyModelsDevSnapshot(JSON.parse(raw) as Record); + setSyncOk(true); + } catch (err) { + setSyncError(err instanceof Error ? err.message : String(err)); + } finally { + setModelsDevBusy(false); + } + }; + + const handleModelsDevRestore = async () => { + if (!confirm(t("modelsDataRestoreConfirm"))) return; + setModelsDevBusy(true); + setSyncError(null); + setSyncOk(false); + try { + const st = await invoke("reset_models_dev"); + setModelsDevStatus(st); + await reloadModelsDev(); + } catch (err) { + setSyncError(err instanceof Error ? err.message : String(err)); + refreshModelsDevStatus(); + } finally { + setModelsDevBusy(false); + } + }; + + const flags = getExperimentalFlags(rawOther); + const pool = getSubagentModelPool(rawOther); + const poolKeys = pool ? Object.keys(pool.models) : []; + // View selection: no/empty section or a bare legacy `model` → simple view; + // a `models` table or `default_model` → pool management view. + const isPoolView = + !!pool && (poolKeys.length > 0 || pool.defaultModel !== undefined); + // Effective default: v2 pool key first, legacy v1 recipe key as fallback. + const effectiveDefault = pool ? pool.defaultModel ?? pool.model : undefined; + // "Upgrade" only makes sense for the bare legacy form and when `force` is + // off — upgrading with force would create an invalid force+pool-table combo. + const canUpgrade = !!pool && !isPoolView && pool.model !== undefined && !pool.force; + const masterOn = isMasterEnvOn(env); + + const aliasList = Object.keys(models).sort(); + + const aliasLabel = (alias: string): string => + models[alias]?.display_name + ? `${alias}(${models[alias].display_name})` + : alias; + + // Rows of the pool view: every `models` table key plus the effective default + // when it is not already in the table (the implicit single-entry form). + const implicitDefault = + pool && effectiveDefault && !(effectiveDefault in pool.models) + ? [effectiveDefault] + : []; + const poolEntries = [...poolKeys, ...implicitDefault]; + // Aliases that can still be added to the pool (not already in the table). + const addableAliases = aliasList.filter((a) => !(a in (pool?.models ?? {}))); + + // Inherited settings shown for the picked model: subagents bind the pointed + // entry directly (no patch fields), so context size and effort come from the + // model entry / global [thinking], not from a `[secondary_model]` patch. + const selectedEntry = effectiveDefault ? models[effectiveDefault] : undefined; + const inheritedContext = selectedEntry?.max_context_size; + const modelEffort = + selectedEntry && + typeof (selectedEntry.raw_other as Record | undefined) + ?.default_effort === "string" + ? ((selectedEntry.raw_other as Record).default_effort as string) + : undefined; + const thinkingEffort = (() => { + const ro = rawOther as Record | undefined; + const thinking = ro?.thinking as Record | undefined; + return thinking && typeof thinking.effort === "string" && thinking.effort + ? thinking.effort + : undefined; + })(); + const inheritedEffort = modelEffort ?? thinkingEffort; + + const fmtContext = (n: number): string => + n >= 1_000_000 + ? `${(n / 1_000_000).toFixed(1)}M` + : n >= 1000 + ? `${Math.round(n / 1000)}K` + : String(n); + const effortLabel = (e: string): string => { + // Unknown values (hand-written config) are shown verbatim. + const key = EFFORT_LABELS[e as (typeof EFFORTS)[number]]; + return key ? t(key) : e; + }; + + /** + * Fallback guard before any pool-related write: validate the *target* state + * against the engine rules (mirrored by `validateSubagentPool`) and block the + * write when it would produce an invalid config. Normal UI flows never trip + * this — it protects against stale/divergent configs. + */ + const validateWrite = (nextRaw: unknown): boolean => { + const nextPool = getSubagentModelPool(nextRaw); + const errors = nextPool ? validateSubagentPool(nextPool, aliasList) : []; + setPoolErrors(errors); + return errors.length === 0; + }; + + const handleUpgrade = () => { + if (!effectiveDefault) return; + const nextRaw = setSubagentDefault(rawOther, effectiveDefault); + if (validateWrite(nextRaw)) onChange(nextRaw); + }; + + const handleSetDefault = (alias: string) => { + if (alias === effectiveDefault) return; + const nextRaw = setSubagentDefault(rawOther, alias); + if (validateWrite(nextRaw)) onChange(nextRaw); + }; + + const handleSaveDescription = (alias: string, description: string) => { + const nextRaw = upsertSubagentPoolEntry(rawOther, alias, description); + if (validateWrite(nextRaw)) onChange(nextRaw); + }; + + const handleRemoveEntry = (alias: string) => { + const nextRaw = removeSubagentPoolEntry(rawOther, alias); + if (validateWrite(nextRaw)) onChange(nextRaw); + }; + + const handleAddEntry = (alias: string) => { + const nextRaw = upsertSubagentPoolEntry(rawOther, alias, ""); + if (validateWrite(nextRaw)) onChange(nextRaw); + }; + + const handleForceChange = (next: boolean) => { + if (!pool) return; + // Turning force on drops the `models` table — confirm when it is non-empty. + if (next && Object.keys(pool.models).length > 0) { + if (!confirm(t("secondaryPoolForceConfirm"))) return; + } + const nextRaw = setSubagentForce(rawOther, next); + if (validateWrite(nextRaw)) onChange(nextRaw); + }; + + const handleClear = () => { + setPoolErrors([]); + onChange(clearSecondaryModel(rawOther)); + }; + + const renderFlagRow = (flag: ExperimentalFlagDef) => { + const labels = FLAG_LABELS[flag.id]; + // Only the flag's own env var locks the toggle. The master env force-ons + // undefined flags but an explicit config entry outranks it, so the user + // can still flip values here while it is set. + const locked = isFlagLockedByEnv(env, flag); + // Resolution order mirrors kimi-code 0.40.1 flagService: + // single-flag env > explicit [experimental] value (true *or* false) > + // master env (on-only) > upstream default (`defaultEnabled`). + const explicitlySet = isExperimentalFlagSet(rawOther, flag.id); + const on = locked + ? forcedEnvValue(env, flag) + : explicitlySet + ? flags[flag.id] === true + : masterOn + ? true + : (flag.defaultEnabled ?? false); + // Turning the flag off only sticks via an explicit `false` when the + // fallback (master env or the upstream default) would otherwise keep it on. + const explicitFalse = masterOn || flag.defaultEnabled === true; + return ( +
+
+
+ {t(labels.name)} + {!locked && !explicitlySet && flag.defaultEnabled && ( + + {t("flagDefaultOn")} + + )} +
+
+ {t(labels.desc)} + {flag.envVar} +
+
+ {locked && ( + + 🔒 {t("lockedByEnv")} + + )} + + onChange( + setExperimentalFlag(rawOther, flag.id, checked, { explicitFalse }) + ) + } + /> +
+ ); + }; + + return ( +
+ {masterOn && ( +
+ {t("masterEnvOnHint")} +
+ )} + + {/* Kimi Code WebUI quick open */} + +

{t("webuiDesc")}

+
+ + +
+
+ + {/* models.dev reference data: online sync / restore bundled */} + +

{t("modelsDataDesc")}

+ {modelsDevStatus && ( +
+ + {modelsDevStatus.source === "synced" + ? t("modelsDataSourceSynced") + : t("modelsDataSourceBuiltin")} + + + {t("modelsDataStats", { + date: modelsDevStatus.lastUpdated, + models: modelsDevStatus.modelCount.toLocaleString(), + providers: modelsDevStatus.providerCount.toLocaleString(), + })} + +
+ )} + {syncOk && ( +
+ {t("modelsDataSyncOk")} +
+ )} + {syncError && ( +
+ {t("modelsDataSyncFailed")}: {syncError} +
+ )} +
+ + {modelsDevStatus?.source === "synced" && ( + + )} +
+
+ + {/* Subagent: secondary model picker / pool + inherited settings */} + + {isPoolView && pool ? ( + <> + {pool.force ? ( + /* Force on: the `models` table was dropped — only the pinned + default is shown, entries and "add" are hidden. */ +
+
+ 🔒{" "} + {t("secondaryPoolForceOn", { + alias: aliasLabel(effectiveDefault ?? ""), + })} +
+
+ ) : ( + <> +
+ {poolEntries.map((alias) => ( + + ))} +
+

+ {t("secondaryPoolDescHint")} +

+ + + )} + +
+
+
+ {t("secondaryPoolForce")} +
+
+ {t("secondaryPoolForceDesc")} +
+
+ +
+ + + + ) : ( + <> + + + {canUpgrade && ( +
+ + + {t("secondaryPoolUpgradeDesc")} + +
+ )} + + )} + + {poolErrors.length > 0 && ( +
+ {poolErrors.map((err, i) => ( +
{t(POOL_ERROR_KEYS[err.key], err.params)}
+ ))} +
+ )} + +
+ + {effectiveDefault !== undefined && ( +
+
+ + {t("secondaryModelInheritedContext")} + + + {inheritedContext ? fmtContext(inheritedContext) : "—"} + +
+
+ + {t("secondaryModelInheritedEffort")} + + + {inheritedEffort ? effortLabel(inheritedEffort) : "—"} + +
+

+ {t("secondaryModelInheritedHint")} +

+
+ )} + +

{t("secondaryModelPriorityHint")}

+ + + {/* Experimental feature flags — one unified list */} + + {EXPERIMENTAL_FLAGS.map(renderFlagRow)} +

{t("experimentalFlagsHint")}

+
+
+ ); +} diff --git a/src/components/settings/SettingsGeneralTab.tsx b/src/components/settings/SettingsGeneralTab.tsx new file mode 100644 index 0000000..6b75d0f --- /dev/null +++ b/src/components/settings/SettingsGeneralTab.tsx @@ -0,0 +1,532 @@ +import { useEffect, useState, type ReactNode } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { useTranslation, type Language } from "../../i18n"; +import { useTheme, type Theme } from "../../hooks/useTheme"; +import type { UpdateInfo } from "../../hooks/useUpdateCheck"; +import { + useKimiCodeVersion, + isNewerVersion, + type KimiCodeStatus, +} from "../../hooks/useKimiCodeVersion"; + +function formatVersion(version: string): string { + const v = version.trim(); + return v.startsWith("v") || v.startsWith("V") ? v : `v${v}`; +} + +/** 上游 kimi-code releases 页面。 */ +const KIMI_CODE_RELEASES_URL = "https://github.com/MoonshotAI/kimi-code/releases"; + +interface SettingsGeneralTabProps { + updateInfo: UpdateInfo | null; + checking: boolean; + onCheckUpdate: () => void; + lastChecked: number | null; +} + +export function SettingsGeneralTab({ + updateInfo, + checking, + onCheckUpdate, + lastChecked, +}: SettingsGeneralTabProps) { + const { t, lang, setLang } = useTranslation(); + const { theme, setTheme } = useTheme(); + const { + info: kimiCodeInfo, + checking: kimiCodeChecking, + checkNow: checkKimiCode, + } = useKimiCodeVersion(); + const [appVersion, setAppVersion] = useState(""); + const [downloadProgress, setDownloadProgress] = useState(null); + const [downloadedPath, setDownloadedPath] = useState(null); + const [downloadError, setDownloadError] = useState(null); + + useEffect(() => { + invoke("get_app_version").then(setAppVersion).catch(() => {}); + }, []); + + // Listen to download progress events + useEffect(() => { + const unlisteners: (() => void)[] = []; + + const setupProgress = async () => { + const un1 = await listen<{ downloaded: number; total: number; progress: number }>( + "download-progress", + (event) => { + setDownloadProgress(event.payload.progress); + } + ); + unlisteners.push(() => un1()); + + const un2 = await listen<{ path: string }>( + "download-complete", + (event) => { + setDownloadedPath(event.payload.path); + setDownloadProgress(100); + } + ); + unlisteners.push(() => un2()); + }; + + setupProgress(); + + return () => unlisteners.forEach((fn) => fn()); + }, []); + + const handleDownload = async () => { + // No installer asset for this OS in the release (e.g. a release published + // without a Windows package, or a renamed asset): silently returning would + // look like a dead button. Fall back to the release page instead, and say + // why when even that URL is missing. + if (!updateInfo?.downloadUrl) { + if (updateInfo?.releaseUrl) { + openExternal(updateInfo.releaseUrl); + setDownloadError(null); + } else { + setDownloadError(t("updateNoDownload")); + } + return; + } + setDownloadProgress(0); + setDownloadError(null); + setDownloadedPath(null); + try { + const path = await invoke("download_update", { + url: updateInfo.downloadUrl, + }); + setDownloadedPath(path); + setDownloadProgress(100); + } catch (e) { + setDownloadError(e instanceof Error ? e.message : String(e)); + setDownloadProgress(null); + } + }; + + const handleInstall = () => { + if (!downloadedPath) return; + invoke("open_installer", { path: downloadedPath }).catch(() => {}); + }; + + // Open an external link via the Rust-side opener command (bypasses the JS + // plugin scope); fall back to a new tab when even that fails. + const openExternal = (url: string) => { + invoke("open_external_url", { url }).catch(() => { + const w = window.open(url, "_blank"); + if (!w) console.error(`failed to open ${url}`); + }); + }; + + const themeOptions: { value: Theme; icon: ReactNode; label: string }[] = [ + { + value: "light", + icon: ( + + + + + ), + label: t("themeLight"), + }, + { + value: "dark", + icon: ( + + + + ), + label: t("themeDark"), + }, + { + value: "auto", + icon: ( + + + + + ), + label: t("themeAuto"), + }, + ]; + + const fmtLastChecked = (ts: number | null) => { + if (!ts) return "—"; + const d = new Date(ts); + return d.toLocaleString(lang === "zh" ? "zh-CN" : "en-US"); + }; + + // 状态徽标:绿=已适配 / 黄=建议升级 KimiSwitch / 橙=部分兼容 / 灰=未知或未安装 + const kimiCodeBadge = (status: KimiCodeStatus) => { + switch (status) { + case "current": + return { + label: t("kimiCodeStatusCurrent"), + className: + "bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 border-green-300 dark:border-green-500/30", + }; + case "outdated": + return { + label: t("kimiCodeStatusOutdated"), + className: + "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 border-yellow-300 dark:border-yellow-500/30", + }; + case "partial": + return { + label: t("kimiCodeStatusPartial"), + className: + "bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 border-orange-300 dark:border-orange-500/30", + }; + case "unknown": + return { + label: t("kimiCodeStatusUnknown"), + className: "bg-hover-2 text-content-muted border-border", + }; + default: + return { + label: t("kimiCodeStatusNotInstalled"), + className: "bg-hover-2 text-content-muted border-border", + }; + } + }; + + const kimiCodeUpdateAvailable = + !!kimiCodeInfo?.installed && + !!kimiCodeInfo?.latest && + isNewerVersion(kimiCodeInfo.latest, kimiCodeInfo.installed); + + const kimiCodeBadgeInfo = kimiCodeInfo ? kimiCodeBadge(kimiCodeInfo.status) : null; + + return ( +
+ {/* Appearance */} +
+

+ {t("settingsAppearance")} +

+
+ {/* Theme */} +
+ + {t("themeLight")} / {t("themeDark")} + +
+ {themeOptions.map((opt) => ( + + ))} +
+
+ {/* Language */} +
+ {t("language")} + +
+
+
+ + {/* Updates */} +
+

+ {t("settingsUpdates")} +

+
+
+ {t("currentVersion")} + + v{appVersion || "—"} + +
+
+ {t("latestVersion")} + + {updateInfo ? formatVersion(updateInfo.latest) : "—"} + +
+
+ {t("lastChecked")} + + {fmtLastChecked(lastChecked)} + +
+
+ + {updateInfo?.updateAvailable && downloadProgress === null && !downloadedPath && ( + + )} + {downloadedPath && ( + + )} +
+ {/* Download progress bar */} + {downloadProgress !== null && downloadProgress < 100 && ( +
+
+ {t("downloading")} + {downloadProgress}% +
+
+
+
+
+ )} + {downloadedPath && ( +
+ ✓ {t("downloadComplete")} +
+ )} + {downloadError && ( +
+ {t("downloadFailed")}: {downloadError} +
+ )} + {updateInfo?.updateAvailable && ( +
+ ✦ {t("updateAvailable")}: {formatVersion(updateInfo.latest)} +
+ )} + {updateInfo && !updateInfo.updateAvailable && ( +
+ ✓ {t("upToDate")} +
+ )} +
+
+ + {/* Kimi Code CLI version & compatibility */} +
+

+ {t("kimiCodeSection")} +

+
+
+ {t("kimiCodeInstalled")} + + {kimiCodeInfo?.installed ? formatVersion(kimiCodeInfo.installed) : "—"} + +
+
+ {t("kimiCodeLatest")} + + {kimiCodeInfo?.latest ? formatVersion(kimiCodeInfo.latest) : "—"} + +
+
+ {t("kimiCodeStatusLabel")} + {kimiCodeBadgeInfo ? ( + + {kimiCodeBadgeInfo.label} + + ) : ( + — + )} +
+
+ + {kimiCodeUpdateAvailable && ( + + )} +
+
+
+ + {/* About */} +
+

+ {t("settingsAbout")} +

+
+
Kimi Switch
+
{t("appDescription")}
+
v{appVersion} · MIT License
+
+
+ {t("settingsWebsite")}:{" "} + +
+
+ {t("kimiEyesSite")}:{" "} + +
+
+ {t("openTerminalSite")}:{" "} + +
+
+ {t("attributionPorted")}{" "} + + {t("attributionSuffix")} +
+
+ {t("modelDataSourcePrefix")}{" "} + +
+
+ {t("referenceProject")}: + + (MIT, © Jason Young) +
+
+
+
+
+ ); +} diff --git a/src/components/settings/SettingsGlobalTab.tsx b/src/components/settings/SettingsGlobalTab.tsx new file mode 100644 index 0000000..7a50830 --- /dev/null +++ b/src/components/settings/SettingsGlobalTab.tsx @@ -0,0 +1,32 @@ +import { AgentSettingsPanel } from "../AgentSettingsPanel"; +import type { Model } from "../../types"; + +interface SettingsGlobalTabProps { + rawOther: unknown; + models: Record; + defaultModel: string | null; + onChange: (nextRawOther: unknown) => void; +} + +/** + * Global agent settings (thinking / loop control / permission rules / hooks), + * which live in the shared `config.raw_other` and therefore apply to every + * provider rather than to a single one. + */ +export function SettingsGlobalTab({ + rawOther, + models, + defaultModel, + onChange, +}: SettingsGlobalTabProps) { + return ( +
+ +
+ ); +} diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx new file mode 100644 index 0000000..0d76327 --- /dev/null +++ b/src/components/settings/SettingsPage.tsx @@ -0,0 +1,98 @@ +import { useTranslation } from "../../i18n"; +import type { UpdateInfo } from "../../hooks/useUpdateCheck"; +import type { Model } from "../../types"; +import { SettingsGeneralTab } from "./SettingsGeneralTab"; +import { SettingsGlobalTab } from "./SettingsGlobalTab"; +import { SettingsAdvancedTab } from "./SettingsAdvancedTab"; + +type SettingsTab = "general" | "global" | "advanced"; + +interface SettingsPageProps { + activeTab: SettingsTab; + onTabChange: (tab: SettingsTab) => void; + updateInfo: UpdateInfo | null; + checking: boolean; + onCheckUpdate: () => void; + lastChecked: number | null; + rawOther: unknown; + models: Record; + defaultModel: string | null; + onRawOtherChange: (nextRawOther: unknown) => void; +} + +/** + * Full-page settings shell: a tab bar and three tab panels. No back header — + * the top nav group already offers peer-level switching. Every panel stays + * mounted — the inactive ones are only hidden — so the update download state + * (progress + installer path) survives tab switches. + */ +export function SettingsPage({ + activeTab, + onTabChange, + updateInfo, + checking, + onCheckUpdate, + lastChecked, + rawOther, + models, + defaultModel, + onRawOtherChange, +}: SettingsPageProps) { + const { t } = useTranslation(); + + const tabs: { id: SettingsTab; label: string }[] = [ + { id: "global", label: t("settingsTabGlobal") }, + { id: "advanced", label: t("subagentSettings") }, + { id: "general", label: t("settingsAbout") }, + ]; + + return ( +
+ {/* Tab bar —— 无返回头:顶部导航组已可平级切换,设置页是平级视图 */} +
+
+ {tabs.map((tab) => ( + + ))} +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ ); +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 473c567..6cbc5de 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -319,6 +319,7 @@ export const enTranslations: Record = { themeAuto: "Auto", settings: "Settings", settingsTitle: "Settings", + settingsTabGlobal: "Global Settings", settingsAppearance: "Appearance", settingsUpdates: "Updates", settingsAbout: "About", @@ -333,6 +334,9 @@ export const enTranslations: Record = { downloadComplete: "Download complete, click to install", install: "Install", updateCheckFailed: "Update check failed", + updateNoDownload: "No installer available for this release — open the release page to download manually", + openReleasePage: "Open release page", + downloadFailed: "Download failed", lastChecked: "Last checked", // Kimi Code version tracking kimiCodeSection: "Kimi Code version", @@ -349,6 +353,7 @@ export const enTranslations: Record = { appDescription: "Windows desktop LLM provider config manager", settingsWebsite: "Official website", kimiEyesSite: "Kimi Eyes plugin site", + openTerminalSite: "OpenTerminal site", referenceProject: "Reference project", // Validation errors diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index a575f75..71d9d56 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -308,6 +308,7 @@ export const zhTranslations = { themeAuto: "跟随系统", settings: "设置", settingsTitle: "设置", + settingsTabGlobal: "全局设置", settingsAppearance: "外观", settingsUpdates: "更新", settingsAbout: "关于", @@ -322,6 +323,9 @@ export const zhTranslations = { downloadComplete: "下载完成,点击安装", install: "安装", updateCheckFailed: "检查更新失败", + updateNoDownload: "该版本没有可用的安装包,请前往 Release 页面手动下载", + openReleasePage: "打开下载页", + downloadFailed: "下载失败", lastChecked: "上次检查", // Kimi Code 版本跟踪 kimiCodeSection: "Kimi Code 版本", @@ -338,6 +342,7 @@ export const zhTranslations = { appDescription: "Windows 桌面端 LLM 供应商配置管理器", settingsWebsite: "官方网站", kimiEyesSite: "Kimi Eyes 插件站", + openTerminalSite: "OpenTerminal 项目站", referenceProject: "引用项目", // Validation errors diff --git a/src/lib/models-dev-full.json b/src/lib/models-dev-full.json index 3382896..3ea0efd 100644 --- a/src/lib/models-dev-full.json +++ b/src/lib/models-dev-full.json @@ -8041,9 +8041,9 @@ "id": "deepseek/deepseek-latest", "name": "DeepSeek Latest", "cost": { - "input": 0.8957, - "output": 0.99, - "cache_read": 0.1866 + "input": 1.1, + "output": 2.5, + "cache_read": 0.04 }, "context": 1048576, "input_limit": 1048576, @@ -8086,9 +8086,9 @@ "id": "deepseek/deepseek-v4-pro-0813:thinking", "name": "DeepSeek V4 Pro 0813 Thinking", "cost": { - "input": 0.8957, - "output": 0.99, - "cache_read": 0.1866 + "input": 1.1, + "output": 2.5, + "cache_read": 0.04 }, "context": 1048576, "input_limit": 1048576, @@ -8101,9 +8101,9 @@ "id": "deepseek/deepseek-v4-pro-0813", "name": "DeepSeek V4 Pro 0813", "cost": { - "input": 0.8957, - "output": 0.99, - "cache_read": 0.1866 + "input": 1.1, + "output": 2.5, + "cache_read": 0.04 }, "context": 1048576, "input_limit": 1048576, @@ -8157,21 +8157,6 @@ "tool_call": true, "structured_output": true }, - { - "id": "deepseek/deepseek-v4-flash-vision-exp-cybersecurity", - "name": "DeepSeek V4 Flash Vision Exp Cybersecurity", - "cost": { - "input": 0.15, - "output": 0.6, - "cache_read": 0.075 - }, - "context": 1048576, - "input_limit": 1048576, - "output_limit": 32768, - "reasoning": true, - "tool_call": true, - "image": true - }, { "id": "deepseek/deepseek-v3.2", "name": "DeepSeek V3.2", @@ -11376,9 +11361,9 @@ "id": "moonshotai/kimi-k2.6", "name": "Kimi K2.6", "cost": { - "input": 0.441, - "output": 2.45, - "cache_read": 0.099 + "input": 0.5, + "output": 2.6, + "cache_read": 0.125 }, "context": 262144, "input_limit": 262144, @@ -11448,9 +11433,9 @@ "id": "moonshotai/kimi-k2.6:thinking", "name": "Kimi K2.6 Thinking", "cost": { - "input": 0.441, - "output": 2.45, - "cache_read": 0.099 + "input": 0.5, + "output": 2.6, + "cache_read": 0.125 }, "context": 262144, "input_limit": 262144, @@ -26364,21 +26349,6 @@ "structured_output": true, "image": true }, - { - "id": "anthropic/claude-sonnet-4", - "name": "Anthropic: Claude Sonnet 4 (retires Oct 15)", - "cost": { - "input": 3, - "output": 15, - "cache_read": 0.3, - "cache_write": 3.75 - }, - "context": 200000, - "output_limit": 64000, - "reasoning": true, - "tool_call": true, - "image": true - }, { "id": "anthropic/claude-sonnet-5", "name": "Claude Sonnet 5", @@ -27608,9 +27578,9 @@ "id": "~z-ai/glm-latest", "name": "Z.ai: GLM Latest", "cost": { - "input": 0.063, + "input": 0.0608, "output": 4.4, - "cache_read": 0.0586 + "cache_read": 0.0565 }, "context": 1048576, "output_limit": 131072, @@ -27984,9 +27954,9 @@ "id": "~deepseek/deepseek-pro-latest", "name": "DeepSeek: DeepSeek Pro Latest", "cost": { - "input": 0.225, + "input": 0.1307, "output": 8, - "cache_read": 0.23 + "cache_read": 0.13 }, "context": 1048576, "output_limit": 393216, @@ -28667,7 +28637,7 @@ "output": 0.3, "cache_read": 0.01 }, - "context": 128000, + "context": 256000, "output_limit": 16384, "tool_call": true, "structured_output": true, @@ -30268,7 +30238,7 @@ "output": 2.7 }, "context": 262144, - "output_limit": 81920, + "output_limit": 65536, "reasoning": true, "tool_call": true, "structured_output": true, @@ -39799,21 +39769,6 @@ "structured_output": true, "image": true }, - { - "id": "anthropic/claude-sonnet-4", - "name": "Claude Sonnet 4", - "cost": { - "input": 3, - "output": 15, - "cache_read": 0.3, - "cache_write": 3.75 - }, - "context": 200000, - "output_limit": 64000, - "reasoning": true, - "tool_call": true, - "image": true - }, { "id": "anthropic/claude-sonnet-5", "name": "Claude Sonnet 5", @@ -40103,9 +40058,9 @@ "id": "deepseek/deepseek-v4-pro", "name": "DeepSeek V4 Pro", "cost": { - "input": 0.95526, - "output": 1.91052, - "cache_read": 0.079605 + "input": 0.43935, + "output": 0.8787, + "cache_read": 0.036613 }, "context": 1048576, "output_limit": 384000, @@ -40611,9 +40566,9 @@ "id": "z-ai/glm-5.2", "name": "GLM-5.2", "cost": { - "input": 0.02, + "input": 0.06, "output": 7, - "cache_read": 0.019 + "cache_read": 0.059 }, "context": 1048576, "output_limit": 943718, @@ -40665,9 +40620,9 @@ "id": "z-ai/glm-5.3", "name": "GLM-5.3", "cost": { - "input": 0.079, - "output": 4.8, - "cache_read": 0.078 + "input": 0.4, + "output": 8, + "cache_read": 0.399 }, "context": 1048576, "output_limit": 943718, @@ -41054,9 +41009,9 @@ "id": "~z-ai/glm-latest", "name": "GLM Latest", "cost": { - "input": 0.063, + "input": 0.0608, "output": 4.4, - "cache_read": 0.0586 + "cache_read": 0.0565 }, "context": 1048576, "output_limit": 131072, @@ -41430,9 +41385,9 @@ "id": "~deepseek/deepseek-pro-latest", "name": "DeepSeek Pro Latest", "cost": { - "input": 0.225, + "input": 0.1307, "output": 8, - "cache_read": 0.23 + "cache_read": 0.13 }, "context": 1048576, "output_limit": 393216, @@ -42135,8 +42090,8 @@ "id": "mistralai/mistral-small-3.2-24b-instruct", "name": "Mistral Small 3.2 24B", "cost": { - "input": 0.075, - "output": 0.2 + "input": 0.09375, + "output": 0.25 }, "context": 256000, "output_limit": 16384, @@ -43647,11 +43602,11 @@ "id": "qwen/qwen3.6-27b", "name": "Qwen3.6 27B", "cost": { - "input": 0.32, - "output": 3.2 + "input": 0.45, + "output": 2.7 }, "context": 262144, - "output_limit": 81920, + "output_limit": 65536, "reasoning": true, "tool_call": true, "structured_output": true, diff --git a/src/lib/models-dev.json b/src/lib/models-dev.json index d4e50e6..9aad7de 100644 --- a/src/lib/models-dev.json +++ b/src/lib/models-dev.json @@ -7166,9 +7166,9 @@ "tool_call": true, "structured_output": true, "cost": { - "input": 0.8957, - "output": 0.99, - "cache_read": 0.1866 + "input": 1.1, + "output": 2.5, + "cache_read": 0.04 } }, "nano-gpt/deepseek/deepseek-v4-flash-0423:thinking": { @@ -7205,9 +7205,9 @@ "tool_call": true, "structured_output": true, "cost": { - "input": 0.8957, - "output": 0.99, - "cache_read": 0.1866 + "input": 1.1, + "output": 2.5, + "cache_read": 0.04 } }, "nano-gpt/deepseek/deepseek-v4-pro-0813": { @@ -7218,9 +7218,9 @@ "tool_call": true, "structured_output": true, "cost": { - "input": 0.8957, - "output": 0.99, - "cache_read": 0.1866 + "input": 1.1, + "output": 2.5, + "cache_read": 0.04 } }, "nano-gpt/deepseek/deepseek-v4-flash-0731:thinking": { @@ -7262,19 +7262,6 @@ "cache_read": 0.013 } }, - "nano-gpt/deepseek/deepseek-v4-flash-vision-exp-cybersecurity": { - "name": "DeepSeek V4 Flash Vision Exp Cybersecurity", - "context": 1048576, - "output": 32768, - "reasoning": true, - "tool_call": true, - "image": true, - "cost": { - "input": 0.15, - "output": 0.6, - "cache_read": 0.075 - } - }, "nano-gpt/deepseek/deepseek-v3.2": { "name": "DeepSeek V3.2", "context": 163000, @@ -10032,9 +10019,9 @@ "tool_call": true, "image": true, "cost": { - "input": 0.441, - "output": 2.45, - "cache_read": 0.099 + "input": 0.5, + "output": 2.6, + "cache_read": 0.125 } }, "nano-gpt/moonshotai/kimi-k2.5:thinking": { @@ -10095,9 +10082,9 @@ "tool_call": true, "image": true, "cost": { - "input": 0.441, - "output": 2.45, - "cache_read": 0.099 + "input": 0.5, + "output": 2.6, + "cache_read": 0.125 } }, "nano-gpt/moonshotai/kimi-k2-instruct-0711": { @@ -23499,20 +23486,6 @@ "cache_write": 12.5 } }, - "kilo/anthropic/claude-sonnet-4": { - "name": "Anthropic: Claude Sonnet 4 (retires Oct 15)", - "context": 200000, - "output": 64000, - "reasoning": true, - "tool_call": true, - "image": true, - "cost": { - "input": 3, - "output": 15, - "cache_read": 0.3, - "cache_write": 3.75 - } - }, "kilo/anthropic/claude-sonnet-5": { "name": "Claude Sonnet 5", "context": 1000000, @@ -24653,9 +24626,9 @@ "tool_call": true, "structured_output": true, "cost": { - "input": 0.063, + "input": 0.0608, "output": 4.4, - "cache_read": 0.0586 + "cache_read": 0.0565 } }, "kilo/arcee-ai/trinity-large-thinking": { @@ -25003,9 +24976,9 @@ "tool_call": true, "structured_output": true, "cost": { - "input": 0.225, + "input": 0.1307, "output": 8, - "cache_read": 0.23 + "cache_read": 0.13 } }, "kilo/~deepseek/deepseek-v4-flash-latest": { @@ -25629,7 +25602,7 @@ }, "kilo/mistralai/mistral-small-3.2-24b-instruct": { "name": "Mistral Small 3.2 24B", - "context": 128000, + "context": 256000, "output": 16384, "tool_call": true, "structured_output": true, @@ -27111,7 +27084,7 @@ "kilo/qwen/qwen3.6-27b": { "name": "Qwen3.6 27B", "context": 262144, - "output": 81920, + "output": 65536, "reasoning": true, "tool_call": true, "structured_output": true, @@ -35752,20 +35725,6 @@ "cache_write": 12.5 } }, - "openrouter/anthropic/claude-sonnet-4": { - "name": "Claude Sonnet 4", - "context": 200000, - "output": 64000, - "reasoning": true, - "tool_call": true, - "image": true, - "cost": { - "input": 3, - "output": 15, - "cache_read": 0.3, - "cache_write": 3.75 - } - }, "openrouter/anthropic/claude-sonnet-5": { "name": "Claude Sonnet 5", "context": 1000000, @@ -36038,9 +35997,9 @@ "tool_call": true, "structured_output": true, "cost": { - "input": 0.95526, - "output": 1.91052, - "cache_read": 0.079605 + "input": 0.43935, + "output": 0.8787, + "cache_read": 0.036613 } }, "openrouter/deepseek/deepseek-r1-0528": { @@ -36507,9 +36466,9 @@ "tool_call": true, "structured_output": true, "cost": { - "input": 0.02, + "input": 0.06, "output": 7, - "cache_read": 0.019 + "cache_read": 0.059 } }, "openrouter/z-ai/glm-5.1": { @@ -36557,9 +36516,9 @@ "tool_call": true, "structured_output": true, "cost": { - "input": 0.079, - "output": 4.8, - "cache_read": 0.078 + "input": 0.4, + "output": 8, + "cache_read": 0.399 } }, "openrouter/inference-net/schematron-v2-small": { @@ -36914,9 +36873,9 @@ "tool_call": true, "structured_output": true, "cost": { - "input": 0.063, + "input": 0.0608, "output": 4.4, - "cache_read": 0.0586 + "cache_read": 0.0565 } }, "openrouter/arcee-ai/trinity-large-thinking": { @@ -37264,9 +37223,9 @@ "tool_call": true, "structured_output": true, "cost": { - "input": 0.225, + "input": 0.1307, "output": 8, - "cache_read": 0.23 + "cache_read": 0.13 } }, "openrouter/~deepseek/deepseek-v4-flash-latest": { @@ -37920,8 +37879,8 @@ "structured_output": true, "image": true, "cost": { - "input": 0.075, - "output": 0.2 + "input": 0.09375, + "output": 0.25 } }, "openrouter/mistralai/mistral-large": { @@ -39313,15 +39272,15 @@ "openrouter/qwen/qwen3.6-27b": { "name": "Qwen3.6 27B", "context": 262144, - "output": 81920, + "output": 65536, "reasoning": true, "tool_call": true, "structured_output": true, "image": true, "video": true, "cost": { - "input": 0.32, - "output": 3.2 + "input": 0.45, + "output": 2.7 } }, "openrouter/qwen/qwen3.6-plus": { diff --git a/website/index.html b/website/index.html index f99e128..692b2b7 100644 --- a/website/index.html +++ b/website/index.html @@ -1,11 +1,10 @@ - + - - + Kimi Switch — 多 LLM 供应商配置管理 · 用量与账单监控 + + @@ -38,6 +39,30 @@ + + + - \ No newline at end of file + diff --git a/website/public/screenshots/v084/dashboard-dark.png b/website/public/screenshots/v084/dashboard-dark.png new file mode 100644 index 0000000..c308ab6 Binary files /dev/null and b/website/public/screenshots/v084/dashboard-dark.png differ diff --git a/website/public/screenshots/v084/providers-dark.png b/website/public/screenshots/v084/providers-dark.png new file mode 100644 index 0000000..78b5bf6 Binary files /dev/null and b/website/public/screenshots/v084/providers-dark.png differ diff --git a/website/public/screenshots/v084/sessions-dark.png b/website/public/screenshots/v084/sessions-dark.png new file mode 100644 index 0000000..9470a84 Binary files /dev/null and b/website/public/screenshots/v084/sessions-dark.png differ diff --git a/website/public/screenshots/v084/settings-about-dark.png b/website/public/screenshots/v084/settings-about-dark.png new file mode 100644 index 0000000..32aba5d Binary files /dev/null and b/website/public/screenshots/v084/settings-about-dark.png differ diff --git a/website/public/screenshots/v084/settings-advanced-dark.png b/website/public/screenshots/v084/settings-advanced-dark.png new file mode 100644 index 0000000..f08b5ce Binary files /dev/null and b/website/public/screenshots/v084/settings-advanced-dark.png differ diff --git a/website/public/screenshots/v084/settings-global-dark.png b/website/public/screenshots/v084/settings-global-dark.png new file mode 100644 index 0000000..e1fa6b5 Binary files /dev/null and b/website/public/screenshots/v084/settings-global-dark.png differ diff --git a/website/src/App.tsx b/website/src/App.tsx index 703eb36..a38b701 100644 --- a/website/src/App.tsx +++ b/website/src/App.tsx @@ -3,9 +3,11 @@ import { Route, Routes, useLocation } from "react-router-dom"; import Nav from "./components/Nav"; import Hero from "./sections/Hero"; import Features from "./sections/Features"; +import Showcase from "./sections/Showcase"; +import Performance from "./sections/Performance"; +import Privacy from "./sections/Privacy"; import Changelog from "./sections/Changelog"; import Download from "./sections/Download"; -import Privacy from "./sections/Privacy"; import CTA from "./sections/CTA"; import Footer from "./components/Footer"; import { useLang, type Dict } from "./i18n"; @@ -14,17 +16,26 @@ const SITE_URL = "https://billowliu2.github.io/KimiSwitch"; type SeoKey = keyof Dict["seo"]; function ScrollToTop() { - const { pathname } = useLocation(); + const { pathname, hash } = useLocation(); useEffect(() => { + // Deep links to a section (e.g. "/#showcase" from anywhere on the site) + // win over the plain top-of-page reset. + if (hash) { + const target = document.getElementById(hash.slice(1)); + if (target) { + target.scrollIntoView({ block: "start" }); + return; + } + } window.scrollTo(0, 0); - }, [pathname]); + }, [pathname, hash]); return null; } /** Update per-page title / description / OG / canonical for SEO. */ function RouteMeta() { const { pathname } = useLocation(); - const { t } = useLang(); + const { lang, t } = useLang(); useEffect(() => { const page = pathname.replace(/\/+$/, ""); @@ -40,20 +51,20 @@ function RouteMeta() { setMeta("property", "og:title", m.title); setMeta("property", "og:description", m.description); setMeta("property", "og:url", url); + setMeta("property", "og:locale", lang === "zh" ? "zh_CN" : "en_US"); + setMeta("property", "og:locale:alternate", lang === "zh" ? "en_US" : "zh_CN"); setMeta("name", "twitter:title", m.title); setMeta("name", "twitter:description", m.description); const canon = document.querySelector('link[rel="canonical"]'); if (canon) canon.setAttribute("href", url); - }, [pathname, t]); + }, [pathname, t, lang]); return null; } function setMeta(attr: "name" | "property", key: string, content: string) { - let el = document.head.querySelector( - `meta[${attr}="${key}"]`, - ); + let el = document.head.querySelector(`meta[${attr}="${key}"]`); if (!el) { el = document.createElement("meta"); el.setAttribute(attr, key); @@ -65,30 +76,47 @@ function setMeta(attr: "name" | "property", key: string, content: string) { const homePage = ( <> + + + + ); +const featuresPage = ( + <> + + + + + +); + +const downloadPage = ( + <> + + +); + export default function App() { return ( -
+
+ + Skip to content / 跳到正文 +