nx-sk 0.23.0 → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +27 -86
  2. package/README.md +0 -14
  3. package/assets/nx-sk/references/10-sections-entries.md +10 -76
  4. package/assets/nx-sk/references/60-transfer.md +4 -10
  5. package/package.json +1 -12
  6. package/src/core/attachments.js +1 -34
  7. package/src/core/paths.js +0 -11
  8. package/src/core/render.js +0 -13
  9. package/src/core/store.js +3 -51
  10. package/src/core/vault.js +2 -18
  11. package/src/modules/entries/index.js +0 -147
  12. package/src/modules/entries/service.js +0 -517
  13. package/src/modules/entries/view.jsx +6 -136
  14. package/src/modules/sections/index.js +0 -65
  15. package/src/modules/sections/service.js +1 -290
  16. package/src/modules/settings/service.js +7 -90
  17. package/src/modules/skill/index.js +2 -1
  18. package/src/modules/skill/service.js +38 -1
  19. package/src/modules/system/service.js +2 -15
  20. package/src/modules/transfer/service.js +4 -138
  21. package/src/web/frontend/App.jsx +234 -200
  22. package/src/web/frontend/activeView.js +0 -56
  23. package/src/web/frontend/components/sourcePanel.jsx +48 -6
  24. package/src/web/frontend/main.jsx +0 -4
  25. package/src/web/frontend/styles.css +8 -361
  26. package/src/web/public/assets/{files-BoJqTGyZ.js → files-Cr-oMOeX.js} +1 -1
  27. package/src/web/public/assets/index-BiDzPPaq.css +1 -0
  28. package/src/web/public/assets/index-DPjpWSaw.js +41 -0
  29. package/src/web/public/assets/{view-B04bzsph.js → view-7t9TLUNM.js} +1 -1
  30. package/src/web/public/assets/{view-xwEy1P7C.js → view-C8iiWh9-.js} +1 -1
  31. package/src/web/public/assets/view-CpkUF3Xr.js +1 -0
  32. package/src/web/public/assets/{view-DNT9of0L.js → view-DYFqX9mq.js} +1 -1
  33. package/src/web/public/assets/view-qaSrOBcK.js +42 -0
  34. package/src/web/public/index.html +2 -2
  35. package/src/modules/entries/aiPending.js +0 -303
  36. package/src/modules/entries/blockPanel.jsx +0 -239
  37. package/src/modules/entries/boardView.jsx +0 -136
  38. package/src/modules/entries/entriesTable.jsx +0 -119
  39. package/src/shared/blocks.js +0 -400
  40. package/src/shared/navTree.js +0 -252
  41. package/src/shared/views.js +0 -221
  42. package/src/web/frontend/blocks/registry.jsx +0 -304
  43. package/src/web/frontend/components/aiBar.jsx +0 -192
  44. package/src/web/frontend/components/blockDnd.jsx +0 -69
  45. package/src/web/frontend/components/sidebar.jsx +0 -354
  46. package/src/web/frontend/components/viewChips.jsx +0 -43
  47. package/src/web/frontend/panels.jsx +0 -75
  48. package/src/web/frontend/workspace.js +0 -253
  49. package/src/web/public/assets/index-DDcA8xZc.js +0 -64
  50. package/src/web/public/assets/index-FDvbnX_f.css +0 -1
  51. package/src/web/public/assets/view-CvJr_elT.js +0 -42
  52. package/src/web/public/assets/view-tNIpJ3PQ.js +0 -1
package/CHANGELOG.md CHANGED
@@ -2,79 +2,45 @@
2
2
 
