diff --git a/docs/M1_SKILL_MANAGEMENT_DESIGN.md b/docs/M1_SKILL_MANAGEMENT_DESIGN.md new file mode 100644 index 0000000..23cd165 --- /dev/null +++ b/docs/M1_SKILL_MANAGEMENT_DESIGN.md @@ -0,0 +1,144 @@ +# M1 详细设计 · Skills 全局管理(能力资产库) + +> 状态:设计定稿(MVP 原型已确认) +> 关联:`docs/WORKBENCH_SETTINGS_DESIGN.md` 第 4 节(Skill 混合投影)、`docs/assets/prototype-skill-global-m1.html` +> 范围:M1 MVP(按 v2 原型)+ 后续接入大模型的入口预留 + +## 0. 不可约结果 + +用户维护一份能力资产(skill),在 Kimi Code / Claude Code / Codex 想用的引擎会话中都能加载。 + +- 主用户:KCC Workbench 使用者(在本机、多引擎间切换)。 +- 触发:在设置页添加/移除/切换 skill 的引擎可用性。 +- 成功终态:页面出现"已同步 Kimi / Claude / Codex",Kimi 实时、Claude/Codex 新会话生效。 +- 事实源:`/config/skills/`(便携、随 exe 迁移;不可写时回退 userData)。 +- 权威边界:Workbench 只管理自己创建的投影项,绝不误删用户手工放置的 skill。 + +## 1. 术语与范围 + +- **能力资产库 / SSOT**:全局 skills 目录,唯一事实源。 +- **投影**:把库内 skill 按引擎的发现机制,同步/指向到各引擎能力目录。 +- **管理项**:库内一个 skill 及其启用矩阵(kimi/claude/codex)。 +- **历史/未管理**:各引擎目录里用户手工放置(非 Workbench 创建)的 skill,只读展示不操作。 + +## 2. 功能范围(MVP) + +MVP 有: + +1. 能力资产库:添加本地 skill 目录(含 `SKILL.md`)到全局库;默认三引擎启用。 +2. 每 skill 引擎启用矩阵:行内摘要 + 展开细调;至少保留一个引擎启用(护城河)。 +3. 投影服务:Kimi 用原生 `extra_skill_dirs` 指针(实时);Claude/Codex 自动选择软链优先、失败回退复制;只管理自建项。 +4. 同步状态可观察:保存后结果条显示各引擎已同步数量;失败则指出引擎与下一步。 +5. 移除先备份、可撤销:移除 = 停用 + 备份到 `/config/skill-backups/` + 删除,页面可一键恢复。 +6. 高级与诊断(折叠):展示三引擎落点目录、投影方式、当前加载数、最近错误。 +7. **AI 摘要入口(预留)**:UI 上为每个 skill 预留"AI 自动总结用途"入口位(占位说明:M3 接入大模型后启用),本期不调用任何模型。 + +MVP 明确不做(延后到 M3+): + +- 仓库安装/发现(GitHub zip / skills.sh / 多仓库)。 +- 云同步(WebDAV/S3)。 +- skill 更新检测与回滚。 +- junction 硬链接可选项(可作为高级隐藏项,默认关)。 +- 自动"AI 总结"的实际模型调用(只留入口)。 + +## 3. 事实源与数据模型 + +SSOT 目录结构: + +``` +/config/skills/ + /SKILL.md ... +/config/workbench-config.json +/config/skill-backups/ + -/ +``` + +全局配置 `workbench-config.json` 新增: + +```jsonc +{ + "skills": { + "library": "/config/skills/", // 事实源目录(可被工程覆盖) + "managed": { + "": { "apps": { "kimi": true, "claude": true, "codex": true } } + }, + "projection": { "claude": "auto", "codex": "auto" } // auto|symlink|copy;Kimi 用指针 + } +} +``` + +- `managed` 记录每个库内 skill 的启用矩阵;**库内目录是事实源,配置只存启停与投影策略**。 +- 兼容保留:Kimi `config.toml` 的 `extra_skill_dirs` 仍为指针来源,M1 界面写入的指针与该字段双向一致。 + +## 4. 投影设计 + +### 4.1 引擎发现机制与投影动作 + +| 引擎 | 发现机制 | 投影动作 | 生效时点 | +|---|---|---|---| +| Kimi Code | `extra_skill_dirs` 指针 | 写入 `~/.kimi-code/config.toml` 指向全局库 | 实时(app 内存配置 + 重启后仍生效) | +| Claude Code | `~/.claude/skills/` 目录 | 软链优先,失败复制;仅对启用项操作 | 新会话 | +| Codex | `~/.agents/skills/` 目录 | 软链优先,失败复制;仅对启用项操作 | 新会话 | + +### 4.2 自动策略 + +- `auto`:目标存在且不是自建链接 → 保持(绝不覆盖用户内容);目标为自建链接 → 重建;创建链接失败 → 回退复制到临时目录再原子 rename。 +- `copy`:整目录复制到临时目录,`fs.rename` 原子替换;失败保留旧副本。 +- 清理:仅删除"指向 SSOT 的软链"或"有同名全局管理项且已停用"的目标;对其他目标目录内容一律不动。 + +### 4.3 安全红线 + +- 添加/覆盖前校验源目录存在 `SKILL.md`,缺失拒绝。 +- 同一 skill 若目标目录已有同名真实目录(非自建),**不覆盖**,诊断中标记"冲突"。 +- 移除:先备份整目录到 `skill-backups/`,再从各引擎投影目录删除;备份可恢复。 +- 所有目标操作均限定在 `~/.claude/skills`、`~/.agents/skills` 及显式 SSOT 内,不做任何递归删除扩展。 + +## 5. 与大模型接入口(预留,不实现) + +- UI:每个 skill 卡片提供"AI 摘要"按钮位(本期为禁用/说明占位)。 +- 数据层:`skills` 配置预留 `summary` 字段(`string | null`),本期不写入。 +- 未来接入点:设置页保存后把 `{description, source, manifestText}` 传给可配置的摘要服务,回填 `manifest.summary`;服务抽象为 `src/main/skills-summary-provider.js`(本期仅接口占位,不建文件避免死代码,若需可后续加)。 +- 验收:M1 不调用模型,不引入依赖;入口点击提示"将在接入大模型后可用"。 + +## 6. 状态与反馈 + +- 每个 skill 三种引擎状态:启用/停用;行内摘要 + 展开开关。 +- 保存结果条:成功`已同步 Kimi x / Claude y / Codex z`;失败红条指明引擎与恢复动作。 +- 诊断表:引擎/方式/目录/当前加载/状态,冲突与错误高亮。 + +## 7. 关键时序(正常与失败) + +正常保存:收集 UI 变更 → 校验 → 更新 SSOT 与配置 → 投影(Kimi 指针、Claude/Codex 同步)→ 返回汇总。 + +高风险失败:目标目录存在同名真实目录(用户手工 skill)→ 不覆盖、诊断标记冲突、结果条提示;移除后备份失败 → 中止移除并提示。 + +## 8. 测试与验收 + +- 单元:`skills-service.test.js` 覆盖——添加复制、启用矩阵、至少一个引擎、自动软链失败回退复制、只清理自建项、备份/恢复、冲突不覆盖。 +- 类型:`vue-tsc` 仅在涉及 typed 文件时;本项目 renderer 用原生 JS,主要靠 `node --test` + `npm run build`(含类型检查)。 +- 交互验收(用户):打开设置页 Skills 面板,添加本地 skill 后三引擎默认启用;关某引擎后摘要联动;保存后 Kimi 实时、Claude/Codex 新会话生效;移除后备份且可撤销;AI 摘要入口为占位。 + +## 9. 边界与回退 + +- exe 目录不可写:SSOT/备份自动回退 userData,UI 显示"配置存储位置"。 +- 引擎目录不可写/权限不足:该引擎标记"同步失败",不影响其他引擎,且不阻塞保存。 +- 跨盘/网络盘符号链接受限:自动回退复制,不报错阻断。 +- 升级覆盖:配置放 `/config/`,升级包不触碰。 + +## 10. 决策台账 + +| ID | 决策 | 状态 | 答案 | +|---|---|---|---| +| D-01 | 核心心智 | confirmed | 能力资产库,非引擎同步中心 | +| D-02 | 每行引擎控制 | confirmed | 默认全开 + 摘要 + 展开细调 | +| D-03 | 同步方式可见性 | confirmed | 全自动,诊断折叠 | +| D-04 | 移除语义 | confirmed | 先备份、可撤销 | +| D-05 | AI 摘要入口 | confirmed | 预留占位,M3 接入,本期不调用 | +| D-06 | 高级项(junction/仓库/云同步) | deferred | M3+ | + +## 11. 里程碑落地计划 + +- 后端:新增 `src/main/skills-service.js`(库/投影/备份/同步/冲突/诊断);`settings-service.js` 保留 Kimi 兼容并接入新库。 +- IPC/preload:增加 `settings:skills-list` / `settings:skills-save`(或并入 `settings:save`)与 renderer 绑定。 +- UI:替换 `Skills 与 Agent` 面板为 v2 能力资产库;`extra_skill_dirs` 编辑收敛为"指针状态"只读/维护。 +- 提交:按功能边界拆分(后端/配置/UI/测试…),遵循 Conventional Commits,作者 Dylan5237。 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,不重复造轮子。 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:待议,不排期。 +``` diff --git a/docs/assets/prototype-skill-global-m1.html b/docs/assets/prototype-skill-global-m1.html new file mode 100644 index 0000000..51271f1 --- /dev/null +++ b/docs/assets/prototype-skill-global-m1.html @@ -0,0 +1,338 @@ + + + + + +M1 原型 v2 · Skills 全局管理(能力资产库) + + + +
交互原型 v2 · 重新设计能力资产库方向:一个 Skill 只维护一份,按你的选择在引擎会话中加载;同步机制默认隐藏
+ +

