From 27f5d7100b5992f18885ca5ba0db804a19ba0937 Mon Sep 17 00:00:00 2001 From: Dylan5237 <58796901+Dylan5237@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:17:05 +0800 Subject: [PATCH 01/22] =?UTF-8?q?docs(viewer):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=BC=80=E5=8F=91/=E8=BF=90=E8=A1=8C=E5=8F=8C=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象: Viewer 单一形态无法同时服务开发项目与运行工作流两类心智模型; 改法: 按第一性原理定稿双模式信息架构, 开发=空间导航(全量树+高亮), 运行=产物消费(类型/目录facets+卡片流), 并明确目录多/卡片多的边界处理 Co-Authored-By: Codex --- docs/VIEWER_MODES_DESIGN.md | 123 ++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 docs/VIEWER_MODES_DESIGN.md diff --git a/docs/VIEWER_MODES_DESIGN.md b/docs/VIEWER_MODES_DESIGN.md new file mode 100644 index 0000000..739531b --- /dev/null +++ b/docs/VIEWER_MODES_DESIGN.md @@ -0,0 +1,123 @@ +# Viewer 双模式设计(开发 / 运行) + +> 状态:设计定稿,待实现 +> 分支:feature/viewer-modes +> 目标:让 Viewer 按会话性质切换两种信息架构,而不是用一套界面硬套两类心智模型。 + +## 1. 背景与问题 + +当前 Viewer 只有一种形态:文件树只展示人类可读产物(`.md/.json/.html/.mmd/.mermaid`), +忽略源码、配置、图片、二进制等其余文件。 + +这导致两个方向的体验都不对: + +- 开发一个完整项目或 skill 时,用户想看到的恰恰是**全部文件**(源码、配置、资源), + 现在被白名单挡住了,Viewer 不像资源管理器。 +- 运行 skill 或固定工作流时,用户只关心**少数人类可读产物**,现在却被目录树和中间文件 + 干扰,缺少“这一轮产出了什么”的总览。 + +根本原因不是文件类型列表不对,而是**两类会话的注意力结构不同**,需要不同的信息架构。 + +## 2. 第一性原理 + +Viewer 回答的核心问题是:**“这个会话里,我该看什么?”** + +| 维度 | 开发模式 | 运行模式 | +|---|---|---| +| 会话在做什么 | 建造:项目 / skill / 系统 | 执行:skill / 固定工作流 | +| 产物形态 | 结构(文件树本身即交付物) | 结果(少数人类可读成品) | +| 用户心智模型 | 文件系统:层级、路径、全量 | 交付物:清单、即时预览 | +| 类比 | IDE 项目面板 + 编辑器 | 报告 / 成果画廊 | + +结论:这两个模式不是“换一个文件类型白名单”,而是**同一份目录数据上的两种视图投影**。 + +## 3. 模式定义 + +### 3.1 开发模式 = 空间导航(资源管理器) + +- 文件树显示**全量文件**:源码、配置、Markdown、JSON、图片、二进制都进树,按目录层级展开。 +- 文本类源码(`.ts/.js/.py/.css/.vue/.sh/.yml/.toml/.xml` 等)可预览,只读语法高亮。 +- 图片(`.png/.jpg/.svg/.webp`)可直接预览。 +- 二进制 / 超大文件降级为“文件类型 + 大小 + 在资源管理器中打开”,不尝试渲染。 +- 过滤框支持“文件名或类型”。 + +### 3.2 运行模式 = 产物消费(成果画廊) + +- 默认落点在“本轮产物”面板,文件树退为辅助。 +- 产物列表升级为**卡片流**:md 显示摘要、mermaid/html/图片显示缩略图、json 显示键结构,点击进入完整预览。 +- 目录层级**降级为分组标签(facets)**,而不是可展开树: + - 类型 facets:`md / html / mmd / json / 图片`; + - 目录 facets:把产物所在目录拍平成一层 chips。 +- 每个产物卡片仍保留相对路径面包屑,落盘结构信息不丢。 + +## 4. 交互方案 + +### 4.1 模式切换器(顶栏) + +三档:`自动 / 开发 / 运行`。 + +- **自动**:按会话行为推断,推断结果显示为当前生效档位(可提示置信度)。 +- **开发 / 运行**:手动覆盖,覆盖后持久化到该会话,不再被自动推断改写。 + +### 4.2 模式推断信号(自动档) + +从会话 JSONL 可得的两个信号叠加: + +1. **写操作文件类型分布**:非人类可读类型(源码/配置/二进制)占比高 → 偏开发; + 几乎全是 `md/html/mermaid/json` → 偏运行。 +2. **skill / 工作流执行痕迹**:存在 skill 调用或固定工作流特征 → 偏运行。 + +两者叠加得到置信度;低置信度时回落默认(开发模式,因为它更“全”)。 + +### 4.3 模式与引擎无关 + +模式统一按“当前会话行为”判定,不因 Kimicode / CloudCLI / Codex 引擎不同而各自定义默认。 + +## 5. 关键技术决策 + +| 决策点 | 结论 | 理由 | +|---|---|---| +| 模式判定 | 自动推断 + 手动覆盖 | 固定工作流与开发项目通常可从会话行为区分,但必须允许用户纠偏 | +| 引擎关系 | 引擎无关,统一按会话判定 | 避免三类引擎各维护一套默认,语义不因引擎漂移 | +| 语法高亮 | highlight.js | 只读预览场景,纯静态高亮 190+ 语言;CodeMirror 是编辑器、带输入/选区/历史等用不上的重量;引入方式与现有 marked/mermaid 一致(`require.resolve` 动态 serve,不复制 vendor) | +| 运行模式目录 | 目录拍平为 facets,保留路径面包屑 | 目录层级在运行态是“落盘副作用”而非“心智模型”,拍平后更贴合“快速定位 + 分类总览” | + +## 6. 边界情况(实现必须覆盖) + +### 6.1 目录很多(大几十个乃至上百个) + +运行模式的目录 facets 不能无限横向铺开。规则: + +- **截断 + 折叠**:目录 chips 默认只展示前 N 个(建议 8~10),超出部分收进“更多目录”弹出层(可搜索)。 +- **按产物数排序**:目录按“本轮产物数量”降序,高频目录始终可见,低频目录沉入“更多”。 +- **目录名太长**:单 chip 限宽,超长省略号,`title` 显示完整路径。 +- **文件树侧**:开发模式的目录树保持虚拟滚动 / 懒加载,避免一次性渲染大几十层节点。 + +### 6.2 本轮产物卡片显示不全 + +产物卡片流是运行模式主视图,但必须假设“本轮可能产出几百个文件”。规则: + +- **默认只显示本轮(当前 artifact session)产物**,不显示历史全部。 +- **虚拟滚动或增量渲染**:首屏只渲染可视区 + 缓冲,滚动时增量加载,不一次性 DOM 化全部卡片。 +- **类型 facets 默认“全部”,但数量上限提示**:卡片流顶部给出总数,超过渲染上限时明确提示“显示前 X 条,可用 facets 缩小范围”。 +- **卡片内容懒加载**:缩略图(尤其 mermaid/html)在卡片进入视口时才渲染,避免首屏卡顿。 +- **单卡片高度封顶**:md 摘要、json 键列表、mermaid 缩略图都设最大行数/高度,防止单个超大产物把列表撑爆。 + +### 6.3 其他必须保留的安全 / 资源边界 + +- `../` 越权、符号链接逃逸、补充根(extraRoots)之外的任意路径仍返回 403。 +- 超大文件(沿用 `MAX_FILE_BYTES` / `MAX_ARTIFACT_CONTENT_BYTES`)降级,不整读内存。 +- 二进制文件只提供“在资源管理器中打开”,不进入产物卡片流的缩略图逻辑。 +- HTML 仍走 iframe sandbox + CSP,脚本/表单/外部网络禁用。 + +## 7. 落地切分 + +第一版只做**信息架构**,不追求渲染细节: + +1. 模式状态与推断信号接入(顶栏切换器 + 会话上下文透传)。 +2. 开发模式:全量文件树 + 文本/图片预览 + 二进制降级。 +3. 运行模式:类型 + 目录 facets + 产物卡片流(含虚拟滚动/懒加载骨架)。 +4. highlight.js 接入源码只读高亮。 +5. 上述 6.1 / 6.2 的边界处理。 + +语法高亮用 highlight.js、mermaid 复用现有渲染、HTML 复用现有 iframe sandbox,不重复造轮子。 From 6a94b5afa7666e7c595d8d117986d2da98733523 Mon Sep 17 00:00:00 2001 From: Dylan5237 <58796901+Dylan5237@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:17:16 +0800 Subject: [PATCH 02/22] =?UTF-8?q?docs(settings):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E9=80=9A=E7=94=A8=E8=AE=BE=E7=BD=AE=E4=B8=8E=E5=85=A8=E5=B1=80?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E7=AE=A1=E7=90=86=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象: 设置页只有Kimi配置可视化且改了不生效; 根因: 缺配置生效契约, workbench自身配置层缺失; 改法: 四层架构(产品/共享资源/投影/引擎原生), Skill用混合投影, MCP用标准定义+生成, Memory标待议, 配置存exe路径并附卡诺+第一性原理迭代规划M0-M3 Co-Authored-By: Codex --- docs/WORKBENCH_SETTINGS_DESIGN.md | 196 ++++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 docs/WORKBENCH_SETTINGS_DESIGN.md diff --git a/docs/WORKBENCH_SETTINGS_DESIGN.md b/docs/WORKBENCH_SETTINGS_DESIGN.md new file mode 100644 index 0000000..585d213 --- /dev/null +++ b/docs/WORKBENCH_SETTINGS_DESIGN.md @@ -0,0 +1,196 @@ +# Workbench 通用设置与全局资源管理 · 全量设计 + +> 状态:设计定稿(Memory 域标待议) +> 阶段:设计探讨完成,待用户确认后进入实现 +> 关联:feature/viewer-modes(同属 v1.1 迭代) + +## 1. 背景与问题 + +现有设置页存在三个问题,且三个问题的根因同源: + +1. **只有 Kimi Code 配置可视化,没有 workbench 自己的设置**:设置服务读写的全部是 + `~/.kimi-code/` 下的 `config.toml / tui.toml / mcp.json / SYSTEM.md / AGENTS.md`, + 所有字段都是 Kimi CLI 的字段,workbench 自身的配置项一个都没有。 +2. **设置入口被硬编码成 Kimi 专属**:`main.js` 的 `switchTab` 在 `activeEngine !== 'kimi'` + 时直接拒绝进入设置,shell 层也把设置 tab 隐藏。 +3. **配置改了不生效**:保存链路里只有 `default_permission_mode` 变化会触发服务重启, + 其余字段写进 `config.toml` 就结束,服务不重启、workbench 也不消费,新会话读到的仍是旧值。 + +根因不是“配置没保存”,而是缺少**“配置 → 运行时行为”的传导契约**——持久化不等于生效。 + +## 2. 第一性原理 + +workbench 这个产品的本质:**一个承载多引擎(Kimi / CloudCLI / Codex)的壳,加上一套共享的 +产物体验(Viewer + 时间机器)。** + +由此,设置天然分两种本质: + +| | 引擎配置(Engine Profile) | Workbench 配置(Product Settings) | +|---|---|---| +| 谁拥有 | 各引擎自己 | workbench 自己 | +| 举例 | kimi 的 config.toml、cloudcli 的配置 | 默认引擎、Viewer 模式、引擎路径定位 | +| workbench 角色 | 定位 / 透传 / 诊断,不重写格式 | 定义、持久化、**保证生效** | + +现有设置页把“引擎配置”当成了“workbench 设置”的全部,而 workbench 层整个缺失。 + +## 3. 四层架构 + +``` +┌─────────────────────────────────────────────┐ +│ L1 产品层 Workbench 全局配置 │ +│ 默认引擎 / Viewer 模式 / 行为偏好 │ +│ 存 exe 所在路径,workbench 自己拥有 │ +├─────────────────────────────────────────────┤ +│ L2 共享资源层 Skill / MCP / Memory │ +│ 单一事实源,三个引擎共用 │ +├─────────────────────────────────────────────┤ +│ L3 投影层 projection engine │ +│ 把 L2 渲染到各引擎的目录 / 配置文件 │ +├─────────────────────────────────────────────┤ +│ L4 引擎原生层 各引擎自己的配置 │ +│ config.toml / settings.json / mcp.json │ +│ 只读诊断 + 关键项透传,不重写其格式 │ +└─────────────────────────────────────────────┘ +``` + +### 3.1 L1 产品层 + +workbench 自己的全局配置,候选配置项: + +- 会话:默认引擎(Kimi / CloudCLI / Codex)、启动时是否记住上次引擎。 +- Viewer:默认模式(auto / dev / run)、默认根目录、是否自动跟随会话路径透传。 +- 引擎定位:CloudCLI / Codex / Node 22 的可执行路径。 +- 行为:额度弹窗默认展开、日志级别。 + +**存储位置:exe 所在路径**(便携、绿色、随 exe 迁移)。配置放独立子目录 +`/config/`,升级约定不触碰该目录。 + +### 3.2 L4 引擎原生层 + +每个引擎一个 tab:Kimi 沿用现有 TOML 可视化;CloudCLI / Codex 各自给到 +“配置路径定位 + 只读诊断 + 关键项透传”,不重写其原生格式。 + +## 4. Skill 全局统一(混合投影) + +### 4.1 事实:三个引擎对“额外 skill 目录”支持差异大 + +- **Kimi Code**:原生支持 `extra_skill_dirs`,绝对路径、追加而非替换。 +- **Claude Code**:`settings.json` 无等价字段;官方路径是复制/软链到 + `~/.claude/skills/`,或打成 plugin,或项目内 `.claude/skills/`。 +- **Codex**:`config.toml` 无任意额外目录设置;`[[skills.config]]` 是逐 skill 的 + `path + enabled`,用户级目录 `~/.agents/skills/`(新版)。 + +结论:没有“纯配置指针”能三引擎通吃,统一层本质是“在三种发现机制上叠一个统一层”。 + +### 4.2 五种投影方式与利弊 + +| 方式 | 利 | 弊 | +|---|---|---| +| ① 目录 junction | 单一事实源、零同步延迟、改一处三处即时生效 | 平台级行为,跨盘/杀软/云盘易出问题;引擎升级覆盖会打断链;调试难 | +| ② 目录复制同步 | 引擎无感知、最稳、任何引擎都接受 | 三份物理副本;存在时序与冲突;要设计变更检测与方向约定 | +| ③ 配置指针 | 最轻、引擎原生 | 仅 Kimi 支持,无法三引擎统一 | +| ④ 逐 skill 配置 | 精确、可单 skill 开关 | 是枚举不是目录,新增要新增配置;Claude 需打 plugin,维护成本高 | +| ⑤ 启动参数注入 | 不落盘、不动引擎配置、最干净 | 只对 workbench 启动的会话生效;脱离 workbench 开 CLI 就失效 | + +### 4.3 判定方法 + +按顺序回答三问: + +1. skill 是否只在 workbench 启动的会话才需要生效?否(用户可能直接开 CLI)→ 排除⑤。 +2. 部署形态是否允许目录 junction?便携 exe、单机、不跨盘可考虑①;跨盘/多机则不可靠。 +3. 能否接受“多副本 + 同步”复杂度?能 → ②;不能 → 混合。 + +### 4.4 结论:混合模式 + +- **主路径 = ② 复制同步**:全局 `/config/skills/` 为唯一事实源,保存时同步到 + `~/.kimi-code/skills`、`~/.claude/skills`、`~/.agents/skills`。 +- **Kimi 加分项 = ③ 指针**:Kimi 原生 `extra_skill_dirs` 直接指全局目录,省一次复制、天然实时。 +- **① junction 作为可选增强,默认关**:高级用户可开“硬链接模式”,明确标注风险。 +- **④ 只做“单 skill 启停/覆盖”层,不做发现层**。 + +## 5. MCP 全局统一(标准定义 + 生成) + +- 全局存一份**标准 MCP server 定义**(name + command/url + env + enabledTools + disabledTools)。 +- 投影层按引擎格式生成/合并:kimi 的 `mcp.json`、Claude 的 `.mcp.json`、 + Codex `config.toml` 的 mcp 段。 +- 敏感项(API key)不进全局明文,或明确标注“仅本地、不上传”。 + +## 6. Memory 全局统一(待议,不设计、不执行) + +Memory 域坑很多,本版**明确标记为待议**:不设计、不执行、不排期。后续单独研究后再决定。 + +## 7. 配置生效契约(针对“改了不生效”) + +每个配置项声明**生效时机(scope)**,由 workbench 主动执行传导: + +- `immediate`:改完立即生效(UI 偏好,如 Viewer 模式、弹窗默认)。 +- `next-session`:下次启动引擎会话生效(如权限模式、模型),保存后 workbench 主动重启对应服务并提示。 +- `restart-workbench`:重启应用生效(如引擎路径、Node 路径),明确标注。 + +保存按钮旁显示“生效方式”;凡需重启的,workbench 替你重启。 + +## 8. 引擎无关性 + +设置页不再对 Kimi 隐藏。Workbench 层任何引擎下可进;引擎层 tab 跟随当前引擎高亮,但都可见。 + +## 9. 边界情况 + +1. **exe 目录只读**(Program Files 安装):可写则用 exe 旁配置,不可写则回落到 + `%APPDATA%`,并在设置页明示当前配置实际落点。 +2. **多实例 / 多版本隔离**:exe 旁配置意味着“每个 exe 一份配置”,属便携特性,UI 需明示“这是本实例配置”。 +3. **升级覆盖风险**:配置放 `/config/` 子目录,升级不触碰。 +4. **引擎侧手改冲突**:同步方向约定为“全局为源,引擎侧改动会被覆盖”,并在 UI 提示。 + +## 10. 开源项目选型(复用,不重造) + +| 域 | 首选 | 复用方式 | +|---|---|---| +| Skill | `anthropics/skills` + `nikships/skills-registry` | 前者是标准源头(沿用 SKILL.md 规范);后者借鉴“GitHub repo 作事实源 + 安装”模型 | +| MCP | `pathintegral-institute/mcpm.sh` | 跨客户端 MCP 配置管理,可作依赖或借鉴 profile 模型 | +| Memory | 待议 | 不选型,后续单独研究 | + +## 11. 迭代规划(卡诺模型 + 第一性原理) + +### 11.1 卡诺分类 + +| 需求 | 卡诺类型 | 说明 | +|---|---|---| +| 配置生效契约 | 基本型 | “改了要生效”是底线,现在做不到,用户已不满 | +| Workbench 配置持久化层 | 基本型 | 没有统一配置层,上层全局管理无从谈起 | +| 设置页引擎无关化 | 期望型 | 越开放越符合 workbench 定位,缺了不至于崩溃 | +| Skill 全局统一 | 期望型 | 高频痛点“配三遍”,解决后满意度线性上升 | +| MCP 全局统一 | 期望型 | 同 Skill,痛点同源 | +| Skill junction 加速 | 兴奋型 | 无它也能用(同步已解决),有它是体验惊喜 | +| 开源 registry 接入 | 兴奋型 | 锦上添花 | +| Memory 全局统一 | 待议 | 不分类、不排期 | + +### 11.2 第一性原理排序 + +workbench 核心价值 = “让多引擎 + 共享产物体验用起来更灵活”。灵活的前提是 +**配置可信、配置统一**。因此: + +- P0 = 可信配置(生效契约 + 统一持久化层)。 +- P1 = 高频痛点(Skill 先于 MCP:Skill 是 agent 能力核心,且三引擎支持差异最大、最需要混合投影)。 +- P2 = 加速 / 增强(junction、registry)。 + +### 11.3 迭代里程碑 + +``` +M0(P0 地基)Workbench 配置层 + 生效契约 + - workbench-config.json(exe 路径 + 只读降级) + - SettingsService 重构、scope 模型 + - 设置页引擎无关入口 + - 验收:改一个 next-session 配置,保存后自动重启并生效 + +M1(P1 核心)Skill 全局统一(混合投影) + - 全局 skills 目录 + 复制同步 + Kimi 指针 + 单 skill 启停 + - 验收:新增一个 skill,三引擎都能用;Kimi 即时,Claude/Codex 保存后同步 + +M2(P1 核心)MCP 全局统一 + - 标准 MCP 定义 + 三引擎生成投影 + - 验收:配一个 MCP server,三引擎都连上 + +M3(P2 增强)junction 加速 + registry 接入(可选,后置) + +Memory:待议,不排期。 +``` From bb0a9f42264638d12b965ae04a0f9f100c1ebd73 Mon Sep 17 00:00:00 2001 From: Dylan5237 <58796901+Dylan5237@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:22:53 +0800 Subject: [PATCH 03/22] =?UTF-8?q?feat(settings):=20=E6=96=B0=E5=A2=9E=20wo?= =?UTF-8?q?rkbench=20=E5=85=A8=E5=B1=80=E9=85=8D=E7=BD=AE=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象: workbench 自身配置缺失, 现有设置页只读写 Kimi 的 config.toml; 改法: 新增 WorkbenchConfigService, 配置存 exe 所在路径 config/ 子目录, exe 目录不可写时回落 userData, 支持 forceFallback 隔离 demo/sandbox; 附可写探测/原子写/损坏配置回退 Co-Authored-By: Codex --- src/main/main.js | 8 ++++ src/main/workbench-config.js | 78 +++++++++++++++++++++++++++++++++++ test/workbench-config.test.js | 49 ++++++++++++++++++++++ 3 files changed, 135 insertions(+) create mode 100644 src/main/workbench-config.js create mode 100644 test/workbench-config.test.js diff --git a/src/main/main.js b/src/main/main.js index c5c9e6d..203df2c 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -29,6 +29,7 @@ import { parseCloudCliSessionId } from './cloudcli-context.js' import { SettingsService } from './settings-service.js' +import { WorkbenchConfigService } from './workbench-config.js' import { copyPathsToWindowsClipboard } from './windows-file-clipboard.js' import { requireSender, normalizeForkRequest } from './ipc-validators.js' import { createKimiCodeUrlGuard } from './url-trust.js' @@ -71,6 +72,7 @@ let quotaService = null let localKimiService = null let cloudCliService = null let settingsService = null +let workbenchConfigService = null let activeEngine = 'kimi' let viewerContextSync = null let viewerContextLogPath = null @@ -165,6 +167,12 @@ app.whenReady().then(async () => { kimiCodeHome, sandboxed: settingsSandboxed }) + workbenchConfigService = new WorkbenchConfigService({ + exeDir: path.dirname(app.getPath('exe')), + fallbackDir: app.getPath('userData'), + forceFallback: demoMode || settingsSandboxed + }) + await workbenchConfigService.initialize() quotaService = new QuotaService({ userDataPath: app.getPath('userData'), partition: SESSION_PARTITION, diff --git a/src/main/workbench-config.js b/src/main/workbench-config.js new file mode 100644 index 0000000..4850c06 --- /dev/null +++ b/src/main/workbench-config.js @@ -0,0 +1,78 @@ +import path from 'node:path' +import { promises as fs } from 'node:fs' + +const CONFIG_DIR_NAME = 'config' +const CONFIG_FILE_NAME = 'workbench-config.json' + +/** + * Workbench 自己的全局配置(L1 产品层)。 + * + * 存储位置默认在 exe 所在路径的 config/ 子目录(便携、随 exe 迁移), + * 当 exe 目录不可写时(如 Program Files)回落到 fallbackDir。 + * 配置落点会通过 state 暴露给设置页,便于用户确认“本实例配置在哪”。 + */ +export class WorkbenchConfigService { + constructor({ exeDir, fallbackDir, forceFallback = false }) { + this.exeDir = path.resolve(exeDir) + this.fallbackDir = path.resolve(fallbackDir) + this.forceFallback = forceFallback + this.storageDir = null + this.configPath = null + this.state = {} + } + + async initialize() { + const exeConfigDir = path.join(this.exeDir, CONFIG_DIR_NAME) + this.storageDir = (!this.forceFallback && (await canWrite(exeConfigDir))) + ? exeConfigDir + : this.fallbackDir + this.configPath = path.join(this.storageDir, CONFIG_FILE_NAME) + this.state = await readJson(this.configPath) + return this.describe() + } + + async get() { + return { ...this.state } + } + + async save(patch) { + if (!patch || typeof patch !== 'object') throw new Error('配置补丁无效') + this.state = { ...this.state, ...patch } + await fs.mkdir(this.storageDir, { recursive: true }) + const temporaryPath = `${this.configPath}.tmp` + await fs.writeFile(temporaryPath, JSON.stringify(this.state, null, 2), 'utf8') + await fs.rename(temporaryPath, this.configPath) + return this.describe() + } + + describe() { + return { + configPath: this.configPath, + storage: this.storageDir === path.join(this.exeDir, CONFIG_DIR_NAME) ? 'exe' : 'userData' + } + } +} + +async function canWrite(directory) { + try { + await fs.mkdir(directory, { recursive: true }) + const probe = path.join(directory, `.write-probe-${process.pid}`) + await fs.writeFile(probe, '', 'utf8') + await fs.rm(probe, { force: true }) + return true + } catch { + return false + } +} + +async function readJson(filePath) { + try { + const text = await fs.readFile(filePath, 'utf8') + const value = JSON.parse(text) + return value && typeof value === 'object' && !Array.isArray(value) ? value : {} + } catch (error) { + if (error.code === 'ENOENT') return {} + if (error instanceof SyntaxError) return {} + throw error + } +} diff --git a/test/workbench-config.test.js b/test/workbench-config.test.js new file mode 100644 index 0000000..a98eeb4 --- /dev/null +++ b/test/workbench-config.test.js @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict' +import { promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { WorkbenchConfigService } from '../src/main/workbench-config.js' + +test('persists config beside the exe when the exe dir is writable', async t => { + const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'kcc-workbench-config-')) + t.after(() => fs.rm(temp, { recursive: true, force: true })) + const exeDir = path.join(temp, 'exe') + const fallbackDir = path.join(temp, 'userData') + await fs.mkdir(exeDir, { recursive: true }) + + const service = new WorkbenchConfigService({ exeDir, fallbackDir }) + const info = await service.initialize() + + assert.equal(info.storage, 'exe') + assert.equal(info.configPath, path.join(exeDir, 'config', 'workbench-config.json')) + assert.deepEqual(await service.get(), {}) + + await service.save({ defaultEngine: 'kimi', viewerMode: 'auto' }) + const saved = JSON.parse(await fs.readFile(info.configPath, 'utf8')) + assert.equal(saved.defaultEngine, 'kimi') + assert.equal(saved.viewerMode, 'auto') + + const reloaded = new WorkbenchConfigService({ exeDir, fallbackDir }) + await reloaded.initialize() + assert.equal((await reloaded.get()).defaultEngine, 'kimi') +}) + +test('falls back to userData when the exe dir cannot be written', async t => { + const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'kcc-workbench-config-')) + t.after(() => fs.rm(temp, { recursive: true, force: true })) + const exeDir = path.join(temp, 'readonly-exe') + const fallbackDir = path.join(temp, 'userData') + await fs.mkdir(exeDir, { recursive: true }) + // 模拟只读:把 exe 目录设为不可写目录。Windows 上无法可靠 chmod 目录, + // 这里用一个“exe 目录本身是一个文件”的路径来触发 mkdir 失败。 + const blockingFile = path.join(temp, 'blocked') + await fs.writeFile(blockingFile, '') + const blockedExeDir = path.join(blockingFile, 'config') + + const service = new WorkbenchConfigService({ exeDir: path.join(blockingFile), fallbackDir }) + const info = await service.initialize() + + assert.equal(info.storage, 'userData') + assert.equal(info.configPath, path.join(fallbackDir, 'workbench-config.json')) +}) From fe4eb249d2abb8af9de6619c9ef568570a98413d Mon Sep 17 00:00:00 2001 From: Dylan5237 <58796901+Dylan5237@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:24:27 +0800 Subject: [PATCH 04/22] =?UTF-8?q?feat(settings):=20=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=A1=B5=E5=BC=95=E6=93=8E=E6=97=A0=E5=85=B3=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象: 设置 tab 仅在 Kimi 引擎激活时可见, CloudCLI/Codex 下被隐藏且 switchTab 拒绝进入; 改法: 移除 switchTab 的 kimi-only 判断与 shell 的 hidden 逻辑, 设置入口对所有引擎开放 Co-Authored-By: Codex --- src/main/main.js | 1 - src/renderer/shell.html | 2 +- src/renderer/shell.js | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/main.js b/src/main/main.js index 203df2c..33a1107 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -572,7 +572,6 @@ function layoutViews() { async function switchTab(nextTab) { if (!mainWindow || !shellView || !kimiView || !cloudCliView || !viewerView || !settingsView) return if (!['kimi', 'viewer', 'settings'].includes(nextTab) || nextTab === activeTab) return - if (nextTab === 'settings' && activeEngine !== 'kimi') return closeQuotaPopup() const views = { diff --git a/src/renderer/shell.html b/src/renderer/shell.html index 56963ef..e281cc5 100644 --- a/src/renderer/shell.html +++ b/src/renderer/shell.html @@ -32,7 +32,7 @@ { } 文件 - diff --git a/src/renderer/shell.js b/src/renderer/shell.js index af11a7c..918bd7c 100644 --- a/src/renderer/shell.js +++ b/src/renderer/shell.js @@ -70,7 +70,7 @@ function renderKimiOnlyActions() { const visible = activeTab === 'kimi' && activeEngine === 'kimi' restartKimiBtn.hidden = !visible quotaButton.hidden = !visible - settingsTab.hidden = activeEngine !== 'kimi' + settingsTab.hidden = false if (!visible) quotaButton.setAttribute('aria-expanded', 'false') } From 0737c0f001d70cf204aaf3f29141e2d9cc08ebc9 Mon Sep 17 00:00:00 2001 From: Dylan5237 <58796901+Dylan5237@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:29:52 +0800 Subject: [PATCH 05/22] =?UTF-8?q?feat(settings):=20=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E7=94=9F=E6=95=88=E5=A5=91=E7=BA=A6=20scope=20=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象: 仅默认权限模式变化会触发引擎重启, 其余字段改了不生效; 根因: 缺配置变更到运行时行为的传导契约; 改法: 新增 SETTING_SCOPES 与 hasEngineConfigChanged, 任何引擎配置变化保存后自动重启 Kimi Web 并应用到新会话 Co-Authored-By: Codex --- src/main/main.js | 6 +++--- src/main/settings-service.js | 18 ++++++++++++++++++ test/settings-service.test.js | 18 ++++++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/main/main.js b/src/main/main.js index 33a1107..b157592 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -28,7 +28,7 @@ import { extractCloudCliSessionContext, parseCloudCliSessionId } from './cloudcli-context.js' -import { SettingsService } from './settings-service.js' +import { SettingsService, hasEngineConfigChanged } from './settings-service.js' import { WorkbenchConfigService } from './workbench-config.js' import { copyPathsToWindowsClipboard } from './windows-file-clipboard.js' import { requireSender, normalizeForkRequest } from './ipc-validators.js' @@ -876,9 +876,9 @@ function wireIpc() { }) ipcMain.handle('settings:save', async (event, payload) => { requireSender(event, settingsView.webContents) - const previousMode = (await settingsService.getState()).config.default_permission_mode + const previousConfig = (await settingsService.getState()).config const nextState = await settingsService.save(payload || {}) - if (nextState.config.default_permission_mode !== previousMode) { + if (hasEngineConfigChanged(previousConfig, nextState.config)) { localKimiService.stop() await connectLocalKimiView() } diff --git a/src/main/settings-service.js b/src/main/settings-service.js index 5f4685f..719fe0c 100644 --- a/src/main/settings-service.js +++ b/src/main/settings-service.js @@ -2,6 +2,12 @@ import path from 'node:path' import { promises as fs } from 'node:fs' const PERMISSION_MODES = new Set(['manual', 'yolo', 'auto']) + +export const SETTING_SCOPES = Object.freeze({ + immediate: 'immediate', + nextSession: 'next-session', + restartWorkbench: 'restart-workbench' +}) const INTEGER_FIELDS = new Set([ 'loop_control.max_steps_per_turn', 'loop_control.max_retries_per_step', @@ -154,6 +160,18 @@ export class SettingsService { } } +export function hasEngineConfigChanged(before, after) { + if (!before || !after) return Boolean(after && Object.keys(after).length) + return stableStringify(before) !== stableStringify(after) +} + +function stableStringify(value) { + if (value === null || typeof value !== 'object') return JSON.stringify(value) + if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]` + const keys = Object.keys(value).sort() + return `{${keys.map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}` +} + export function parseManagedConfig(text) { const result = {} for (const [fieldPath, type] of MANAGED_FIELDS) { diff --git a/test/settings-service.test.js b/test/settings-service.test.js index bfc1d83..8bb64a6 100644 --- a/test/settings-service.test.js +++ b/test/settings-service.test.js @@ -5,11 +5,29 @@ import path from 'node:path' import test from 'node:test' import { SettingsService, + hasEngineConfigChanged, parseManagedConfig, patchTomlValue, applyModelsToToml } from '../src/main/settings-service.js' + +test('detects any engine config change regardless of key order', () => { + assert.equal(hasEngineConfigChanged({ a: 1 }, { a: 1 }), false) + assert.equal(hasEngineConfigChanged({ a: 1 }, { a: 2 }), true) + assert.equal(hasEngineConfigChanged({ a: 1, b: 2 }, { b: 2, a: 1 }), false) + assert.equal( + hasEngineConfigChanged({ thinking: { enabled: true } }, { thinking: { enabled: false } }), + true + ) + assert.equal( + hasEngineConfigChanged({ thinking: { enabled: true, effort: 'high' } }, { thinking: { effort: 'high', enabled: true } }), + false + ) + assert.equal(hasEngineConfigChanged({}, {}), false) + assert.equal(hasEngineConfigChanged(null, { a: 1 }), true) +}) + test('reads managed fields without changing unknown configuration', () => { const text = `# keep me default_model = "kimi-code/k3" From 44916dc0c23989cedeb2915aae190ead2953ee28 Mon Sep 17 00:00:00 2001 From: Dylan5237 <58796901+Dylan5237@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:18:25 +0800 Subject: [PATCH 06/22] =?UTF-8?q?feat(settings):=20workbench=20=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=20UI=20=E6=B6=88=E8=B4=B9=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象: workbench 配置层已就绪但无 UI 读写, 配置与运行时脱节; 改法: 设置页新增 Workbench 通用面板(默认引擎/记住上次引擎), get-state/save 透传 workbench 配置, 启动按 resolveStartupEngine 决定初始引擎, 切换引擎时按 rememberEngine 持久化 lastEngine, 并清理 switchEngine 里切 CloudCLI 强制退出设置页的遗留逻辑 Co-Authored-By: Codex --- src/main/main.js | 50 ++++++++++++++++++++++++----------- src/main/workbench-config.js | 12 +++++++++ src/renderer/settings.html | 18 ++++++++++--- src/renderer/settings.js | 15 ++++++++++- test/workbench-config.test.js | 18 ++++++++++++- 5 files changed, 93 insertions(+), 20 deletions(-) diff --git a/src/main/main.js b/src/main/main.js index b157592..df75def 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -29,7 +29,7 @@ import { parseCloudCliSessionId } from './cloudcli-context.js' import { SettingsService, hasEngineConfigChanged } from './settings-service.js' -import { WorkbenchConfigService } from './workbench-config.js' +import { WorkbenchConfigService, resolveStartupEngine } from './workbench-config.js' import { copyPathsToWindowsClipboard } from './windows-file-clipboard.js' import { requireSender, normalizeForkRequest } from './ipc-validators.js' import { createKimiCodeUrlGuard } from './url-trust.js' @@ -173,6 +173,7 @@ app.whenReady().then(async () => { forceFallback: demoMode || settingsSandboxed }) await workbenchConfigService.initialize() + activeEngine = resolveStartupEngine(await workbenchConfigService.get()) quotaService = new QuotaService({ userDataPath: app.getPath('userData'), partition: SESSION_PARTITION, @@ -312,6 +313,10 @@ async function createMainWindow() { }) createKimiView() createCloudCliView() + if (activeEngine === 'cloudcli') { + mainWindow.contentView.removeChildView(kimiView) + mainWindow.contentView.addChildView(cloudCliView) + } createViewerView() viewerContextSync.start() createSettingsView() @@ -609,29 +614,30 @@ async function switchEngine(nextEngine) { closeQuotaPopup() activeEngine = nextEngine const nextView = activeEngineView() - if (activeTab === 'settings' && activeEngine === 'cloudcli' && mainWindow) { - mainWindow.contentView.removeChildView(settingsView) - mainWindow.contentView.addChildView(nextView) - activeTab = 'kimi' - layoutViews() - nextView.webContents.focus() - shellView?.webContents.send('shell:tab-changed', { - activeTab, - activeEngine, - viewerRoot: viewerServer.root - }) - } else if (activeTab === 'kimi' && mainWindow) { + if (activeTab === 'kimi' && mainWindow) { mainWindow.contentView.removeChildView(previousView) mainWindow.contentView.addChildView(nextView) layoutViews() nextView.webContents.focus() } + await persistLastEngine() sendNavigationState() shellView?.webContents.send('engine:changed', { engine: activeEngine }) await syncViewerConversationContext() return { engine: activeEngine } } +async function persistLastEngine() { + try { + const config = await workbenchConfigService.get() + if (config.rememberEngine) { + await workbenchConfigService.save({ lastEngine: activeEngine }) + } + } catch (error) { + console.error('Unable to persist last engine:', error) + } +} + function toggleEngine() { return switchEngine(activeEngine === 'kimi' ? 'cloudcli' : 'kimi') } @@ -872,7 +878,14 @@ function wireIpc() { ipcMain.handle('settings:get-state', async event => { requireSender(event, settingsView.webContents) settingsService.setProjectDirectory(await detectKimiProjectDirectory()) - return settingsService.getState() + const kimiState = await settingsService.getState() + return { + ...kimiState, + workbench: { + config: await workbenchConfigService.get(), + ...workbenchConfigService.describe() + } + } }) ipcMain.handle('settings:save', async (event, payload) => { requireSender(event, settingsView.webContents) @@ -882,7 +895,14 @@ function wireIpc() { localKimiService.stop() await connectLocalKimiView() } - return nextState + const workbenchInfo = await workbenchConfigService.save(payload?.workbench || {}) + return { + ...nextState, + workbench: { + config: await workbenchConfigService.get(), + ...workbenchInfo + } + } }) ipcMain.handle('settings:select-directory', async event => { requireSender(event, settingsView.webContents) diff --git a/src/main/workbench-config.js b/src/main/workbench-config.js index 4850c06..34f1bf9 100644 --- a/src/main/workbench-config.js +++ b/src/main/workbench-config.js @@ -3,6 +3,18 @@ import { promises as fs } from 'node:fs' const CONFIG_DIR_NAME = 'config' const CONFIG_FILE_NAME = 'workbench-config.json' +const ENGINES = new Set(['kimi', 'cloudcli']) + +/** + * 根据 workbench 配置决定启动引擎。 + * 优先级:rememberEngine 且 lastEngine 有效 → lastEngine;否则 defaultEngine;否则 kimi。 + */ +export function resolveStartupEngine(config) { + if (!config || typeof config !== 'object') return 'kimi' + if (config.rememberEngine && ENGINES.has(config.lastEngine)) return config.lastEngine + if (ENGINES.has(config.defaultEngine)) return config.defaultEngine + return 'kimi' +} /** * Workbench 自己的全局配置(L1 产品层)。 diff --git a/src/renderer/settings.html b/src/renderer/settings.html index 8ddf251..3093262 100644 --- a/src/renderer/settings.html +++ b/src/renderer/settings.html @@ -12,10 +12,13 @@