3
3
  本文件格式参考 [Keep a Changelog](https://keepachangelog.com/),中文正文。
4
4
 
5
+ ## 0.23.1 (2026-11-05)
6
+
7
+ ### Added
8
+
9
+ - **`skill install --all`:一次装齐 `assets/` 下全部 skill,且直接覆盖**。范围取目录
10
+ 扫描(事实源 `assets/<x>/SKILL.md`),不依赖分组表;覆盖隐式生效,不用再补 `--force`。
11
+ 与 `[name]` 互斥;`--mode symlink` / `--dry-run` 对整批同样适用。
12
+
5
13
  ## 0.23.0 (2026-10-31)
6
14
 
7
- **应用壳换成可停靠的工作区(Notion 式目录树 + FlexLayout);看板视图落地;条目有了块级 AI 的「提议 → 审 → 应用/拒绝」流;栏目结构在面板里可自己搭。**
15
+ **栏目结构在面板里可自己搭了:分组能增删改序,字段能改名 / 换组 / 换类型 / 换位置。**
8
16
 
9
- 壳是「面板的布局由你自己摆」:栏目只从目录树进,布局跨会话恢复。
10
- AI **不自带大模型**——提议由调用方(agent 或外部模型)交进来,人只做评审。
11
- **栏目结构**(分组 + 字段)过去只有命令行能改:面板连建一个分组都要回 CLI 或换模板——
12
- 于是「这个栏目长什么样」这件事面板说了不算。这次把四个分组操作与两个字段操作做成
13
- action(面板与 CLI 同一份),面板「编辑栏目」里直接可改。
17
+ 过去面板只能「加字段」,连建一个分组都要回 CLI 或换模板——于是「这个栏目长什么样」
18
+ 这件事实际上只有命令行能控制,面板就不是控制面板。这次把结构的四个分组操作与两个字段操作
19
+ 做成 action(面板与 CLI 同一份),面板「编辑栏目」里直接可改。
14
20
 
15
21
  ### Added
16
22
 
17
- - **Notion 式工作区壳**:
18
- - 顶栏只留全局动作(品牌 / 存储 / 打码 / +面板 / 撤销重做 / 设置 / 刷新);
19
- **栏目 tab 从顶栏移除**——栏目只从目录树进,两处入口必然分叉。
20
- - 目录树:栏目 + 分组 + 条目子节点;**拖拽重排与嵌套**(第一次拖动把「从旧导航配置
21
- 推断出的树」固化成显式 `settings.navTree`,之前始终只读推断、不写回);
22
- 隐藏的栏目收在树底(一键恢复,数据不动)。
23
- - 布局 = FlexLayout 模型:面板可拖到任意一边拆分、弹出成浮窗;布局存
24
- localStorage(`nx-sk-ui.workspace`),跨会话恢复;撤销/重做按「整段拖拽 = 一步」。
25
- - 深链升级为两段:`#/栏目` 开该栏目的数据库面板,`#/栏目/条目` 再定位到条目;
26
- 栏目 / 条目缺失时明确提示是哪一级没了。
27
- - 正文块支持拖拽排序(块头把手);数据库面板即工作区里的一块面板,可与其他面板并列。
28
- - **看板视图**:列 = select 字段的候选值;**拖卡 = 写操作**,等于 `entry update --set 字段=值`;
29
- 空值与候选外的值落「未分组」列,不静默丢,「未分组」不是落点。
30
- - **块级 AI(提议 → 审 → 应用/拒绝)**:
31
- - CLI:`entry ai list | suggest | apply | reject`(HTTP 同名路由)。
32
- `suggest` 收 `--from @proposal.json`(AI 给出的**最终正文**)与 `--prompt`(指令留痕),
33
- 只落一条 pending;`apply` 才写 store;`reject` 只删文件。
34
- - **pending 落 `~/nx-sk/pending/`(运行态)**:不进 `store.json`、不进导出包、不进迁移副本
35
- ——里面对 secret 块是明文,落进数据面就等于写进备份与导出面。删目录即清空。
36
- - diff 是**字符级**的:`same / del / ins` 三段,面板上红删绿增;容器块的改动由子块表达
37
- (不把父块一起报成改动);secret 块只报「变了」,明文不经面板接口。
38
- - **冲突检测**:pending 记每块应用前的 `baseHash`(服务端算),块被改过 → `CONFLICT`,
39
- 一个字都不写;`--force` 才覆盖。没有差异的提议返回 `noop`,不留空的待审。
40
- - secret 块的差异按**明文**比对(store 里是密文、提议里是明文),否则一个没动的密钥
41
- 会被报成「密文块已变更」。
23
+ - **分组骨架**:`section update --add-group / --rename-group / --move-group / --remove-group`。
24
+ 改名可以**连 id 一起换**(组里字段的 `group` 引用跟着改,不会整组从表单里消失)。
25
+ 三条规矩全是**拦下来**而不是悄悄做:id 重复报 `CONFLICT`(不覆盖同名分组)、
26
+ **非空分组不给删**(报 `BLOCKED` 并列出是哪几个字段)、内置「原始资料」分组不给删。
27
+ - **字段级改动**:`section update --set-field`(标签 / 类型 / 分组 / 提示 / 必填策略)
28
+ 与 `--move-field`(在自己分组内上下挪)。**key 不可改**——值按 key 存在条目里,
29
+ 改 key 等于让已有值读不出来;要换就新增字段再搬值。
30
+ - **面板**:「编辑栏目」弹窗里两块——「分组」(id / 名字就地改、↑↓ 换序、空组才能删)
31
+ 与「字段字典」(每行可改标签 / 类型 / 分组 / 提示 / 必填策略,组内 ↑↓,删除)。
32
+ 一次改动一次落盘(每次改动都要过后端校验,出错时知道是哪一步),改完立刻回读。
33
+ 清单类字段(`entrylist` / `table`)的**类型置灰**:列是字段自己的结构,
34
+ 就地换类型等于换一个字段——要建就在「新增字段」里连列一起建。
35
+ - 每次改动都把**新旧值**打进输出(`字段 phone:分组 contact → reach`),
36
+ 不再只说「已更新栏目 job:fields」——那种输出改错了没法回溯。
37
+
42
38
  - **表格 / 清单:行与列都能拖**(Notion 那种手感):拖**表头**搬列、拖行尾的 ⠿ 搬行,
43
39
  松手前一条**插入线**告诉你落在哪(只高亮整行说不清「会插到哪」)。排序算法单独一个
44
40
  `reorderAt`(插入位语义,有单测)——差一的表现是「拖到第二行结果跑到第三行」。
45
41
  整行不做 `draggable`:那会跟单元格里选文本打架,把手是唯一的拖拽入口。
46
42
  ↑↓ / ←→ 保留但**悬停才浮出**,行内操作键统一收成 16px 小图标键(密集表里的按钮
47
43
  不该抢内容的注意力);entrylist 那颗「正常大小的删除按钮」也换成了小图标键。
48
- - **降级运行的主动提示**:store 由更新版本写入时,`bootstrap` 与 `health`(CLI 输出与 JSON)
49
- 会明确说明「磁盘版本 > 当前版本,写入会被拒」——不等用户改一次被 BLOCKED 才知道。
50
- - **栏目结构在面板里可自己搭**:分组能增删改序,字段能改名 / 换组 / 换类型 / 换位置。
51
- - **分组骨架**:`section update --add-group / --rename-group / --move-group / --remove-group`。
52
- 改名可以**连 id 一起换**(组里字段的 `group` 引用跟着改,不会整组从表单里消失)。
53
- 三条规矩全是**拦下来**而不是悄悄做:id 重复报 `CONFLICT`(不覆盖同名分组)、
54
- **非空分组不给删**(报 `BLOCKED` 并列出是哪几个字段)、内置「原始资料」分组不给删。
55
- - **字段级改动**:`section update --set-field`(标签 / 类型 / 分组 / 提示 / 必填策略)
56
- 与 `--move-field`(在自己分组内上下挪)。**key 不可改**——值按 key 存在条目里,
57
- 改 key 等于让已有值读不出来;要换就新增字段再搬值。
58
- - **面板**:「编辑栏目」弹窗里两块——「分组」(id / 名字就地改、↑↓ 换序、空组才能删)
59
- 与「字段字典」(每行可改标签 / 类型 / 分组 / 提示 / 必填策略,组内 ↑↓,删除)。
60
- 一次改动一次落盘(每次改动都要过后端校验,出错时知道是哪一步),改完立刻回读。
61
- 清单类字段(`entrylist` / `table`)的**类型置灰**:列是字段自己的结构,
62
- 就地换类型等于换一个字段——要建就在「新增字段」里连列一起建。
63
- - 每次改动都把**新旧值**打进输出(`字段 phone:分组 contact → reach`),
64
- 不再只说「已更新栏目 job:fields」——那种输出改错了没法回溯。
65
-
66
- ### Fixed
67
-
68
- - **导入会把打码的密钥块加密存盘**(与 0.19 字段路径同类缺陷):导入包里的 `******`
69
- 占位会被当成真密钥加密落盘。现在值清空待重填,并在预览里计数与警告;
70
- 导出预览的掩码统计同步扩到块。
71
- - **表格/看板视图把详情列挤成一条竖缝**(S2 起的老缺陷,列多的栏目才暴露):
72
- 表格列的 flex 基准用的是内容宽度(75 列 ≈ 7300px),行溢出时按基准比例收缩,
73
- 详情列被抽走全部溢出量。现在表格列 basis 为 0(「拿剩余空间」),
74
- 横向滚动交给表格与看板自己的 overflow。
75
- - **布局里的空面板组自愈**:拖动/关闭面板可能留下空 tabset,FlexLayout 会把它原样
76
- 存盘、恢复时渲染成一块白板。现在存盘与恢复两端都剪掉空面板组——
77
- 已经存坏的布局**刷新即自愈**,不用点「重置布局」。
78
44
 
79
45
  ## 0.22.0 (2026-10-31)
80
46
 
@@ -126,31 +92,6 @@ action(面板与 CLI 同一份),面板「编辑栏目」里直接可改。
126
92
  表头写「已删字段」、单元格「—」(不炸、也不静默消失)。
127
93
  - 面板:栏目页顶部视图 chips + 表格视图(横向投影,密文列在表格里一律打码)。
128
94
  看板**渲染**随下一版(配置与 action 已可用)。
129
- - **Notion 式工作区壳**(S3):
130
- - 顶栏只留全局动作(品牌 / 存储 / 打码 / +面板 / 撤销重做 / 设置 / 刷新);
131
- **栏目 tab 从顶栏移除**——栏目只从目录树进,两处入口必然分叉。
132
- - 目录树:栏目 + 分组 + 条目子节点;**拖拽重排与嵌套**(第一次拖动把「从旧导航配置
133
- 推断出的树」固化成显式 `settings.navTree`,之前始终只读推断、不写回);
134
- 隐藏的栏目收在树底(一键恢复,数据不动)。
135
- - 布局 = FlexLayout 模型:面板可拖到任意一边拆分、弹出成浮窗;布局存
136
- localStorage(`nx-sk-ui.workspace`),跨会话恢复;撤销/重做按「整段拖拽 = 一步」。
137
- - 深链升级为两段:`#/栏目` 开该栏目的数据库面板,`#/栏目/条目` 再定位到条目;
138
- 栏目 / 条目缺失时明确提示是哪一级没了。
139
- - 正文块支持拖拽排序(块头把手);数据库面板即工作区里的一块面板,可与其他面板并列。
140
- - **块级 AI(提议 → 审 → 应用/拒绝)**(S4):
141
- - CLI:`entry ai list | suggest | apply | reject`(HTTP 同名路由)。
142
- `suggest` 收 `--from @proposal.json`(AI 给出的**最终正文**)与 `--prompt`(指令留痕),
143
- 只落一条 pending;`apply` 才写 store;`reject` 只删文件。
144
- - **nx-sk 不自带大模型**:提议由调用方(agent 或外部模型)产出后交进来。这样
145
- 「agent 走 CLI」与「人在面板上审同一条 pending」是同一套语义,不需要第二个 AI 入口。
146
- - **pending 落 `~/nx-sk/pending/`(运行态)**:不进 `store.json`、不进导出包、不进迁移副本
147
- ——里面对 secret 块是明文,落进数据面就等于写进备份与导出面。删目录即清空。
148
- - diff 是**字符级**的:`same / del / ins` 三段,面板上红删绿增;容器块的改动由子块表达
149
- (不把父块一起报成改动);secret 块只报「变了」,明文不经面板接口。
150
- - **冲突检测**:pending 记每块应用前的 `baseHash`(服务端算),块被改过 → `CONFLICT`,
151
- 一个字都不写;`--force` 才覆盖。没有差异的提议返回 `noop`,不留空的待审。
152
- - secret 块的差异按**明文**比对(store 里是密文、提议里是明文),否则一个没动的密钥
153
- 会被报成「密文块已变更」。
154
95
 
155
96
  ### Changed
156
97
 
package/README.md CHANGED
@@ -172,20 +172,6 @@ CLI 与 API 同源,skill 驱动 agent**。落到具体功能上:
172
172
  | `nx-sk section update <id> --set-field / --move-field` | 改**字段**:改标签 / 类型 / 分组 / 提示 / 必填策略,组内上下挪(key 不可改)。面板「编辑栏目」同源 |
173
173
  | `nx-sk entry list \| get \| add \| update \| remove` | 条目 CRUD(PATCH 语义) |
174
174
  | `nx-sk entry fields [--section job]` | 字段字典 —— 填之前先看这个,别猜字段名 |
175
- | `nx-sk entry block add <ref> --type <段落\\|标题\\|图文\\|表格\\|清单\\|附件\\|密钥> [--data @x.json] [--after <块id>]` | 追加**块正文**(`entry.body`,可选键);详细页下半部分同源 |
176
- | `nx-sk entry block update <ref> <块id> [--data @x.json]` | 改块(PATCH 语义);密钥块在打码态下写回会被拒(星号不能存成密钥) |
177
- | `nx-sk entry block move <ref> <块id> --to <index>` | 移动块(拖拽落点 = 一次 move) |
178
- | `nx-sk entry block remove \\| remove-many <ref> <块id…>` | 删块;批量时找不到的跳过并报告 |
179
- | `nx-sk entry body <ref> [--mask]` | 读块正文(人读渲染;`--mask` 只作用于密钥块) |
180
- | `nx-sk entry ai suggest <ref> --prompt "改短一点" --from @proposal.json [--block <块id>]` | 生成一条**AI 提议**:只落 pending,不写 store。`--from` 是 AI 给出的最终正文(nx-sk 不自带大模型) |
181
- | `nx-sk entry ai list <ref>` | 看待审提议(新的在前)。pending 是运行态,不进 store、不进导出包 |
182
- | `nx-sk entry ai apply <pendingId> [--block <块id>] [--force]` | 应用提议(**唯一会写 store 的一步**);块被改过 → `CONFLICT`,`--force` 才覆盖 |
183
- | `nx-sk entry ai reject <pendingId>` | 拒绝提议:删掉 pending 文件,数据一个字不动 |
184
- | `nx-sk section view list <id>` | 列视图配置;**没配过时给缺省推断**(表格/精简/看板,标 provisional,不落盘) |
185
- | `nx-sk section view add <id> --type table\\|board\\|kv [--title 名] [--columns 姓名,状态] [--groupby 字段] [--sort 字段:desc]` | 新增视图(第一次 add 把当前生效的推断视图一起固化,chips 不会凭空消失) |
186
- | `nx-sk section view update <id> <viewId> [--title] [--columns] [--sort] [--set col.字段.width=120]` | 改视图;列宽拖拽结束 = 一次 update |
187
- | `nx-sk section view remove <id> <viewId>` | 删视图;删光回到缺省推断态(`defaultView` 一并清掉) |
188
- | `nx-sk section view set-default <id> <viewId>` | 设缺省视图(写 `section.defaultView` —— 是数据,不是面板设置) |
189
175
  | `nx-sk file list \| get \| add \| update \| remove` | 附件 CRUD(原件在 `~/nx-sk/files/`,同内容去重) |
190
176
  | `nx-sk file add --path <文件> [--copy] [--category C]` | 导入附件:**默认移动原件**,`--copy` 仅复制 |
191
177
  | `nx-sk file list [--section <栏目\|none>] [--q 关键字]` | 列附件:按「哪些栏目在引用」筛选(`none` = 未引用);返回栏目汇总与存储目录 |
@@ -105,64 +105,7 @@ nx-sk entry get 张三 --json
105
105
  - 身份证号、手机号、紧急联系人电话这类字段,写之前跟用户确认一次来源。
106
106
  - 每次批量写之前先 `--dry-run`。
107
107
 
108
- ## 五、块正文与 AI 提议(entry.body / entry ai)
109
-
110
- 条目除了结构化字段还有**块正文**(`entry.body`,可选键):段落 / 标题 / 图文 / 表格 / 清单 /
111
- 附件 / 密钥七种叶块,加内嵌栏目(`section`)与分栏(`column`)两种容器块。
112
-
113
- ```bash
114
- # 建块 / 看块
115
- nx-sk entry block add <ref> --type paragraph --data '{"text":"第一段"}'
116
- nx-sk entry block list <ref> # secret 块默认原文,--mask 打码
117
- nx-sk entry block update <ref> <块id> --data @块.json
118
- nx-sk entry block move <ref> <块id> --to 0
119
-
120
- # AI 提议:**AI 永不直接改数据**
121
- # ① 你(agent / 外部模型)产出提议正文 = 该条目的最终块数组
122
- # ② suggest 只落一条 pending(~/nx-sk/pending/,运行态,不进 store / 导出包)
123
- # ③ 人或你在面板/CLI 上 apply(才写 store)或 reject(只删文件)
124
- nx-sk entry ai suggest <ref> --prompt "把自我介绍改短" --from @proposal.json
125
- nx-sk entry ai list <ref>
126
- nx-sk entry ai apply <pendingId> # 块被改过 → CONFLICT,--force 才覆盖
127
- nx-sk entry ai reject <pendingId>
128
- ```
129
-
130
- **纪律**:
131
-
132
- - proposal.json 是 `{"blocks":[…]}` 或直接块数组;给**最终正文**,不是补丁。
133
- - 建议先 `nx-sk entry block list <ref> --json` 拿现正文再改,块 id 保持不变
134
- (同 id 不同内容 = 一次「修改」,出现新 id = 「新增」,缺了的 id = 「删除」)。
135
- - 密钥块在提议里是明文——pending 落在本机运行态目录(0600),这是有意设计;
136
- 但**不要**把 pending 文件内容贴进对话或日志。
137
- - 应用前想留底就先 `transfer export`(apply 写 store,与手改同一条边界)。
138
-
139
- ## 六、数据库视图(section.views / section view)
140
-
141
- 视图是**同一批条目的多种投影**:表格(列 = 字段投影)、精简(两列)、看板(按 select 字段分列)、
142
- KV 栏目的键值面。配置存在 `section.views` / `section.defaultView`(可选键)。
143
-
144
- ```bash
145
- nx-sk section view list <id> # 没配过时给缺省推断(provisional: true,不落盘)
146
- nx-sk section view add <id> --type board --title 按状态 --groupby status
147
- nx-sk section view update <id> <viewId> --set col.状态.width=180 --sort 状态:desc
148
- nx-sk section view set-default <id> <viewId>
149
- nx-sk section view remove <id> <viewId>
150
- ```
151
-
152
- **语义**:
153
-
154
- - **缺省视图是推断的、不落盘**:非 KV 栏目给「表格 / 精简 / 看板」,KV 栏目给「KV」。
155
- 第一次改视图才固化(把当时生效的那套推断视图一起写进去——只写用户新建的那条,
156
- 其余 chips 会凭空消失)。`views: []` 与「没配过」行为一致,都走推断。
157
- - 投影只存 `key / width / hidden`;标签与类型回查字段字典。引用的字段被删 → 列还在,
158
- 表头写「已删字段」、单元格「—」(不炸、也不静默消失)。
159
- - **看板的拖卡 = 写操作**:等于 `entry update --set <groupby字段>=<列值>`;
160
- 空值与候选外的值落「未分组」列——展示可以,**不是拖拽落点**(拖进去等于写一个不存在的值)。
161
- - **没有筛选 DSL**(`--filter` 明确不做):存一个不生效的配置比没有更糟。
162
- - 视图随导出包往返;导入只在「新建栏目」或「目标没配过视图」时应用——
163
- 目标已有视图说明用户自己配过,不静默覆盖(预览里给警告)。
164
-
165
- ## 七、扩字段字典(不写代码)
108
+ ## 五、扩字段字典(不写代码)
166
109
 
167
110
  **最省事的做法:直接写。** 字典里没有的键会自动登记(键名就是它本身),类型按值推断
168
111
  (`是/否` → bool,数组 → tags,其余 → text)。
@@ -325,24 +268,15 @@ nx-sk entry get 张三
325
268
  > ② 两者都**不要**出现在「还缺什么」的清单里;③ 用户说「这个不用填」时,
326
269
  > 用 `--fill <字段>=avoid` 记下来,而不是靠下一轮上下文记住。
327
270
 
328
- ## 八、Web 面板
329
-
330
- `nx-sk serve` 打开的是**可停靠的工作区**(Notion 式壳),不是一栏一页:
331
-
332
- - **目录树**(左侧面板):栏目 + 分组 + 条目子节点。拖拽重排与嵌套——第一次拖动会把
333
- 「从旧导航配置推断出的树」固化成显式 `settings.navTree`(`settings get` 里
334
- `navTreeSource: explicit`,之前始终 `inferred` 只读不写回);隐藏的栏目收在树底,一键恢复。
335
- - **布局 = FlexLayout 模型**:数据库 / 资料 / 文件 / 导出 / 设置都是面板,可拖到任意一边拆分、
336
- 弹出成浮窗;布局存 localStorage(`nx-sk-ui.workspace`),跨会话恢复,顶栏可撤销/重做。
337
- - **两段深链**:`#/栏目` 开该栏目的数据库面板,`#/栏目/条目` 再定位到条目;
338
- 栏目 / 条目缺失时明确提示是哪一级没了。
339
- - 栏目页(数据库面板)顶部是**视图 chips**(表格 / 精简 / 看板 / KV,见 §六);
340
- 左侧条目列表带完整度,右侧按分组渲染的字段表单。
341
- - 密文字段默认显示原文;顶栏「打码」等价于 `--mask`(选择持久化在 localStorage);
342
- - **栏目结构自己能搭**:栏目面板里点某个栏目的「编辑」,弹窗里分组可新增 / 改名(含换 id)/
343
- 上下挪 / 删(空的才让删),字段可改标签 / 类型 / 分组 / 提示 / 必填策略、组内上下挪、删除
344
- —— 一次改动一次落盘,与 CLI 的 `--add-group` 等是同一批 action(见 §七「栏目结构」)。
271
+ ## 六、Web 面板
272
+
273
+ `nx-sk serve` → 每个栏目一个 tab,共用同一个条目视图:
274
+
275
+ - 左侧条目列表(带完整度),右侧按分组渲染的字段表单;
276
+ - **「栏目」页的「编辑」弹窗能改整个结构**:分组可新增 / 改名(含换 id)/ 上下挪 / 删(空的才让删),
277
+ 字段可改标签、类型、分组、提示、必填策略、组内上下挪、删除 —— 一次改动一次落盘;
278
+ - 密文字段默认显示原文;「打码显示(投屏/截图时用)」勾选等价于 `--mask`(选择持久化在 localStorage);
345
279
  - 保存是 **PATCH**:只有被你改动过的字段才会发出去(面板会显示「N 处改动」);
346
- - 详细页下半部分是**块面板**(见 §五),AI 提议是评审条:渲染 diff + 应用 / 拒绝。
280
+ - 每个页面底部会派生显示该模块的 CLI 等价命令(可点击复制)。
347
281
 
348
282
  面板上没有 CLI 做不到的事——两边共享同一套 action 声明。
@@ -74,14 +74,9 @@ bundle.json 包清单:包含哪些栏目、整体口令说明
74
74
  sections/<id>.zip 每个栏目一个上面的单栏目包(逐栏目复用同一套逻辑)
75
75
  ```
76
76
 
77
- - 只打包**条目实际引用**的附件:`file` 字段的 id + `md` 正文里的 `![…](f_x)` + `file` **块**里的 id。
77
+ - 只打包**条目实际引用**的附件:`file` 字段的 id + `md` 正文里的 `![…](f_x)`。
78
78
  - 导入时附件按 **sha256 去重**:本机已有同一份文件就复用,不会存两份。
79
- - 条目里的旧 file id 会**自动重映射**到新 id(`file` 字段数组、`file` 块与 md 图片引用都改)。
80
- - 包版本(`manifest.formatVersion`):**2** 起,包里多 `entries[].body`(块正文)与
81
- `section.views`(视图配置)。老版本读到 2 号包会明确报「版本过新」,
82
- 不会把这两个键静默丢掉;manifest 里**没有这个键**(0.20 及更早的包)视为 1 号包——
83
- 无正文/无视图,照常导入。
84
- - 导入时视图配置只在「新建栏目」或「目标没配过视图」时应用——目标已有自己的视图就不覆盖(预览里会提示)。
79
+ - 条目里的旧 file id 会**自动重映射**到新 id(`file` 字段数组与 md 图片引用都改)。
85
80
  - `import-new` 会看包结构自动判断是单栏目包还是整库包,不用手动挑命令。
86
81
 
87
82
  ## 口令加密(transfer 层)
@@ -107,9 +102,8 @@ sections/<id>.zip 每个栏目一个上面的单栏目包(逐栏目复
107
102
  包里**恒为明文**,保护只靠口令 —— 没有「打码 / 明文」二选(0.11.0 之前的 `--no-secrets` 已移除)。
108
103
  要发给别人的包就设口令;不设口令的包自己收好,别当分享件用。
109
104
 
110
- 读取侧仍兼容旧打码包:遇到 `******` 会跳过该字段并计数,不会把打码串当新密码存进去;
111
- **密钥块**同一条口径——打码的密钥块保留、值清空待重填(预览会统计「密钥块打码 N 个」)。
112
- 导入后敏感字段与密钥块用**本机 vaultKey** 重新加密落盘,与手填的没有区别。
105
+ 读取侧仍兼容旧打码包:遇到 `******` 会跳过该字段并计数,不会把打码串当新密码存进去。
106
+ 导入后敏感字段用**本机 vaultKey** 重新加密落盘,与手填的字段没有区别。
113
107
 
114
108
  ## 限制与坑
115
109
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nx-sk",
3
- "version": "0.23.0",
3
+ "version": "0.23.1",
4
4
  "description": "nx-sk · 本机个人资源管理器(server-cli-web 骨架):栏目化存储个人信息与大模型密钥,CLI 与 Web 同源,敏感字段密文落盘。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,23 +37,12 @@
37
37
  "dev:serve": "node bin/nx-sk.mjs serve --no-open",
38
38
  "build": "vite build && node scripts/link-local.mjs --auto",
39
39
  "start": "pnpm run build && node bin/nx-sk.mjs serve",
40
- "proto:dev": "vite --config plan/block-refactor/prototype/vite.config.js",
41
- "proto:build": "vite build --config plan/block-refactor/prototype/vite.config.js",
42
- "loop:on": "node scripts/rp-loop.mjs on",
43
- "loop:off": "node scripts/rp-loop.mjs off",
44
- "loop:status": "node scripts/rp-loop.mjs status",
45
- "loop:log": "node scripts/rp-loop.mjs log",
46
- "loop:cancel": "node scripts/rp-loop.mjs cancel",
47
40
  "lint": "eslint .",
48
41
  "test:unit": "node --test",
49
42
  "test:smoke": "node tests/smoke.mjs",
50
43
  "test": "pnpm run lint && pnpm run build && pnpm run test:smoke && pnpm run test:unit"
51
44
  },
52
45
  "dependencies": {
53
- "@dnd-kit/core": "^6.3.1",
54
- "@dnd-kit/sortable": "^10.0.0",
55
- "@dnd-kit/utilities": "^3.2.2",
56
- "flexlayout-react": "0.11.1",
57
46
  "react": "^18.3.1",
58
47
  "react-dom": "^18.3.1",
59
48
  "react-markdown": "^10.1.0",
@@ -136,24 +136,6 @@ export function normalizeFileRecord(raw) {
136
136
  * 0.8.0 之前 `--set` 不拆逗号),所以字符串统一按分隔符拆开。
137
137
  * 去空、去重、保序:同一个附件在一个字段里出现两次没有意义。
138
138
  */
139
- /** 块正文里的附件引用:file 块的 id 列表 + md 块正文里的图片 id。 */
140
- function blockFileRefs(entry) {
141
- const out = [];
142
- const walk = (blocks) => {
143
- for (const b of blocks || []) {
144
- if (b.type === 'file' && Array.isArray(b.files)) {
145
- for (const id of fileIdsOf(b.files)) out.push(id);
146
- }
147
- if (b.type === 'md' && typeof b.text === 'string') {
148
- for (const id of mdImageIds(b.text)) out.push(id);
149
- }
150
- if (b.children) walk(b.children);
151
- }
152
- };
153
- walk(entry.body);
154
- return out;
155
- }
156
-
157
139
  export function fileIdsOf(value) {
158
140
  const raw = value === null || value === undefined
159
141
  ? []
@@ -191,11 +173,7 @@ export function findFileRecord(store, ref) {
191
173
  * 图片是**内嵌在 markdown 里**的,和 file 字段一样是正式引用。
192
174
  * 3. `source` 字段 —— 原始资料袋里 kind='file' 的 item(袋是溯源的锚点:
193
175
  * 字段值是从这份原件整理出来的,原件被删,溯源就成了空话)。
194
- * 4. **块正文**(v4)—— `file` 块里挂的附件、`md` 块正文里贴的图。
195
- * 块是条目正文的一部分,正式程度与字段相同;漏扫它 = 「只在块里挂过」的附件
196
- * 会被 cleanup 判成未引用然后删掉(真实事故的块版)。
197
- * 块引用出一条(fieldKey 记块 id);其余同一条(条目, 字段)只出一条。
198
- * 附件中心据此显示「被哪些栏目 / 条目 / 字段 / 块用到」。
176
+ * 同一个(条目, 字段)只出一条。附件中心据此显示「被哪些栏目 / 条目 / 字段用到」。
199
177
  */
200
178
  export function fileReferences(store, rec) {
201
179
  if (!rec) return [];
@@ -243,17 +221,6 @@ export function fileReferences(store, rec) {
243
221
  .map((x) => x.fileId);
244
222
  if (ids.length && hits(ids)) push(f);
245
223
  }
246
- // ④ 块正文(v4):file 块 + md 块。命中就出一条,fieldKey 记块 id、fieldLabel 记块类型。
247
- const blockHits = blockFileRefs(e);
248
- if (blockHits.length && hits(blockHits)) {
249
- const which = (e.body || []).some((b) => b.type === 'file'
250
- && fileIdsOf(b.files).some((ref) => hits([ref]))) ? 'file 块' : 'md 块';
251
- out.push({
252
- sectionId: sec.id, sectionTitle: sec.title,
253
- entryId: e.id, entryTitle: e.title,
254
- fieldKey: 'body', fieldLabel: which,
255
- });
256
- }
257
224
  }
258
225
  }
259
226
  return out;
package/src/core/paths.js CHANGED
@@ -61,17 +61,6 @@ export function migrationDir() {
61
61
  return join(appHome(), 'migrate');
62
62
  }
63
63
 
64
- /**
65
- * 块级 AI 的 pending 目录:`~/nx-sk/pending/`(一个 pending 一个文件)。
66
- *
67
- * **运行态,不是数据**:不进 store.json、不进导出包、不进迁移副本。
68
- * 理由见设计 §14.4——pending 里可能有明文密钥(AI 提议的 secret 块),
69
- * 落进 store 就等于把它写进了备份与导出面。删掉这个目录即清空全部待审提议。
70
- */
71
- export function pendingDir() {
72
- return join(appHome(), 'pending');
73
- }
74
-
75
64
  export function vaultKeyFile() {
76
65
  return join(appHome(), '.vaultkey');
77
66
  }
@@ -108,15 +108,6 @@ export function displaySensitive(raw, { mask = false, decrypt } = {}) {
108
108
  }
109
109
  }
110
110
 
111
- /** 块正文(v4)的展示形态:secret 块的值走与敏感字段**同一条** displaySensitive 边界。 */
112
- export function displayBlocks(blocks, { mask = false, decrypt } = {}) {
113
- return (blocks || []).map((b) => {
114
- if (b.type === 'secret') return { ...b, value: displaySensitive(b.value, { mask, decrypt }) };
115
- if (Array.isArray(b.children)) return { ...b, children: displayBlocks(b.children, { mask, decrypt }) };
116
- return b;
117
- });
118
- }
119
-
120
111
  /**
121
112
  * 条目的**唯一序列化出口**(面板、`entry list`、`section dump` 共用一份)。
122
113
  * 两个出口各写一遍序列化,迟早会分叉成「CLI 看得到、面板看不到」。
@@ -156,10 +147,6 @@ export function serializeEntry(section, entry, { mask = false, decrypt } = {}) {
156
147
  tags: entry.tags || [],
157
148
  createdAt: entry.createdAt,
158
149
  updatedAt: entry.updatedAt,
159
- // 块正文(v4):可选键——没有块就不带这个键(别让老条目凭空多出一个空数组)
160
- ...(Array.isArray(entry.body) && entry.body.length
161
- ? { body: displayBlocks(entry.body, { mask, decrypt }) }
162
- : {}),
163
150
  completeness: {
164
151
  filled: c.filled,
165
152
  total: c.total,
package/src/core/store.js CHANGED
@@ -6,22 +6,12 @@ import { displayPath, migrationDir, storeFile } from './paths.js';
6
6
  import { ensureDir, pathExists, writeAtomic } from './fsx.js';
7
7
  import { ensureSourceBuiltin, instantiateTemplate, seedEntries, seedSections, syncSectionGroups } from './fields.js';
8
8
  import { normalizeFileRecord } from './attachments.js';
9
- // 块(entry.body)的归一在 shared/blocks.js —— 与 shared/table.js 同一条理由:
10
- // 面板不许 import core,而「块怎么算合法」只允许有一份实现。core→shared 允许(见 eslint 注释)。
11
- import { normalizeBlocks } from '../shared/blocks.js';
12
- // 视图配置(section.views)的归一同样在 shared —— 理由同上:面板不许 import core,
13
- // 而「什么算合法视图」只能有一份实现。
14
- import { normalizeViews } from '../shared/views.js';
15
- import { blocked } from './errors.js';
16
9
  import { nowIso, stamp } from './ids.js';
17
10
 
18
11
  /** v2:密钥栏目从 8 字段模板改成单字段 KV(名称 → 值)。
19
12
  * v3:栏目有了 `kv` 标记——KV 栏目是一张表(面板渲染成表格,key 命令默认作用在它上面),
20
- * 并去掉 `settings.kvSection` 这个冗余概念。
21
- * v4:条目有了 `body?: Block[]`(块正文,递归)。**只加可选键**,没有迁移函数——
22
- * 老条目没有 body 就是没有;normalize 会把非法块丢掉,存量数据读一次即安全。
23
- * 反方向的降级没有保护:老代码读 v4 会把 body 丢掉(白名单重建),所以 v4 必须**拒绝老代码写**。 */
24
- export const STORE_VERSION = 4;
13
+ * 并去掉 `settings.kvSection` 这个冗余概念。 */
14
+ export const STORE_VERSION = 3;
25
15
 
26
16
  export function defaultSettings() {
27
17
  return {
@@ -72,20 +62,12 @@ function normalizeSection(raw) {
72
62
  const ensured = ensureSourceBuiltin(out.groups, out.fields, { kv: out.kv });
73
63
  out.groups = ensured.groups;
74
64
  out.fields = ensured.fields;
75
- // 数据库视图(v4 同批):同样是**可选键**。缺席 = 还没配过,读路径推断一套缺省视图
76
- // (不落盘,见 shared/views.js 的文件头)。空数组等价于缺席——存一个 `views: []`
77
- // 只会让「还没配过」和「配过又删光」两件事看起来不一样,实际行为必须一样。
78
- const views = normalizeViews(raw.views);
79
- if (views.length) out.views = views;
80
- // defaultView 必须指向一个**真的存在**的视图;指向空气就丢掉(读的时候退回第一个),
81
- // 否则面板切到缺省视图时会拿到一个不存在的 id。
82
- if (raw.defaultView && views.some((v) => v.id === raw.defaultView)) out.defaultView = String(raw.defaultView);
83
65
  return out;
84
66
  }
85
67
 
86
68
  function normalizeEntry(raw) {
87
69
  if (!raw || typeof raw !== 'object' || !raw.id) return null;
88
- const out = {
70
+ return {
89
71
  id: String(raw.id),
90
72
  section: String(raw.section || ''),
91
73
  title: String(raw.title || ''),
@@ -94,10 +76,6 @@ function normalizeEntry(raw) {
94
76
  createdAt: raw.createdAt || null,
95
77
  updatedAt: raw.updatedAt || null,
96
78
  };
97
- // 块正文(v4):**可选键**,缺席就是没有(不补一个空数组,否则老条目会被无意义地改写)。
98
- // 非法块在这里丢掉 —— normalize 是每条读写路径的必经点,坏数据读一次即安全。
99
- if (Array.isArray(raw.body)) out.body = normalizeBlocks(raw.body);
100
- return out;
101
79
  }
102
80
 
103
81
  // v1 的密钥模板字段。迁移时**不动 values 里这些键**——只把 keyValue 搬到 value,
@@ -218,14 +196,6 @@ let cachePath = null;
218
196
  let cacheMtime = -1;
219
197
  let cacheCorruptNote = null;
220
198
  let cacheMigrationNote = null;
221
- // 磁盘上那份 store 的版本号 —— 它可以**大于** STORE_VERSION(用户降级跑老代码)。
222
- // normalize 会把版本抹成当前值,所以磁盘版本必须在这里单独记,否则降级守卫就无从判断。
223
- let cacheDiskVersion = STORE_VERSION;
224
-
225
- /** 最近一次读盘时的磁盘版本(bootstrap / health 展示「store 由更新版本写入」用)。 */
226
- export function diskVersion() {
227
- return cacheDiskVersion;
228
- }
229
199
 
230
200
  export function storePathInUse() {
231
201
  return storeFile();
@@ -241,12 +211,6 @@ export function migrationNote() {
241
211
  return cacheMigrationNote;
242
212
  }
243
213
 
244
- /** 降级运行时的说明(磁盘版本 > 当前版本,写入会被拒);正常时是 null。bootstrap / health 会展示。 */
245
- export function downgradeNote() {
246
- if (cacheDiskVersion <= STORE_VERSION) return null;
247
- return { disk: cacheDiskVersion, current: STORE_VERSION };
248
- }
249
-
250
214
  export async function loadStore(explicitPath) {
251
215
  const p = explicitPath || storeFile();
252
216
  try {
@@ -265,7 +229,6 @@ export async function loadStore(explicitPath) {
265
229
  cacheCorruptNote = { from: p, to: corrupt, error: String(e && e.message) };
266
230
  }
267
231
  const oldVersion = Number((parsed && parsed.version) || 1);
268
- cacheDiskVersion = oldVersion;
269
232
  const next = normalize(parsed);
270
233
 
271
234
  if (!corrupt && oldVersion < STORE_VERSION && next._migration) {
@@ -282,7 +245,6 @@ export async function loadStore(explicitPath) {
282
245
  };
283
246
  delete next._migration;
284
247
  // 就地落盘:version 写进去之后就不会再迁移(幂等)
285
- cacheDiskVersion = STORE_VERSION;
286
248
  await saveStore(next, p);
287
249
  return next;
288
250
  }
@@ -297,22 +259,12 @@ export async function loadStore(explicitPath) {
297
259
  cache = emptyStore(true);
298
260
  cachePath = p;
299
261
  cacheMtime = -1;
300
- cacheDiskVersion = STORE_VERSION;
301
262
  return cache;
302
263
  }
303
264
  }
304
265
 
305
266
  export async function saveStore(next, explicitPath) {
306
267
  const p = explicitPath || storeFile();
307
- // **降级守卫**(设计 §十一 11.4):磁盘上的 store 由更新版本写入时,拒绝一切写入。
308
- // 否则老代码的白名单 normalize 会把新版本才有的键(比如 v4 的 body)**静默丢掉**——
309
- // 用户降级跑一次、保存一次,块正文就永久没了,而且没有任何报错。
310
- if (cacheDiskVersion > STORE_VERSION) {
311
- throw blocked(
312
- `这份 store 由更新版本的 nx-sk 写入(磁盘版本 v${cacheDiskVersion} > 当前 v${STORE_VERSION})。`
313
- + '为避免丢掉新版本才有的数据(块正文等),已拒绝写入。请升级 nx-sk 后再修改。',
314
- );
315
- }
316
268
  const data = normalize(next);
317
269
  delete data._migration;
318
270
  await ensureDir(join(p, '..'));
package/src/core/vault.js CHANGED
@@ -98,25 +98,12 @@ export async function currentKeyId() {
98
98
  * 是安全决策——把它收成**一个**函数,就不会出现「某个出口忘了打码」。
99
99
  * 只有当栏目里真有密文字段、且条目里真有密文时才去解析密钥(避免无谓地生成密钥文件)。
100
100
  */
101
- /** 块正文里有没有密文(v4:secret 块的值也是密文落盘)。 */
102
- function bodyHasSecretBlob(blocks) {
103
- for (const b of blocks || []) {
104
- if (b.type === 'secret' && isCipherBlob(b.value)) return true;
105
- if (b.children && bodyHasSecretBlob(b.children)) return true;
106
- }
107
- return false;
108
- }
109
-
110
101
  export async function sensitiveViewer(section, entries = []) {
111
102
  const sensitive = (section?.fields || []).filter((f) => f.sensitive || f.type === 'secret');
112
103
  // 清单类(结构清单 entrylist / 表格 table)的敏感**子值**也是密文落盘:
113
104
  // 不探行内的话,清单里真有密文时 viewer 不会被创建,读出来全变成「需要密钥才能解密」。
114
105
  const lists = (section?.fields || []).filter((f) => f.type === 'entrylist' || f.type === 'table');
115
- // 块正文里的 secret 块也要算进「有没有密文」——
116
- // 栏目里一个密文字段都没有、只有块里存了密钥时,早退会让 viewer 不被创建,
117
- // 读出来全是「需要密钥才能解密」。
118
- const bodyHasBlob = entries.some((e) => bodyHasSecretBlob(e?.body));
119
- if (!sensitive.length && !lists.length && !bodyHasBlob) return undefined;
106
+ if (!sensitive.length && !lists.length) return undefined;
120
107
  const keys = sensitive.map((f) => f.key);
121
108
  // 两种清单的「列」字段名不同(fields / columns),但密文边界的判据是同一个
122
109
  const subKeysOf = (f) => (f.type === 'table' ? (f.columns || []) : (f.fields || []))
@@ -131,10 +118,7 @@ export async function sensitiveViewer(section, entries = []) {
131
118
  return Object.values(row).some((v) => isCipherBlob(v));
132
119
  };
133
120
  const hasBlob = entries.some((e) => keys.some((k) => isCipherBlob(e?.values?.[k]))
134
- || lists.some((f) => rowsOf(e?.values?.[f.key]).some((row) => rowHasBlob(row, f)))
135
- // 块正文里的 secret 块同样是密文落盘(v4):不探它的话,
136
- // 「整条条目只有块里有密文」时 viewer 不会被创建,读出来全是「需要密钥才能解密」。
137
- || entries.some((e) => bodyHasSecretBlob(e?.body)));
121
+ || lists.some((f) => rowsOf(e?.values?.[f.key]).some((row) => rowHasBlob(row, f))));
138
122
  if (!hasBlob) return undefined;
139
123
  const { key } = await resolveVaultKey();
140
124
  return (blob) => decryptValue(key, blob);