Skills 全局管理

+

我的能力资产,一次维护,三引擎随处可用

+ +
+
+
我的能力库全局唯一事实源 · <exe>/config/skills/
+ +
+
+
+ +
+ + +
+
+ +
+
+ 高级与诊断 +
+ + + + + + + +
引擎投影方式目标目录当前加载状态
Kimi Code原生指针(实时)extra_skill_dirs → <exe>/config/skills/0实时生效
Claude Code自动(软链优先)~/.claude/skills/0待同步
Codex自动(软链优先)~/.agents/skills/0待同步
+
自动同步策略:优先软链(省空间、实时),失败自动回退复制;只管理 Workbench 自己创建的投影项,绝不触碰你手工放置的 Skill;移除前自动备份到 <exe>/config/skill-backups/,可随时恢复。
+
+
+
+ + + + + + + + diff --git a/package-lock.json b/package-lock.json index 2481fff..f860d24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,19 @@ { "name": "kcc-workbench", - "version": "1.0.0-beta.2", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kcc-workbench", - "version": "1.0.0-beta.2", + "version": "1.0.0", "license": "MIT", "dependencies": { - "@cloudcli-ai/cloudcli": "^1.37.0", + "@cloudcli-ai/cloudcli": "^1.37.1", "@mermaid-js/layout-elk": "^0.2.2", "better-sqlite3": "^12.6.2", "dompurify": "3.4.13", + "highlight.js": "^11.12.0", "marked": "^12.0.2", "mermaid": "11.16.1" }, @@ -218,9 +219,9 @@ "license": "Apache-2.0" }, "node_modules/@cloudcli-ai/cloudcli": { - "version": "1.37.0", - "resolved": "https://registry.npmmirror.com/@cloudcli-ai/cloudcli/-/cloudcli-1.37.0.tgz", - "integrity": "sha512-fXYzqV6/hJfSSZiaRFHYf/pYIRb11GrAtIr3iAWU+Jzquhrb09RN/A3Ra9oupIZl2BoZ7QdRwvcNnHt8CI5d2Q==", + "version": "1.37.1", + "resolved": "https://registry.npmmirror.com/@cloudcli-ai/cloudcli/-/cloudcli-1.37.1.tgz", + "integrity": "sha512-bHYm2JwvY5U6Yr9cyxbV27IHgCBZSIWvu2+n8CIahal/BP7cpqIkSNjD9Ut4gxF9CNDpJ3o1eiOP6U244xIncg==", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "dependencies": { @@ -235,7 +236,7 @@ "@codemirror/theme-one-dark": "^6.1.2", "@iarna/toml": "^2.2.5", "@octokit/rest": "^22.0.0", - "@openai/codex-sdk": "^0.144.0", + "@openai/codex-sdk": "^0.146.0", "@replit/codemirror-minimap": "^0.5.2", "@tailwindcss/typography": "^0.5.16", "@uiw/react-codemirror": "^4.23.13", @@ -2636,9 +2637,9 @@ } }, "node_modules/@openai/codex": { - "version": "0.144.6", - "resolved": "https://registry.npmmirror.com/@openai/codex/-/codex-0.144.6.tgz", - "integrity": "sha512-wk+2CWiBNXiJLBoN2D08N9RceWkSBnlgk5g2K1a4CXrP/C0gdlHyRUG7RFzm9y41DCK/7tvCct233JVxyFmznw==", + "version": "0.146.1", + "resolved": "https://registry.npmmirror.com/@openai/codex/-/codex-0.146.1.tgz", + "integrity": "sha512-f51R56E/G15soLhf5l5pWUiM+mGHK0NdLozOtzjRoAa+bA20hgWrkyxE/fpwCnuGQM6XNdktHYtK9xQ7bPIbTA==", "license": "Apache-2.0", "bin": { "codex": "bin/codex.js" @@ -2647,19 +2648,19 @@ "node": ">=16" }, "optionalDependencies": { - "@openai/codex-darwin-arm64": "npm:@openai/codex@0.144.6-darwin-arm64", - "@openai/codex-darwin-x64": "npm:@openai/codex@0.144.6-darwin-x64", - "@openai/codex-linux-arm64": "npm:@openai/codex@0.144.6-linux-arm64", - "@openai/codex-linux-x64": "npm:@openai/codex@0.144.6-linux-x64", - "@openai/codex-win32-arm64": "npm:@openai/codex@0.144.6-win32-arm64", - "@openai/codex-win32-x64": "npm:@openai/codex@0.144.6-win32-x64" + "@openai/codex-darwin-arm64": "npm:@openai/codex@0.146.1-darwin-arm64", + "@openai/codex-darwin-x64": "npm:@openai/codex@0.146.1-darwin-x64", + "@openai/codex-linux-arm64": "npm:@openai/codex@0.146.1-linux-arm64", + "@openai/codex-linux-x64": "npm:@openai/codex@0.146.1-linux-x64", + "@openai/codex-win32-arm64": "npm:@openai/codex@0.146.1-win32-arm64", + "@openai/codex-win32-x64": "npm:@openai/codex@0.146.1-win32-x64" } }, "node_modules/@openai/codex-darwin-arm64": { "name": "@openai/codex", - "version": "0.144.6-darwin-arm64", - "resolved": "https://registry.npmmirror.com/@openai/codex/-/codex-0.144.6-darwin-arm64.tgz", - "integrity": "sha512-6zgvh70MzBNSeT17HEhSOrmmGGZGAKzSC7x6JAq+edkJkdPYA9P0I1tG7aJ49GlBkBxuC+MKBH1qm6+2Cghcww==", + "version": "0.146.1-darwin-arm64", + "resolved": "https://registry.npmmirror.com/@openai/codex/-/codex-0.146.1-darwin-arm64.tgz", + "integrity": "sha512-zvXxiGRuKCOoFQyh7lRC2SHwomM2VNzHralyeP+i6fxLjQ5grpmnfJpnLs54+GkK7K5nEpkj7chqjlKbMELgZw==", "cpu": [ "arm64" ], @@ -2674,9 +2675,9 @@ }, "node_modules/@openai/codex-darwin-x64": { "name": "@openai/codex", - "version": "0.144.6-darwin-x64", - "resolved": "https://registry.npmmirror.com/@openai/codex/-/codex-0.144.6-darwin-x64.tgz", - "integrity": "sha512-THRyPG0zSU6M8NQAge1LHEHsJDnoH4BpKsfJHB/qe3Fm+Wf6zqAmWJFlOKzBm27m0K2Hq3za4Ac2I5p5i4yp/A==", + "version": "0.146.1-darwin-x64", + "resolved": "https://registry.npmmirror.com/@openai/codex/-/codex-0.146.1-darwin-x64.tgz", + "integrity": "sha512-XQbemMBWH37hVL2vkoP+t//QyUmMjDRksf8YHmhAzHmt2oTTo8xyzH76LLFqMPCNpTdG755T8ZCbkpM0X2NJwA==", "cpu": [ "x64" ], @@ -2691,9 +2692,9 @@ }, "node_modules/@openai/codex-linux-arm64": { "name": "@openai/codex", - "version": "0.144.6-linux-arm64", - "resolved": "https://registry.npmmirror.com/@openai/codex/-/codex-0.144.6-linux-arm64.tgz", - "integrity": "sha512-PGiLXMN+2IQRkf7tOLi64dMInjU1pRLbz0Rwfj/yt2Y97SZQqAjFQoi2wmswmqtqMDnfwCPTC1DRXVQkvU6T6Q==", + "version": "0.146.1-linux-arm64", + "resolved": "https://registry.npmmirror.com/@openai/codex/-/codex-0.146.1-linux-arm64.tgz", + "integrity": "sha512-2LhK2axFZKe6BbhlzGXCpVkSva1TikFYJIq6x7+xu0AdfmY855EV5lQ7lwFpjJfM5gAJqxcycttvE1ODoSoLaA==", "cpu": [ "arm64" ], @@ -2708,9 +2709,9 @@ }, "node_modules/@openai/codex-linux-x64": { "name": "@openai/codex", - "version": "0.144.6-linux-x64", - "resolved": "https://registry.npmmirror.com/@openai/codex/-/codex-0.144.6-linux-x64.tgz", - "integrity": "sha512-4E7EnzCg0OnBxCyYnwJ+qnZwWHYe0YScr5ucKWbngE9u4+0XrpWELqq2Kn9jl5GZK8MDjU7PrJwFIwusHOHjuw==", + "version": "0.146.1-linux-x64", + "resolved": "https://registry.npmmirror.com/@openai/codex/-/codex-0.146.1-linux-x64.tgz", + "integrity": "sha512-XPWpWzJgGK1ygsUBMXgskEkr+uTVj/XOnnCP2UE9tQUXTWYRYElz7QW1YS3bxkN6Kev4CJQKbdJopVwh+0E/TQ==", "cpu": [ "x64" ], @@ -2724,12 +2725,12 @@ } }, "node_modules/@openai/codex-sdk": { - "version": "0.144.6", - "resolved": "https://registry.npmmirror.com/@openai/codex-sdk/-/codex-sdk-0.144.6.tgz", - "integrity": "sha512-jFgXHjFq2//PTfJa8CySqhUilRBPVmCLuQoH5F+iJ2x4owZwPlnqmIbCkWIJJ6IxDuQdjf3ewg0LaDtc3i6h9Q==", + "version": "0.146.1", + "resolved": "https://registry.npmmirror.com/@openai/codex-sdk/-/codex-sdk-0.146.1.tgz", + "integrity": "sha512-Sf7xkut0XctKaS3CC3PnYtXkCNfZZKZthx8NIQS5wXrngRyU1f8MxZsD6iWkZYRlHYJ1NZwi3vvPntVYrRxBQw==", "license": "Apache-2.0", "dependencies": { - "@openai/codex": "0.144.6" + "@openai/codex": "0.146.1" }, "engines": { "node": ">=18" @@ -2737,9 +2738,9 @@ }, "node_modules/@openai/codex-win32-arm64": { "name": "@openai/codex", - "version": "0.144.6-win32-arm64", - "resolved": "https://registry.npmmirror.com/@openai/codex/-/codex-0.144.6-win32-arm64.tgz", - "integrity": "sha512-SpMjXJLW43JzMP0K62mVcYfmFcpk0BK4AOgYmWSfyZHs3iRtHMd0UYw7605n/9lwkT2EqbwQLT2omZFeKJFzwA==", + "version": "0.146.1-win32-arm64", + "resolved": "https://registry.npmmirror.com/@openai/codex/-/codex-0.146.1-win32-arm64.tgz", + "integrity": "sha512-3UJf8sKefCbUgPPcgQRiCaKn9AsItuPl0ZL6erHWzT6UIemrdjt2WHiTNOw/7tVJRnUsG4A1LUrK2to9/SkwXA==", "cpu": [ "arm64" ], @@ -2754,9 +2755,9 @@ }, "node_modules/@openai/codex-win32-x64": { "name": "@openai/codex", - "version": "0.144.6-win32-x64", - "resolved": "https://registry.npmmirror.com/@openai/codex/-/codex-0.144.6-win32-x64.tgz", - "integrity": "sha512-dN39VnjEthKz5io1RNWwZDtErdSn07nW3pGUgvlA6DMxgm/nuGaIAZO/sG/Hgxq/x5j9HteAENfrFgVkpZ0lFg==", + "version": "0.146.1-win32-x64", + "resolved": "https://registry.npmmirror.com/@openai/codex/-/codex-0.146.1-win32-x64.tgz", + "integrity": "sha512-FfiNJysKpHkJ08Kn+571r701dgoZVZjqNUxrEZ/JJyu5hIzNTs4XBllAX6ynHkF8G9NebPEtaJz72zxbol/JPg==", "cpu": [ "x64" ], @@ -7642,12 +7643,12 @@ } }, "node_modules/highlight.js": { - "version": "10.7.3", - "resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-10.7.3.tgz", - "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "version": "11.12.0", + "resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.12.0.tgz", + "integrity": "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==", "license": "BSD-3-Clause", "engines": { - "node": "*" + "node": ">=12.0.0" } }, "node_modules/highlightjs-vue": { @@ -8549,6 +8550,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/lowlight/node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -10893,6 +10903,15 @@ "react": ">= 0.14.0" } }, + "node_modules/react-syntax-highlighter/node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/read-binary-file-arch": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", diff --git a/package.json b/package.json index 3e0c45e..3e1afff 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "demo": "electron . --demo", "test": "node --test", "bundle:elk": "node scripts/bundle-layout-elk.mjs", + "bundle:highlight": "node scripts/bundle-highlight.mjs", "build": "electron-builder --win --dir", "dist": "electron-builder --win", "pack": "node scripts/pack.mjs" @@ -238,10 +239,11 @@ } }, "dependencies": { - "@cloudcli-ai/cloudcli": "^1.37.0", + "@cloudcli-ai/cloudcli": "^1.37.1", "@mermaid-js/layout-elk": "^0.2.2", "better-sqlite3": "^12.6.2", "dompurify": "3.4.13", + "highlight.js": "^11.12.0", "marked": "^12.0.2", "mermaid": "11.16.1" } diff --git a/scripts/bundle-highlight.mjs b/scripts/bundle-highlight.mjs new file mode 100644 index 0000000..cde5c20 --- /dev/null +++ b/scripts/bundle-highlight.mjs @@ -0,0 +1,20 @@ +import { build } from 'esbuild' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { promises as fs } from 'node:fs' + +const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))) +const outdir = path.join(root, 'src', 'viewer', 'public', 'vendor') +await fs.mkdir(outdir, { recursive: true }) +await build({ + absWorkingDir: root, + entryPoints: ['node_modules/highlight.js/lib/index.js'], + bundle: true, + format: 'iife', + globalName: 'hljs', + target: ['chrome120'], + outfile: path.join(outdir, 'highlight.min.js'), + sourcemap: false, + logLevel: 'info' +}) +console.log('bundled highlight.js') diff --git a/src/main/main.js b/src/main/main.js index c5c9e6d..fdfab92 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -28,7 +28,15 @@ import { extractCloudCliSessionContext, parseCloudCliSessionId } from './cloudcli-context.js' -import { SettingsService } from './settings-service.js' +import { SettingsService, hasEngineConfigChanged } from './settings-service.js' +import { WorkbenchConfigService, resolveStartupEngine } from './workbench-config.js' +import { + addSkillToLibrary, + loadSkillsState, + removeSkill, + restoreSkill, + syncSkillToEngines +} from './skills-service.js' import { copyPathsToWindowsClipboard } from './windows-file-clipboard.js' import { requireSender, normalizeForkRequest } from './ipc-validators.js' import { createKimiCodeUrlGuard } from './url-trust.js' @@ -71,6 +79,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 +174,13 @@ 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() + activeEngine = resolveStartupEngine(await workbenchConfigService.get()) quotaService = new QuotaService({ userDataPath: app.getPath('userData'), partition: SESSION_PARTITION, @@ -304,6 +320,10 @@ async function createMainWindow() { }) createKimiView() createCloudCliView() + if (activeEngine === 'cloudcli') { + mainWindow.contentView.removeChildView(kimiView) + mainWindow.contentView.addChildView(cloudCliView) + } createViewerView() viewerContextSync.start() createSettingsView() @@ -564,7 +584,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 = { @@ -602,29 +621,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') } @@ -681,12 +701,17 @@ function wireIpc() { ipcMain.removeHandler('quota:set-preferred-height') ipcMain.removeHandler('viewer:select-directory') ipcMain.removeHandler('viewer:set-root') + ipcMain.removeHandler('viewer:get-workbench-mode') ipcMain.removeHandler('viewer:fork-checkpoint') ipcMain.removeHandler('viewer:copy-files') ipcMain.removeHandler('viewer:copy-png') ipcMain.removeHandler('settings:get-state') ipcMain.removeHandler('settings:save') ipcMain.removeHandler('settings:select-directory') + ipcMain.removeHandler('settings:skills-add') + ipcMain.removeHandler('settings:skills-remove') + ipcMain.removeHandler('settings:skills-restore') + ipcMain.removeHandler('settings:skills-sync') ipcMain.handle('shell:get-state', event => { requireSender(event, shellView.webContents) @@ -769,6 +794,11 @@ function wireIpc() { }) return result.canceled ? null : result.filePaths[0] }) + ipcMain.handle('viewer:get-workbench-mode', async event => { + requireSender(event, viewerView.webContents) + const config = await workbenchConfigService.get() + return config.viewerMode || 'auto' + }) ipcMain.handle('viewer:set-root', async (event, nextRoot) => { requireSender(event, viewerView.webContents) if (typeof nextRoot !== 'string' || nextRoot.length > 4096) { @@ -865,17 +895,169 @@ function wireIpc() { ipcMain.handle('settings:get-state', async event => { requireSender(event, settingsView.webContents) settingsService.setProjectDirectory(await detectKimiProjectDirectory()) - return settingsService.getState() + const workbenchInfo = await workbenchConfigService.get() + const libraryPath = resolveSkillsLibraryPath(workbenchInfo) + await ensureKimiSkillPointer(libraryPath) + const kimiState = await settingsService.getState() + const skills = await loadSkillsState({ + libraryPath, + homePath: app.getPath('home'), + override: workbenchInfo.skills?.targets, + syncMethods: workbenchInfo.skills?.projection, + managedConfig: workbenchInfo.skills?.managed + }) + return { + ...kimiState, + skills, + workbench: { + config: await workbenchConfigService.get(), + ...workbenchConfigService.describe() + } + } }) 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() } - return nextState + const workbenchInfo = await workbenchConfigService.save(payload?.workbench || {}) + return { + ...nextState, + workbench: { + config: await workbenchConfigService.get(), + ...workbenchInfo + } + } + }) + ipcMain.handle('settings:skills-add', async (event, payload) => { + requireSender(event, settingsView.webContents) + if (!payload || typeof payload !== 'object' || typeof payload.sourceDir !== 'string') { + throw new Error('Skill 源目录无效') + } + const workbenchInfo = await workbenchConfigService.get() + const libraryPath = resolveSkillsLibraryPath(workbenchInfo) + await ensureKimiSkillPointer(libraryPath) + const added = await addSkillToLibrary({ libraryPath, sourceDir: payload.sourceDir }) + const apps = defaultSkillApps(payload.apps) + const syncMethods = workbenchInfo.skills?.projection || {} + const results = await syncSkillToEngines({ + libraryPath, + skillName: added.name, + apps, + homePath: app.getPath('home'), + override: workbenchInfo.skills?.targets, + syncMethods + }) + const workbenchNext = await workbenchConfigService.save({ + skills: { + ...workbenchInfo.skills, + managed: { ...(workbenchInfo.skills?.managed || {}), [added.name]: { apps } } + } + }) + return { + added, + results, + workbench: { config: await workbenchConfigService.get(), ...workbenchNext } + } + }) + ipcMain.handle('settings:skills-remove', async (event, payload) => { + requireSender(event, settingsView.webContents) + if (!payload || typeof payload !== 'object' || typeof payload.name !== 'string') { + throw new Error('Skill 名称无效') + } + const workbenchInfo = await workbenchConfigService.get() + const libraryPath = resolveSkillsLibraryPath(workbenchInfo) + const managed = workbenchInfo.skills?.managed || {} + const apps = managed[payload.name]?.apps || {} + const backupDir = path.join(workbenchConfigService.storageDir, 'skill-backups') + const removed = await removeSkill({ + libraryPath, + skillName: payload.name, + homePath: app.getPath('home'), + override: workbenchInfo.skills?.targets, + backupDir, + apps + }) + const nextManaged = { ...managed } + delete nextManaged[payload.name] + const workbenchNext = await workbenchConfigService.save({ + skills: { ...workbenchInfo.skills, managed: nextManaged } + }) + return { removed, workbench: { config: await workbenchConfigService.get(), ...workbenchNext } } + }) + ipcMain.handle('settings:skills-restore', async (event, payload) => { + requireSender(event, settingsView.webContents) + if (!payload || typeof payload !== 'object' + || typeof payload.name !== 'string' + || typeof payload.backupPath !== 'string') { + throw new Error('Skill 恢复参数无效') + } + const workbenchInfo = await workbenchConfigService.get() + const libraryPath = resolveSkillsLibraryPath(workbenchInfo) + const backupDir = path.join(workbenchConfigService.storageDir, 'skill-backups') + const restored = await restoreSkill({ + libraryPath, + skillName: payload.name, + backupDir, + backupPath: payload.backupPath + }) + const apps = defaultSkillApps(payload.apps) + await ensureKimiSkillPointer(libraryPath) + const results = await syncSkillToEngines({ + libraryPath, + skillName: restored.name, + apps, + homePath: app.getPath('home'), + override: workbenchInfo.skills?.targets, + syncMethods: workbenchInfo.skills?.projection + }) + const workbenchNext = await workbenchConfigService.save({ + skills: { + ...workbenchInfo.skills, + managed: { + ...(workbenchInfo.skills?.managed || {}), + [restored.name]: { apps } + } + } + }) + return { + restored: { ...restored, apps }, + results, + workbench: { config: await workbenchConfigService.get(), ...workbenchNext } + } + }) + ipcMain.handle('settings:skills-sync', async (event, payload) => { + requireSender(event, settingsView.webContents) + const workbenchInfo = await workbenchConfigService.get() + const libraryPath = resolveSkillsLibraryPath(workbenchInfo) + await ensureKimiSkillPointer(libraryPath) + const managed = workbenchInfo.skills?.managed || {} + const targetApps = payload?.apps || {} + const results = {} + for (const [name, apps] of Object.entries(managed)) { + const effectiveApps = { ...apps, ...(targetApps[name] || {}) } + results[name] = await syncSkillToEngines({ + libraryPath, + skillName: name, + apps: effectiveApps, + homePath: app.getPath('home'), + override: workbenchInfo.skills?.targets, + syncMethods: workbenchInfo.skills?.projection + }) + } + const workbenchNext = await workbenchConfigService.save({ + skills: { + ...workbenchInfo.skills, + managed: { + ...managed, + ...Object.fromEntries(Object.entries(targetApps).map(([name, apps]) => [name, { apps: normalizeSkillApps(apps) }])) + } + } + }) + return { results, workbench: { config: await workbenchConfigService.get(), ...workbenchNext } } }) ipcMain.handle('settings:select-directory', async event => { requireSender(event, settingsView.webContents) @@ -1365,3 +1547,40 @@ async function runSelfTestMode(outputPath) { }, null, 2)) } } + +function resolveSkillsLibraryPath(workbenchInfo) { + return path.resolve( + workbenchInfo?.skills?.library + || path.join(workbenchConfigService.storageDir, 'skills') + ) +} + +async function ensureKimiSkillPointer(libraryPath) { + if (!settingsService) return false + const before = await settingsService.getState() + const current = Array.isArray(before.config?.extra_skill_dirs) + ? before.config.extra_skill_dirs + : [] + const normalizedLibrary = path.resolve(libraryPath) + if (current.some(item => path.resolve(String(item)) === normalizedLibrary)) return false + const next = [...current, normalizedLibrary] + const after = await settingsService.save({ config: { extra_skill_dirs: next } }) + if (hasEngineConfigChanged(before.config, after.config)) { + localKimiService?.stop() + if (kimiView && !kimiView.webContents.isDestroyed()) await connectLocalKimiView() + } + return true +} + + +function defaultSkillApps(apps) { + if (apps && typeof apps === 'object') return normalizeSkillApps(apps) + return { kimi: true, claude: true, codex: true } +} + +function normalizeSkillApps(apps) { + const result = { kimi: false, claude: false, codex: false } + for (const key of ['kimi', 'claude', 'codex']) result[key] = Boolean(apps?.[key]) + if (!result.kimi && !result.claude && !result.codex) result.kimi = true + return result +} 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/src/main/skills-service.js b/src/main/skills-service.js new file mode 100644 index 0000000..1960b2f --- /dev/null +++ b/src/main/skills-service.js @@ -0,0 +1,397 @@ +import path from 'node:path' +import { promises as fs } from 'node:fs' +import os from 'node:os' + +export const ENGINES = Object.freeze(['kimi', 'claude', 'codex']) +export const SYNC_METHODS = Object.freeze(['auto', 'symlink', 'copy']) +export const ENGINE_DIRS = Object.freeze({ + kimi: null, + claude: ['~', '.claude', 'skills'], + codex: ['~', '.agents', 'skills'] +}) + +const DEFAULT_APPS = () => ({ kimi: true, claude: true, codex: true }) +const PROJECTION_MARKER = '.kcc-workbench-projection.json' + +function normalizeApps(apps) { + const result = { kimi: false, claude: false, codex: false } + for (const key of ENGINES) result[key] = Boolean(apps?.[key]) + return result +} + +function ensureAtLeastOneEnabled(apps) { + const enabled = ENGINES.some(key => apps[key]) + if (!enabled) apps.kimi = true + return apps +} + +function sanitizeName(name) { + if (!name || name.length > 120) return null + if (name.includes('/') || name.includes('\\')) return null + if (name === '.' || name === '..') return null + if (/[\u0000-\u001f\u007f]/.test(name)) return null + return name +} + +export function withMinimumOneEnabled(apps) { + return ensureAtLeastOneEnabled(normalizeApps(apps)) +} + +function resolveHome(homePath) { + return path.resolve(homePath || os.homedir()) +} + +function engineTargetDir(engine, homePath, override = {}) { + const rel = override?.[engine] || ENGINE_DIRS[engine] + if (!rel) return null + if (Array.isArray(rel)) return path.join(resolveHome(homePath), ...rel.slice(1)) + return path.resolve(rel) +} + +async function fileExists(filePath) { + try { return (await fs.stat(filePath)).isFile() } catch { return false } +} +async function dirExists(filePath) { + try { return (await fs.stat(filePath)).isDirectory() } catch { return false } +} +async function isSymlink(filePath) { + try { return (await fs.lstat(filePath)).isSymbolicLink() } catch { return false } +} + +async function pathExists(filePath) { + try { + await fs.lstat(filePath) + return true + } catch { + return false + } +} + +async function readManifest(skillDir) { + const manifestPath = path.join(skillDir, 'SKILL.md') + if (!(await fileExists(manifestPath))) return null + let text = '' + try { text = await fs.readFile(manifestPath, 'utf8') } catch { text = '' } + return { manifestPath, text } +} + +function parseFrontmatter(text) { + if (!text || !text.startsWith('---')) return { name: '', description: '' } + const end = text.indexOf('\n---', 4) + const block = end === -1 ? '' : text.slice(4, end) + let name = '' + let description = '' + for (const line of block.split('\n')) { + if (line.startsWith('name:')) name = line.slice(5).trim().replace(/^["']|["']$/g, '') + if (line.startsWith('description:')) description = line.slice(12).trim().replace(/^["']|["']$/g, '') + } + return { name, description } +} + +/** + * 从全局库读取管理状态与各引擎诊断。 + * override:可选 { claude, codex } 自定义投影目标目录(供测试/高级配置)。 + */ +export async function loadSkillsState({ libraryPath, homePath, override, syncMethods = {}, managedConfig = {} }) { + const ssotDir = path.resolve(libraryPath) + const managed = [] + let entries = [] + try { + entries = await fs.readdir(ssotDir, { withFileTypes: true }) + } catch (error) { + if (error.code !== 'ENOENT' && error.code !== 'EACCES') throw error + } + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith('.')) continue + const skillDir = path.join(ssotDir, entry.name) + const manifest = await readManifest(skillDir) + if (!manifest) continue + const front = parseFrontmatter(manifest.text) + managed.push({ + name: entry.name, + nameFromManifest: front.name || entry.name, + description: front.description || '', + directory: skillDir, + apps: withMinimumOneEnabled(managedConfig?.[entry.name]?.apps || DEFAULT_APPS()) + }) + } + managed.sort((a, b) => a.name.localeCompare(b.name)) + return { + libraryPath: ssotDir, + managed, + diagnostics: buildDiagnostics(managed, { homePath, override, syncMethods }) + } +} + +function buildDiagnostics(managed, { homePath, override, syncMethods }) { + return { + kimi: { + method: 'pointer', + directory: null, + enabled: managed.filter(s => s.apps.kimi).length, + status: 'ok', + error: null + }, + claude: buildEngineDiagnostic(managed, 'claude', { homePath, override, syncMethods }), + codex: buildEngineDiagnostic(managed, 'codex', { homePath, override, syncMethods }) + } +} + +function buildEngineDiagnostic(managed, engine, { homePath, override, syncMethods }) { + const dir = engineTargetDir(engine, homePath, override) + const enabled = managed.filter(s => s.apps[engine]).length + const method = syncMethods?.[engine] || 'auto' + return { + method: SYNC_METHODS.includes(method) ? method : 'auto', + directory: dir, + enabled, + status: 'ok', + error: null + } +} + +/** + * 把本地 skill 目录加入全局库(仅含 SKILL.md 的目录;拒绝覆盖)。 + */ +export async function addSkillToLibrary({ libraryPath, sourceDir }) { + const ssotDir = path.resolve(libraryPath) + const resolvedSource = path.resolve(sourceDir) + const manifest = await readManifest(resolvedSource) + if (!manifest) throw new Error(`不是有效 Skill:缺少 SKILL.md(${sourceDir})`) + const name = path.basename(resolvedSource) + if (!sanitizeName(name)) throw new Error(`无效的 Skill 名称:${name}`) + const destDir = path.join(ssotDir, name) + if (await dirExists(destDir)) throw new Error(`全局库已存在同名 Skill:${name}`) + await fs.mkdir(ssotDir, { recursive: true }) + const tmpDir = path.join(ssotDir, `.${name}.tmp-${process.pid}-${Date.now()}`) + try { + await fs.cp(resolvedSource, tmpDir, { recursive: true }) + if (!(await readManifest(tmpDir))) throw new Error('源 Skill 缺少 SKILL.md') + await fs.rename(tmpDir, destDir) + } catch (error) { + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}) + throw error + } + const addedManifest = await readManifest(destDir) + const front = parseFrontmatter(addedManifest?.text || '') + return { + name, + nameFromManifest: front.name || name, + description: front.description || '', + directory: destDir, + apps: DEFAULT_APPS() + } +} + +async function linkPointsTo(source, linkPath) { + try { + const target = await fs.readlink(linkPath) + const absolute = path.isAbsolute(target) ? target : path.resolve(path.dirname(linkPath), target) + return path.resolve(absolute) === path.resolve(source) + } catch { + return false + } +} + +async function removePath(target) { + if (await isSymlink(target)) { + await fs.unlink(target) + return + } + try { await fs.rm(target, { recursive: true, force: false }) } catch (e) { if (e.code !== 'ENOENT') throw e } +} + +async function copyReplace(source, dest) { + await fs.mkdir(path.dirname(dest), { recursive: true }) + const tmpDir = `${dest}.tmp-${process.pid}-${Date.now()}` + await fs.cp(source, tmpDir, { recursive: true }) + try { + await fs.writeFile(path.join(tmpDir, PROJECTION_MARKER), JSON.stringify({ + source: path.resolve(source), + skillName: path.basename(source), + version: 1 + }), 'utf8') + await removePath(dest) + await fs.rename(tmpDir, dest) + } catch (error) { + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}) + throw error + } +} + +/** + * 投影单个 skill 到单引擎目标目录。 + * - Kimi:指针,无目录操作 + * - auto:优先 symlink,失败回退 copy + * - 目标已有用户手工真实目录 → 不覆盖,返回 conflict + */ +export async function syncToEngine({ libraryPath, skillName, engine, homePath, override, method, force = false }) { + if (!sanitizeName(skillName)) throw new Error(`无效的 Skill 名称:${skillName}`) + const ssotDir = path.resolve(libraryPath) + const source = path.join(ssotDir, skillName) + if (!(await readManifest(source))) throw new Error(`Skill 源缺失:${skillName}`) + + const target = engineTargetDir(engine, homePath, override) + if (!target) return { ok: true, method: 'pointer' } + + await fs.mkdir(target, { recursive: true }) + const dest = path.join(target, skillName) + const resolvedMethod = SYNC_METHODS.includes(method) ? method : 'auto' + const existingPath = await pathExists(dest) + const existingDir = await dirExists(dest) + const existingLink = await isSymlink(dest) + const isSelfLink = existingLink && await linkPointsTo(source, dest) + const isOwnedCopy = existingDir && await projectionMarkerMatches(dest, source, skillName) + + if (existingPath && !force && !existingLink && !isOwnedCopy) { + return { ok: false, conflict: true, error: `目标已存在同名路径:${dest}` } + } + if (existingLink && !isSelfLink && !force) { + return { ok: false, conflict: true, error: `目标是非 Workbench 链接:${dest}` } + } + if (existingLink && isSelfLink && resolvedMethod !== 'copy') { + return { ok: true, method: 'symlink', reused: true } + } + + if (resolvedMethod === 'copy') { + await copyReplace(source, dest) + return { ok: true, method: 'copy' } + } + + // auto / symlink:先尝试软链 + try { + if (existingLink) await removePath(dest) + await fs.symlink(source, dest) + return { ok: true, method: 'symlink' } + } catch { + await copyReplace(source, dest) + return { ok: true, method: 'copy', fallback: true } + } +} + +/** + * 同步 skill 到所有启用引擎;返回每引擎结果。 + */ +export async function syncSkillToEngines({ libraryPath, skillName, apps, homePath, override, syncMethods }) { + const results = {} + for (const engine of ENGINES) { + if (!apps?.[engine]) continue + try { + results[engine] = await syncToEngine({ + libraryPath, + skillName, + engine, + homePath, + override, + method: syncMethods?.[engine] + }) + } catch (error) { + results[engine] = { ok: false, error: error.message } + } + } + return results +} + +export async function backupSkill({ libraryPath, skillName, backupDir }) { + if (!sanitizeName(skillName)) throw new Error(`无效的 Skill 名称:${skillName}`) + const source = path.join(path.resolve(libraryPath), skillName) + if (!(await dirExists(source))) return null + const stamp = new Date().toISOString().replace(/[:.]/g, '-') + const dest = path.join(path.resolve(backupDir), `${skillName}-${stamp}`) + await fs.mkdir(dest, { recursive: true }) + await fs.cp(source, dest, { recursive: true }) + return dest +} + +async function projectionMarkerMatches(dest, source, skillName) { + try { + const marker = JSON.parse(await fs.readFile(path.join(dest, PROJECTION_MARKER), 'utf8')) + return marker?.version === 1 + && marker.skillName === skillName + && path.resolve(marker.source || '') === path.resolve(source) + } catch { + return false + } +} + +export async function restoreSkill({ libraryPath, skillName, backupDir, backupPath }) { + if (!sanitizeName(skillName)) throw new Error(`无效的 Skill 名称:${skillName}`) + const root = path.resolve(backupDir) + const source = path.resolve(backupPath) + if (source !== root && !source.startsWith(`${root}${path.sep}`)) { + throw new Error('备份路径不在 Skill 备份目录中') + } + if (!(await readManifest(source))) throw new Error('备份不是有效 Skill:缺少 SKILL.md') + const destination = path.join(path.resolve(libraryPath), skillName) + if (await dirExists(destination)) throw new Error(`全局库已存在同名 Skill:${skillName}`) + await fs.mkdir(path.dirname(destination), { recursive: true }) + const temporary = `${destination}.restore-${process.pid}-${Date.now()}` + try { + await fs.cp(source, temporary, { recursive: true }) + await fs.rename(temporary, destination) + } catch (error) { + await fs.rm(temporary, { recursive: true, force: true }).catch(() => {}) + throw error + } + const manifest = await readManifest(destination) + const front = parseFrontmatter(manifest?.text || '') + return { + name: skillName, + nameFromManifest: front.name || skillName, + description: front.description || '', + directory: destination, + apps: DEFAULT_APPS() + } +} + +/** + * 移除 skill:先备份,再从启用引擎投影目录移除自建项,最后删除 SSOT 项。 + */ +export async function removeSkill({ libraryPath, skillName, homePath, override, backupDir, apps }) { + if (!sanitizeName(skillName)) throw new Error(`无效的 Skill 名称:${skillName}`) + const backupPath = await backupSkill({ libraryPath, skillName, backupDir }) + if (!backupPath) throw new Error(`Skill 不存在:${skillName}`) + const source = path.join(path.resolve(libraryPath), skillName) + const removal = {} + for (const engine of ENGINES) { + if (!apps?.[engine]) continue + const target = engineTargetDir(engine, homePath, override) + if (!target) continue + const dest = path.join(target, skillName) + try { + if (await isSymlink(dest)) { + if (await linkPointsTo(source, dest)) { + await fs.unlink(dest) + removal[engine] = 'removed' + } else { + removal[engine] = { status: 'conflict', error: `未删除非 Workbench 链接:${dest}` } + } + } else if (await dirExists(dest)) { + if (await projectionMarkerMatches(dest, source, skillName)) { + await fs.rm(dest, { recursive: true, force: false }) + removal[engine] = 'removed' + } else { + removal[engine] = { status: 'conflict', error: `未删除用户目录:${dest}` } + } + } else if (await pathExists(dest)) { + removal[engine] = { status: 'conflict', error: `未删除用户路径:${dest}` } + } else { + removal[engine] = 'removed' + } + } catch (error) { + removal[engine] = `移除失败:${error.message}` + } + } + await fs.rm(path.join(path.resolve(libraryPath), skillName), { recursive: true, force: true }) + return { backupPath, removal } +} + +export const __testing = { + sanitizeName, + parseFrontmatter, + resolveHome, + engineTargetDir, + linkPointsTo, + projectionMarkerMatches, + buildDiagnostics +} diff --git a/src/main/workbench-config.js b/src/main/workbench-config.js new file mode 100644 index 0000000..34f1bf9 --- /dev/null +++ b/src/main/workbench-config.js @@ -0,0 +1,90 @@ +import path from 'node:path' +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 产品层)。 + * + * 存储位置默认在 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/src/preload/settings.cjs b/src/preload/settings.cjs index ea22712..a739cb6 100644 --- a/src/preload/settings.cjs +++ b/src/preload/settings.cjs @@ -3,5 +3,9 @@ const { contextBridge, ipcRenderer } = require('electron') contextBridge.exposeInMainWorld('desktopSettings', { getState: () => ipcRenderer.invoke('settings:get-state'), save: payload => ipcRenderer.invoke('settings:save', payload), - selectDirectory: () => ipcRenderer.invoke('settings:select-directory') + selectDirectory: () => ipcRenderer.invoke('settings:select-directory'), + addSkill: payload => ipcRenderer.invoke('settings:skills-add', payload), + removeSkill: payload => ipcRenderer.invoke('settings:skills-remove', payload), + restoreSkill: payload => ipcRenderer.invoke('settings:skills-restore', payload), + syncSkills: payload => ipcRenderer.invoke('settings:skills-sync', payload) }) diff --git a/src/preload/viewer.cjs b/src/preload/viewer.cjs index 28d8c6a..5f1bb53 100644 --- a/src/preload/viewer.cjs +++ b/src/preload/viewer.cjs @@ -6,5 +6,6 @@ contextBridge.exposeInMainWorld('electronAPI', { forkCheckpoint: input => ipcRenderer.invoke('viewer:fork-checkpoint', input), copyFiles: paths => ipcRenderer.invoke('viewer:copy-files', paths), copyPng: bytes => ipcRenderer.invoke('viewer:copy-png', bytes), - trashItem: paths => ipcRenderer.invoke('viewer:trash-item', paths) + trashItem: paths => ipcRenderer.invoke('viewer:trash-item', paths), + getViewerMode: () => ipcRenderer.invoke('viewer:get-workbench-mode') }) diff --git a/src/renderer/settings.css b/src/renderer/settings.css index ba69e64..262ab46 100644 --- a/src/renderer/settings.css +++ b/src/renderer/settings.css @@ -101,3 +101,44 @@ button:disabled { cursor: default; opacity: .42; } .settings-shell { grid-template-columns: 202px minmax(0, 1fr); } .save-bar { left: 202px; } } + +/* ===== M1: Skills 全局管理(能力资产库) ===== */ +.skill-title { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.skill-row { border-bottom: 1px solid #ededed; } +.skill-row:last-child { border-bottom: 0; } +.skill-line { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 10px 16px; padding: 13px 18px; align-items: center; } +.skill-main { display: flex; flex-direction: column; gap: 4px; min-width: 0; } +.skill-name { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 650; } +.skill-name small { overflow: hidden; max-width: 220px; color: #8a8a8a; font-size: 10px; font-weight: 400; text-overflow: ellipsis; white-space: nowrap; } +.skill-desc { color: #8a8a8a; font-size: 11px; line-height: 1.5; } +.skill-actions { display: flex; align-items: center; gap: 8px; } +.summary-chips { display: flex; gap: 5px; } +.chip { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; color: #666; font-size: 10px; background: #f4f4f4; border-radius: 999px; } +.chip.on { color: #23743d; background: #effaf2; } +.chip.off { color: #a7a7a7; background: #f4f4f4; } +.skill-toggle { height: 27px; padding: 0 9px; color: #377cf6; font-size: 11px; background: transparent; border: 1px solid transparent; border-radius: 7px; } +.skill-toggle:hover { background: #f2f6fc; } +.skill-ai { height: 27px; padding: 0 9px; color: #9b9b9b; font-size: 11px; background: #f7f7f7; border: 1px dashed #d8d8d8; border-radius: 7px; cursor: default; } +.skill-remove { height: 27px; padding: 0 9px; color: #a13c33; font-size: 11px; background: #fff; border: 1px solid #f0d5d0; border-radius: 7px; } +.skill-remove:hover { background: #fdf2f0; } +.skill-undo { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: -4px 0 14px; padding: 10px 14px; color: #73570b; font-size: 11px; background: #fffaf0; border: 1px solid #f0dfb7; border-radius: 10px; } +.skill-undo.hidden { display: none; } +.skill-detail { display: flex; flex-direction: column; padding: 0 18px 14px; } +.engine-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 0; border-top: 1px dashed #eee; } +.engine-row:first-child { border-top: 0; } +.engine-row > div { display: flex; flex-direction: column; gap: 3px; font-size: 12px; } +.engine-row small { color: #8a8a8a; font-size: 10px; font-weight: 400; } +.skills-advanced { margin-top: 2px; } +.skills-advanced summary { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 15px 18px; color: #666; cursor: pointer; list-style: none; } +.skills-advanced summary::-webkit-details-marker { display: none; } +.skills-advanced summary::after { content: "▾"; color: #999; transition: .15s; } +.skills-advanced[open] summary::after { transform: rotate(180deg); } +.skills-advanced summary span { color: #444; font-size: 13px; font-weight: 650; } +.adv-body { padding: 0 18px 16px; } +.adv-table { width: 100%; border-collapse: collapse; font-size: 11px; } +.adv-table th { padding: 7px 8px; color: #8a8a8a; font-size: 10px; text-align: left; font-weight: 650; border-bottom: 1px solid #e8e8e8; } +.adv-table td { padding: 8px; color: #555; border-bottom: 1px solid #f2f2f2; } +.adv-table code { color: #444; font-family: Consolas, monospace; font-size: 10px; } +.adv-table .ok { color: #23743d; font-weight: 650; } +.adv-note { margin-top: 12px; padding: 10px 12px; color: #31527a; font-size: 11px; background: #f2f6fc; border-radius: 9px; line-height: 1.6; } +.adv-note.ai-note { color: #73570b; background: #fffaf0; } diff --git a/src/renderer/settings.html b/src/renderer/settings.html index 8ddf251..958d9cd 100644 --- a/src/renderer/settings.html +++ b/src/renderer/settings.html @@ -12,10 +12,13 @@