nx-kv 0.8.5 → 0.9.0
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.
- package/CHANGELOG.md +76 -0
- package/README.md +47 -2
- package/assets/nx-kv/SKILL.md +48 -0
- package/assets/nx-kv/references/todo-commands.md +52 -0
- package/package.json +1 -1
- package/src/core/attachstore.js +269 -0
- package/src/core/config.js +22 -0
- package/src/core/fileapi.js +243 -0
- package/src/core/kvapi.js +3 -1
- package/src/core/paths.js +9 -0
- package/src/modules/file/index.js +100 -0
- package/src/modules/file/service.js +214 -0
- package/src/modules/setting/index.js +33 -0
- package/src/modules/setting/service.js +48 -0
- package/src/modules/todo/attach.js +96 -0
- package/src/modules/todo/editor.jsx +229 -0
- package/src/modules/todo/index.js +72 -0
- package/src/modules/todo/markdown.jsx +53 -0
- package/src/modules/todo/service.js +171 -10
- package/src/modules/todo/upload.js +107 -0
- package/src/modules/todo/view.jsx +83 -10
- package/src/runtime/api.js +6 -1
- package/src/runtime/registry.js +3 -1
- package/src/web/frontend/style.css +133 -0
- package/src/web/public/assets/{CliHints-ZTpac1yV.js → CliHints-CGOWc046.js} +1 -1
- package/src/web/public/assets/{index-CmCYxz0e.css → index-BDsJPdhM.css} +1 -1
- package/src/web/public/assets/{index-gJ1xxwt4.js → index-BVbLNACS.js} +2 -2
- package/src/web/public/assets/{view-Cq4GElgD.js → view-BBdEL07x.js} +1 -1
- package/src/web/public/assets/view-BlGIceud.js +3 -0
- package/src/web/public/assets/{view-mzUKgI5p.js → view-CwFWDigR.js} +1 -1
- package/src/web/public/assets/{view-CYSIt3hy.js → view-DUy79STG.js} +1 -1
- package/src/web/public/index.html +2 -2
- package/src/web/public/assets/view-C9k3lhCV.js +0 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,82 @@
|
|
|
2
2
|
|
|
3
3
|
本文件记录对外可见的变更。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。
|
|
4
4
|
|
|
5
|
+
## [0.9.0] - 2026-10-16
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **任务附件:正文里用标准 markdown 嵌图片与文档,并可同步到本地给 agent 直接读。**
|
|
10
|
+
|
|
11
|
+
- **嵌入语法就是标准 markdown**,不发明新方言(解析实现只有一处:
|
|
12
|
+
`src/modules/todo/attach.js`,Node 与浏览器共用):
|
|
13
|
+
`` 图片(面板内联预览)、`[文件名](fileId)` 其他文件(docx/pdf…)。
|
|
14
|
+
`fileId` 是后端图床的不透明 id(32 位 hex,原文件名带后缀时结尾接 `.<ext>`)。
|
|
15
|
+
**判定刻意收窄**:URL(含 `://`)、站点路径(`/…`)、相对路径(`./…`)一律按
|
|
16
|
+
普通 markdown 原样保留 —— 否则任务里写条普通链接就会被当成附件,
|
|
17
|
+
然后预览 404、同步白跑一趟网络。
|
|
18
|
+
- **新模块 `file`**:`file upload / list / get / remove / content / sync`。
|
|
19
|
+
上传走后端 multipart(`POST /api/v1/files`,字段名 `file`);`file content` 在
|
|
20
|
+
HTTP 侧把后端出图内容原样透传(面板 `<img src="/api/file/content/<id>">` 用),
|
|
21
|
+
CLI 侧只打印地址与本地路径。
|
|
22
|
+
- **`file sync`:把附件同步到本机** —— 存在的唯一理由是让 agent **读本地文件而不是读网**。
|
|
23
|
+
默认落到 `~/.nx-kv/attachments/<groupId|default>/`(`NX_KV_ATTACHMENTS` 可改根目录),
|
|
24
|
+
同目录 `manifest.json` 记「fileId → 本地文件名 + 大小」,所以二次同步跳过未变更的。
|
|
25
|
+
本地文件名优先取正文里的说明文字(`` → `设计稿.png`),否则退回
|
|
26
|
+
fileId 并按 Content-Type 补扩展名(没有扩展名,Windows 上双击打不开)。
|
|
27
|
+
`--ids` 只同步指定附件;`--prune` 清理本地已不在后端的(**仅在全量同步时生效**——
|
|
28
|
+
带 `--ids` 再 prune 会把没列出来的附件一起删掉);`--dryRun` 只列不落盘。
|
|
29
|
+
- **`todo attach` / `todo attachments`**:把附件**追加**到任务正文;列出某条任务
|
|
30
|
+
引用了哪些附件、本地是否已有。`todo attach` 会拒绝不像 fileId 的值(比如 URL)。
|
|
31
|
+
⚠️ 追加会改写正文,而定位按正文内容(`--ref`)—— 连续操作要用**新的** text。
|
|
32
|
+
- **新模块 `setting` + 面板开关:「读任务时自动下载附件」。**
|
|
33
|
+
`nx-kv setting get` / `nx-kv setting set --autoSyncAttachments[=false]`
|
|
34
|
+
(面板在「清单」页工具栏)。开启后 `todo list` / `todo get` 会同步正文引用的附件,
|
|
35
|
+
给每个任务附加 `attachments[]`(`id / kind / name / localPath / synced`,
|
|
36
|
+
**localPath 是本地绝对路径**),并用 `attachmentsSync` 汇报这一轮
|
|
37
|
+
`downloaded / skipped / failures`。**下载失败不阻断读取** —— 附件拿不到与
|
|
38
|
+
清单读不到是两件事,失败如实进 `failures`,本地已有旧版本时继续用它。
|
|
39
|
+
默认**关**:开启意味着每次 list/get 都触网,对「只想看一眼清单」是纯负担。
|
|
40
|
+
- **`attachments` 标注与开关无关**:关掉开关后不再同步,但「这条任务挂了附件」
|
|
41
|
+
仍会出现在 `attachments[]` 里(`localPath` 反映本地已有则给路径)。
|
|
42
|
+
- 前端:`todo` 的编辑从单行 dialog 换成完整编辑器弹窗(`editor.jsx`)——
|
|
43
|
+
**粘贴(Ctrl+V)/ 拖入 / 选择文件** 三条路都能上传,自动插到光标处、实时预览;
|
|
44
|
+
列表里图片以缩略图内联、其他文件显示为可点附件(`markdown.jsx`)。
|
|
45
|
+
- **Ctrl+V 直接粘截图**是主路径:截图工具不给文件名,`upload.js` 会替它起
|
|
46
|
+
`粘贴图片-<时间戳>.png`(这个名字会一路用下去:正文说明文字 + 落盘文件名,
|
|
47
|
+
退化成 `image.png` 就再也认不出是哪张)。一次粘多张带序号,不重名。
|
|
48
|
+
- 拖拽挂在 **window** 上而不是编辑区上:往弹窗里丢文件、落点差一点也该接住,
|
|
49
|
+
而不是让浏览器把文件打开;只在拖**文件**时介入(拖选中的文字不弹提示)。
|
|
50
|
+
- 批量上传**先收齐再一次性插入** —— 边传边插会跟光标位置打架
|
|
51
|
+
(插入后 DOM 里光标还没更新,第二个附件会算错落点、变成倒序)。
|
|
52
|
+
- 纯文本粘贴不拦截,交给浏览器默认行为插到光标处。
|
|
53
|
+
- 快捷键 <kbd>Ctrl</kbd>+<kbd>Enter</kbd> 保存;字符计数与插入语法提示常驻。
|
|
54
|
+
- 「快速新增」那一栏也能 Ctrl+V 贴图:贴张截图再回车,任务与附件一次成型。
|
|
55
|
+
- 「附件自动下载」做成胶囊开关(面板「清单」页),悬停显示附件会落到哪个目录。
|
|
56
|
+
- `core/fileapi.js`(后端文件客户端,与 kvapi 共用同一套信封判定)、
|
|
57
|
+
`core/attachstore.js`(本地镜像与台账)、`core/config.resolveGroupId`
|
|
58
|
+
(模块间共享的 group 回落规则 —— todo 读 KV 与 file 选分组/本地目录必须同一条,
|
|
59
|
+
否则会出现「任务读 A 组、附件同步 B 组」这种不报错的错位)。
|
|
60
|
+
- 本机服务的请求体上限 4MB → 24MB:面板上传走 base64 JSON(膨胀 ~33%),
|
|
61
|
+
10MB 的图会变成 ~13MB 的请求体。
|
|
62
|
+
|
|
63
|
+
### Changed
|
|
64
|
+
|
|
65
|
+
- `core/kvapi.js` 的 `call` 改为导出,供 `core/fileapi.js` 复用(两处各写一遍
|
|
66
|
+
信封判定迟早分叉)。
|
|
67
|
+
- `todo list` / `todo get` 的每个任务可能多出 `attachments[]`;开启自动下载时
|
|
68
|
+
顶层多出 `attachmentsSync`。未开启时**没有** `attachmentsSync`,其余字段与旧版一致。
|
|
69
|
+
|
|
70
|
+
### Tests
|
|
71
|
+
|
|
72
|
+
- `tests/fake-backend.mjs` 增加文件端点,并**手写 multipart 解析** ——
|
|
73
|
+
用 JSON 假装上传就测不出「boundary 丢了」「字段名写错」。
|
|
74
|
+
假后端刻意**不返回 originalName**,与真实后端一致:本地文件名只能自己解决。
|
|
75
|
+
- `tests/smoke.mjs` 新增 25 项:上传(图片/docx)、list/get/remove、content 直出字节、
|
|
76
|
+
嵌入追加、`file sync` 落盘与二次跳过、开关打开后读任务自动下载并回传全路径、
|
|
77
|
+
关掉后不再同步但标注仍在、非法 fileId 被拒。
|
|
78
|
+
- `tests/unit/attach.test.mjs` 新增 9 项:嵌入解析的边界(普通链接不许被误判、
|
|
79
|
+
去重保序、说明文字取首次出现、往返一致)。
|
|
80
|
+
|
|
5
81
|
## [0.8.5] - 2026-10-13
|
|
6
82
|
|
|
7
83
|
### Added
|
package/README.md
CHANGED
|
@@ -6,6 +6,10 @@ KV 清单(todo)管理中枢。一个 `npx` 命令起一个 Web 面板,同
|
|
|
6
6
|
对接 GoFrame KV 后端(默认 `http://47.110.80.47:8988`),
|
|
7
7
|
数据落在**四把 key** 上:`todo:open` / `todo:done` / `todo:freeze` / `todo:topics`。
|
|
8
8
|
|
|
9
|
+
任务正文还能直接**嵌图片与文档**(标准 markdown:`` / `[文件名](fileId)`)——
|
|
10
|
+
附件存在后端图床的同一用户空间里,既能同步到本地给 agent 直接读(`nx-kv file sync`),
|
|
11
|
+
也能在读任务时自动下载并把本地绝对路径一并交出去。
|
|
12
|
+
|
|
9
13
|
## 快速开始
|
|
10
14
|
|
|
11
15
|
```bash
|
|
@@ -66,6 +70,47 @@ nx-kv group members 24 # 成员
|
|
|
66
70
|
完整命令表由 action 声明自动生成:`nx-kv help`。
|
|
67
71
|
命令与 HTTP 端点互相可查:`nx-kv routes`,反查用 `nx-kv routes --http "POST /api/todo"`。
|
|
68
72
|
|
|
73
|
+
## 附件(图片 / 文档)
|
|
74
|
+
|
|
75
|
+
任务正文直接用**标准 markdown** 引用附件,附件本体存在后端图床的同一用户空间里:
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
修一下首页 logo  # 图片:面板内联预览
|
|
79
|
+
补充材料见 [需求说明.docx](b71e…04.docx) # 其他文件:点开即下载/预览
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
括号里是后端返回的 `fileId`(32 位 hex,原文件名带后缀时会接 `.<ext>`)。
|
|
83
|
+
URL 与相对路径不算附件,按普通 markdown 原样保留(不会误判)。
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
nx-kv file upload --path "D:\设计\首页.png" # 上传到当前用户空间
|
|
87
|
+
nx-kv todo attach --ref "修一下首页 logo" --path "D:\设计\首页.png" # 上传并嵌进正文
|
|
88
|
+
nx-kv todo attachments --ref "修一下首页 logo" # 这条任务引用了哪些附件
|
|
89
|
+
|
|
90
|
+
nx-kv file sync # 把附件同步到 ~/.nx-kv/attachments/<工作空间>/
|
|
91
|
+
nx-kv file sync --ids a,b # 只同步指定附件
|
|
92
|
+
nx-kv file sync --prune # 顺带清掉本地已不在后端的(仅全量同步时生效)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
面板里编辑任务时,**Ctrl+V 直接粘截图**、把文件拖进来、或点「选择文件」——
|
|
96
|
+
都会自动上传并插到光标处,右侧实时预览;列表里图片显示为缩略图、其他文件是可点附件。
|
|
97
|
+
「快速新增」那一栏也支持粘贴图片。
|
|
98
|
+
|
|
99
|
+
**给 agent 的关键开关**:开启「读任务时自动下载」后,`todo list` / `todo get`
|
|
100
|
+
的每个任务会多出 `attachments[]`,里面是**本地绝对路径**,另有 `attachmentsSync`
|
|
101
|
+
汇报下载/跳过/失败。读本地文件因此不再受网络与跨域影响:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
nx-kv setting set --autoSyncAttachments # 开
|
|
105
|
+
nx-kv setting set --autoSyncAttachments=false # 关(默认)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
面板里对应「清单」页工具栏的「附件自动下载」勾选。本地文件名优先取正文里的说明文字
|
|
109
|
+
(`` → `设计稿.png`),否则退回 fileId 并补上扩展名;
|
|
110
|
+
同目录 `manifest.json` 记「fileId → 文件名 + 大小」,所以二次同步会跳过未变更的。
|
|
111
|
+
|
|
112
|
+
单独的命令参考见 `nx-kv help file` 与 skill 的 `todo-commands`。
|
|
113
|
+
|
|
69
114
|
## 本地任务(localtask,不需登录)
|
|
70
115
|
|
|
71
116
|
本机任务队列,按**工作目录**划分(目录名即主题):提交(`add`)、领取(`wait`)、
|
|
@@ -156,8 +201,8 @@ nx-kv todo get --ref "旧记录" --topic fr # 或按主题收窄
|
|
|
156
201
|
所以「Web 上能做的,CLI 都能做」是结构保证而非口头约定。
|
|
157
202
|
|
|
158
203
|
```
|
|
159
|
-
src/core/ 零业务语义:kvapi
|
|
160
|
-
src/modules/ 功能域:system / auth / group / localtask / todo / bundled
|
|
204
|
+
src/core/ 零业务语义:kvapi / fileapi(后端客户端) / attachstore(附件本地镜像) / config / errors / fstree
|
|
205
|
+
src/modules/ 功能域:system / setting / auth / group / localtask / todo / file / bundled
|
|
161
206
|
src/runtime/ 装配层:registry / spec / cli / api / server
|
|
162
207
|
src/web/frontend/ React 壳
|
|
163
208
|
assets/nx-kv/ 内置 skill:KV 清单操作手册(远程)
|
package/assets/nx-kv/SKILL.md
CHANGED
|
@@ -17,6 +17,7 @@ KV 清单(todo)的操作入口。四把 key 上的任务管理,对接 GoFr
|
|
|
17
17
|
| --- | --- |
|
|
18
18
|
| **领任务 / 收单**:按主题拉待办 → 读上下文 → 与用户对齐意图 → 完成后回填 | [[agent-workflow]] |
|
|
19
19
|
| **查命令**:要敲哪条、退出码、数据结构、避坑 | [[todo-commands]] |
|
|
20
|
+
| **任务带附件**:正文里的图片 / docx 怎么读、怎么落到本地 | 本页「附件」一节 + [[todo-commands]] |
|
|
20
21
|
|
|
21
22
|
两份都是按需加载,不要一开始就全读进来。
|
|
22
23
|
|
|
@@ -55,6 +56,53 @@ nx-kv todo done --ref "<任务内容>" --result "改了什么 / 关键决策 /
|
|
|
55
56
|
`--status open --topic` 是「只要待办」。收单用后者(省上下文),
|
|
56
57
|
做统计/核对/回顾用前者。
|
|
57
58
|
|
|
59
|
+
## 附件:正文里的图片与文档(读本地,不读网)
|
|
60
|
+
|
|
61
|
+
任务**正文本身**就能带附件——用标准 markdown 写,不发明新语法:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
修一下首页 logo  ← 图片,面板里内联预览
|
|
65
|
+
补充材料见 [需求说明.docx](b71e…04.docx) ← 其他文件,点开即下载/预览
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`( )` 里是**后端图床的 fileId**(32 位 hex,原文件名带后缀时结尾会接 `.<ext>`)。
|
|
69
|
+
除此之外的 markdown 链接(`http(s)://`、`./x.md`)一律按普通链接处理,不会被当成附件。
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
nx-kv todo attachments --ref "<任务内容>" --json # 这条任务引用了哪些附件(含本地路径)
|
|
73
|
+
nx-kv todo attach --ref "<任务内容>" --path <本机文件> # 上传并追加到正文
|
|
74
|
+
nx-kv todo attach --ref "<任务内容>" --fileId <fileId> # 追加一个已上传的附件
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**给要读附件的 agent:先把附件落到本地,再读本地文件。**
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
nx-kv file sync --json # 同步当前工作空间的附件 → ~/.nx-kv/attachments/<工作空间>/
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**更省事的做法是打开「读任务时自动下载」**(面板「清单」页的开关,或 CLI 一条命令):
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
nx-kv setting set --autoSyncAttachments # 开
|
|
87
|
+
nx-kv setting set --autoSyncAttachments=false # 关(默认)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
开启后,`todo list` / `todo get` 的每个任务会多出 `attachments[]`,
|
|
91
|
+
里面直接给出**本地绝对路径**(`localPath`),另有 `attachmentsSync` 汇报这一轮
|
|
92
|
+
下载了什么、跳过什么、失败什么:
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{ "id": "3f2a…9c", "kind": "image", "name": "设计稿.png",
|
|
96
|
+
"localPath": "C:\\Users\\me\\.nx-kv\\attachments\\24\\设计稿.png", "synced": true }
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
于是 agent 只要 `Read` 那个路径就行——不必拼出图 URL、不受网络和跨域影响。
|
|
100
|
+
下载失败**不会**让清单读不出来:附件拿不到与任务读不到是两件事,
|
|
101
|
+
失败项如实出现在 `attachmentsSync.failures` 里。
|
|
102
|
+
|
|
103
|
+
> ⚠️ 追加附件会**改写正文**,而定位是按正文内容(`--ref`)——
|
|
104
|
+
> 同一条任务连续操作时,要用**新的** text 去 `--ref`。
|
|
105
|
+
|
|
58
106
|
## ⚠️ 定位一律按内容,不按 id(最容易改错数据的地方)
|
|
59
107
|
|
|
60
108
|
待办的 id **非常容易重复**,两条独立的原因:
|
|
@@ -92,6 +92,55 @@ nx-kv group current
|
|
|
92
92
|
nx-kv group use <id|name|default> # 别名:group switch
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
+
## 附件(图片 / 文档)
|
|
96
|
+
|
|
97
|
+
| 操作 | 命令 | 路由 |
|
|
98
|
+
| --- | --- | --- |
|
|
99
|
+
| 传附件 | `nx-kv file upload --path <本机文件>` | `POST /api/file` |
|
|
100
|
+
| 列附件 | `nx-kv file list [--limit N]` | `GET /api/file` |
|
|
101
|
+
| 查单个 | `nx-kv file get <fileId>` | `GET /api/file/item` |
|
|
102
|
+
| 出图/下载 | `nx-kv file content <fileId>` | `GET /api/file/content/:fileId`(HTTP 直出字节) |
|
|
103
|
+
| 删附件 | `nx-kv file remove <fileId>` | `DELETE /api/file/item` |
|
|
104
|
+
| **同步到本地** | `nx-kv file sync [--ids a,b] [--dir D] [--prune] [--dryRun]` | `POST /api/file/sync` |
|
|
105
|
+
| 给任务挂附件 | `nx-kv todo attach --ref <内容> --path <本机文件>` | `POST /api/todo/attach` |
|
|
106
|
+
| 看任务挂了什么 | `nx-kv todo attachments --ref <内容>` | `GET /api/todo/attachments` |
|
|
107
|
+
| 读/改设置 | `nx-kv setting get` / `nx-kv setting set --autoSyncAttachments` | `GET|POST /api/setting` |
|
|
108
|
+
|
|
109
|
+
**正文里的嵌入语法**(标准 markdown,不发明方言):
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
 图片:面板里内联预览
|
|
113
|
+
[文件名](fileId) 其他文件(docx/pdf/…):点开即下载/预览
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`fileId` 是后端图床的不透明 id:32 位 hex,原文件名带扩展名时结尾接 `.<ext>`
|
|
117
|
+
(如 `3f2a…9c.docx`)。URL(含 `://`)与相对路径(`./x.md`、`/files/x`)**不算附件**,
|
|
118
|
+
一律按普通 markdown 原样保留。
|
|
119
|
+
|
|
120
|
+
**本地镜像**:默认落到 `~/.nx-kv/attachments/<groupId|default>/`,同目录的
|
|
121
|
+
`manifest.json` 记「fileId → 本地文件名 + 大小」,所以二次同步会跳过未变更的
|
|
122
|
+
(不会每次重下)。文件名优先用正文里的**说明文字**(`` → `设计稿.png`),
|
|
123
|
+
没有说明文字时退回 fileId 并补上扩展名。
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
# 读任务时自动下载:开启后 todo list / get 会带回本地绝对路径
|
|
127
|
+
nx-kv setting set --autoSyncAttachments
|
|
128
|
+
nx-kv todo get --ref "<任务内容>" --json
|
|
129
|
+
# → 任务对象多出 attachments[](id / kind / name / localPath / synced)
|
|
130
|
+
# + attachmentsSync(dir / downloaded / skipped / failures)
|
|
131
|
+
|
|
132
|
+
# 手动同步(不依赖开关)
|
|
133
|
+
nx-kv file sync --json # 全部附件
|
|
134
|
+
nx-kv file sync --ids <id1>,<id2> # 只同步指定附件(读任务时按引用同步走的就是这条)
|
|
135
|
+
nx-kv file sync --prune # 额外删掉本地已不在后端的文件(仅全量同步时生效)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`file sync --prune` 在带 `--ids` 时**刻意不生效**:过滤出几个 id 再 prune,
|
|
139
|
+
会把没列出来的附件一起删掉。
|
|
140
|
+
|
|
141
|
+
删除附件的语义:`file remove` 只删后端记录(软删),本地已同步的副本会留着;
|
|
142
|
+
要连本地一起清,用 `file sync --prune`。
|
|
143
|
+
|
|
95
144
|
## ⚠️ 定位按内容(--ref),不按 id
|
|
96
145
|
|
|
97
146
|
```jsonc
|
|
@@ -215,3 +264,6 @@ nx-kv todo list --status open --topic bug # bug 消费者
|
|
|
215
264
|
| 未登录就调 todo | exit 1「未登录」 | 先 `auth login` |
|
|
216
265
|
| 以为切换 group 会改服务端 | 只改本机配置 | 数据一直在服务端,切换只是换个 groupId |
|
|
217
266
|
| 领取待办时用了全量 `list` | done 的历史 + 长 note 白占上下文 | 一律 `--status open --topic <t>` |
|
|
267
|
+
| `todo attach` 之后还用**旧的** `--ref` | 报「不存在」——正文已被追加改写 | 用返回的 `task.text`(新正文)继续操作 |
|
|
268
|
+
| 把附件当成「读了就行」的东西 | agent 拿 `<img>` URL 去请求,受网络/跨域影响 | 先 `file sync`(或开 `--autoSyncAttachments`),读 `localPath` |
|
|
269
|
+
| 拿图片地址手拼 / 存进正文 | 换后端地址就全失效 | 正文只写 `fileId`(``),地址由客户端拼 |
|
package/package.json
CHANGED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// 附件的「本地镜像」:把某个用户空间(group)里的附件落到本机,
|
|
2
|
+
// 好让 agent **直接读本地文件**,不再依赖网络与出图地址。
|
|
3
|
+
//
|
|
4
|
+
// 目录(刻意落在 nx-kv 的用户目录下):
|
|
5
|
+
// ~/.nx-kv/attachments/<groupId|default>/ ← NX_KV_ATTACHMENTS 可改根目录
|
|
6
|
+
// 台账:
|
|
7
|
+
// 同目录 manifest.json → { groupId, dir, updatedAt, files: { <fileId>: {name, size, contentType, syncedAt} } }
|
|
8
|
+
//
|
|
9
|
+
// 为什么需要台账:后端 FileInfoRes **不回传原始文件名**,本地落点只能自己定;
|
|
10
|
+
// 台账把「fileId → 本地文件名 + 大小」固定下来,于是反复同步能跳过未变更的,
|
|
11
|
+
// 也不会每 sync 一次就换一个名字(那样等于每次重新下载)。
|
|
12
|
+
import fsp from 'node:fs/promises';
|
|
13
|
+
import { existsSync } from 'node:fs';
|
|
14
|
+
import { dirname, extname, join, parse as parsePath, resolve } from 'node:path';
|
|
15
|
+
import { attachmentsRootFromEnv } from './paths.js';
|
|
16
|
+
import { normalizeGroupId } from './config.js';
|
|
17
|
+
import { downloadFile, listAllFiles } from './fileapi.js';
|
|
18
|
+
|
|
19
|
+
export const MANIFEST_NAME = 'manifest.json';
|
|
20
|
+
|
|
21
|
+
const nowIso = () => new Date().toISOString();
|
|
22
|
+
|
|
23
|
+
/** 该 group 的本地目录。override 优先(来自设置里的 attachmentsDir)。 */
|
|
24
|
+
export function localDirFor(groupId, override = '') {
|
|
25
|
+
if (override) return resolve(String(override));
|
|
26
|
+
const n = normalizeGroupId(groupId);
|
|
27
|
+
return join(attachmentsRootFromEnv(), n === undefined ? 'default' : String(n));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function manifestPath(dir) {
|
|
31
|
+
return join(dir, MANIFEST_NAME);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function readManifest(dir) {
|
|
35
|
+
try {
|
|
36
|
+
const raw = JSON.parse(await fsp.readFile(manifestPath(dir), 'utf8'));
|
|
37
|
+
return {
|
|
38
|
+
groupId: Number(raw.groupId) || 0,
|
|
39
|
+
dir: String(raw.dir || dir),
|
|
40
|
+
updatedAt: String(raw.updatedAt || ''),
|
|
41
|
+
files: raw.files && typeof raw.files === 'object' ? raw.files : {},
|
|
42
|
+
};
|
|
43
|
+
} catch {
|
|
44
|
+
// 台账不存在或损坏:当成空台账。它只是缓存,不是真相源 —— 真相源是后端。
|
|
45
|
+
return { groupId: 0, dir, updatedAt: '', files: {} };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function writeManifest(dir, data) {
|
|
50
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
51
|
+
const tmp = manifestPath(dir) + '.tmp';
|
|
52
|
+
await fsp.writeFile(tmp, JSON.stringify(data, null, 2), 'utf8');
|
|
53
|
+
await fsp.rename(tmp, manifestPath(dir));
|
|
54
|
+
return data;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Windows 保留设备名做文件名会让文件无法访问
|
|
58
|
+
const RESERVED = new Set([
|
|
59
|
+
'con', 'prn', 'aux', 'nul',
|
|
60
|
+
...Array.from({ length: 9 }, (_, i) => `com${i + 1}`),
|
|
61
|
+
...Array.from({ length: 9 }, (_, i) => `lpt${i + 1}`),
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
/** 收成跨平台可用的单段文件名(保留中文)。 */
|
|
65
|
+
export function safeFileName(name) {
|
|
66
|
+
let s = String(name ?? '')
|
|
67
|
+
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
|
|
68
|
+
.replace(/\s+/g, ' ')
|
|
69
|
+
.trim()
|
|
70
|
+
.replace(/^\.+/, '');
|
|
71
|
+
if (!s) s = 'file';
|
|
72
|
+
if (RESERVED.has(parsePath(s).name.toLowerCase())) s = '_' + s;
|
|
73
|
+
return s.slice(0, 120);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const EXT_BY_TYPE = {
|
|
77
|
+
'image/png': '.png',
|
|
78
|
+
'image/jpeg': '.jpg',
|
|
79
|
+
'image/webp': '.webp',
|
|
80
|
+
'image/gif': '.gif',
|
|
81
|
+
'image/svg+xml': '.svg',
|
|
82
|
+
'application/pdf': '.pdf',
|
|
83
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx',
|
|
84
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx',
|
|
85
|
+
'application/vnd.openxmlformats-officedocument.presentationml.presentation': '.pptx',
|
|
86
|
+
'text/plain; charset=utf-8': '.txt',
|
|
87
|
+
'text/markdown; charset=utf-8': '.md',
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 本地落点文件名。优先用调用方给的「人读名字」(任务里 markdown 的说明/链接文字),
|
|
92
|
+
* 否则退回 fileId;两者都没有扩展名时,再按 fileId 自带后缀 / Content-Type 补一个。
|
|
93
|
+
* 补后缀是刚需:没有扩展名,Windows 上双击打不开、内联预览也认不出类型。
|
|
94
|
+
*/
|
|
95
|
+
export function localNameFor(fileId, preferred, contentType = '') {
|
|
96
|
+
let base = safeFileName(preferred || fileId);
|
|
97
|
+
if (!extname(base)) {
|
|
98
|
+
const fromId = extname(String(fileId));
|
|
99
|
+
const guess = fromId || EXT_BY_TYPE[String(contentType).toLowerCase()] || '';
|
|
100
|
+
if (guess) base += guess;
|
|
101
|
+
}
|
|
102
|
+
return base;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 同目录内不重名的落点(`名字 (2).ext`)。files 是 id → 台账记录。 */
|
|
106
|
+
function uniqueName(files, name, selfId) {
|
|
107
|
+
const taken = new Set(
|
|
108
|
+
Object.entries(files)
|
|
109
|
+
.filter(([id]) => id !== selfId)
|
|
110
|
+
.map(([, rec]) => String(rec?.name || ''))
|
|
111
|
+
);
|
|
112
|
+
if (!taken.has(name)) return name;
|
|
113
|
+
const { name: stem, ext } = parsePath(name);
|
|
114
|
+
let n = 1;
|
|
115
|
+
let candidate = name;
|
|
116
|
+
while (taken.has(candidate)) {
|
|
117
|
+
n += 1;
|
|
118
|
+
candidate = `${stem} (${n})${ext}`;
|
|
119
|
+
}
|
|
120
|
+
return candidate;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 读台账 + 落盘文件,判断某个附件是否**已经在本地且内容未变**。
|
|
125
|
+
* 返回绝对路径;没同步过(或本地文件被删了)返回 null。
|
|
126
|
+
*/
|
|
127
|
+
export async function resolveLocal(groupId, fileId, overrideDir = '') {
|
|
128
|
+
const dir = localDirFor(groupId, overrideDir);
|
|
129
|
+
const man = await readManifest(dir);
|
|
130
|
+
const rec = man.files[fileId];
|
|
131
|
+
if (!rec || !rec.name) return null;
|
|
132
|
+
const abs = join(dir, String(rec.name));
|
|
133
|
+
return existsSync(abs) ? { path: abs, name: String(rec.name), size: Number(rec.size) || 0 } : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 该目录下已同步的全部条目(id → {path,name,size}),供 `todo attachments` 批量标注用。 */
|
|
137
|
+
export async function localIndex(groupId, overrideDir = '') {
|
|
138
|
+
const dir = localDirFor(groupId, overrideDir);
|
|
139
|
+
const man = await readManifest(dir);
|
|
140
|
+
const out = new Map();
|
|
141
|
+
for (const [id, rec] of Object.entries(man.files)) {
|
|
142
|
+
const abs = join(dir, String(rec?.name || ''));
|
|
143
|
+
if (rec?.name && existsSync(abs)) {
|
|
144
|
+
out.set(id, { path: abs, name: String(rec.name), size: Number(rec.size) || 0 });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { dir, index: out };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 同步附件到本地。
|
|
152
|
+
*
|
|
153
|
+
* - `ids` 为空 → 同步该 group 的**全部**附件(`file sync` 的默认语义)
|
|
154
|
+
* - `ids` 非空 → 只同步这些 id(读任务时按引用同步走这条)
|
|
155
|
+
* - `names` → fileId 的人读文件名(来自任务里 markdown 的说明文字)
|
|
156
|
+
* - `prune` → 删掉本地已不在后端的文件(**仅在 sync-all 时生效**:
|
|
157
|
+
* 带了 ids 过滤还 prune,会把没列出来的附件一起删掉)
|
|
158
|
+
*
|
|
159
|
+
* 单个文件下载失败**不中断整体**:如实记进 failures 让调用方知道哪些没拿到。
|
|
160
|
+
*/
|
|
161
|
+
export async function syncAttachments({
|
|
162
|
+
groupId,
|
|
163
|
+
ids = null,
|
|
164
|
+
names = {},
|
|
165
|
+
dir: overrideDir = '',
|
|
166
|
+
prune = false,
|
|
167
|
+
dryRun = false,
|
|
168
|
+
} = {}) {
|
|
169
|
+
const dir = localDirFor(groupId, overrideDir);
|
|
170
|
+
const man = await readManifest(dir);
|
|
171
|
+
const files = { ...man.files };
|
|
172
|
+
|
|
173
|
+
const remote = await listAllFiles({ groupId });
|
|
174
|
+
const wanted = Array.isArray(ids) && ids.length ? new Set(ids.map(String)) : null;
|
|
175
|
+
const remoteIds = new Set(remote.items.map((f) => f.fileId));
|
|
176
|
+
|
|
177
|
+
const failures = [];
|
|
178
|
+
const missing = [];
|
|
179
|
+
if (wanted) for (const id of wanted) if (!remoteIds.has(id)) missing.push(id);
|
|
180
|
+
|
|
181
|
+
const entries = [];
|
|
182
|
+
let downloaded = 0;
|
|
183
|
+
let skipped = 0;
|
|
184
|
+
|
|
185
|
+
for (const meta of remote.items) {
|
|
186
|
+
const id = meta.fileId;
|
|
187
|
+
if (wanted && !wanted.has(id)) continue;
|
|
188
|
+
|
|
189
|
+
const desired = localNameFor(id, names[id], meta.contentType);
|
|
190
|
+
const prev = files[id];
|
|
191
|
+
const prevAbs = prev?.name ? join(dir, String(prev.name)) : '';
|
|
192
|
+
const sizeKnown = Number(prev?.size) > 0;
|
|
193
|
+
const unchanged = prevAbs && existsSync(prevAbs) && sizeKnown && Number(prev.size) === Number(meta.size);
|
|
194
|
+
|
|
195
|
+
if (unchanged) {
|
|
196
|
+
skipped++;
|
|
197
|
+
entries.push({ id, name: String(prev.name), path: prevAbs, size: Number(prev.size), contentType: meta.contentType, synced: true });
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (dryRun) {
|
|
201
|
+
entries.push({ id, name: desired, path: join(dir, desired), size: meta.size, contentType: meta.contentType, synced: false });
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let buf;
|
|
206
|
+
try {
|
|
207
|
+
buf = await downloadFile(id);
|
|
208
|
+
} catch (e) {
|
|
209
|
+
failures.push({ id, error: String((e && e.message) || e) });
|
|
210
|
+
// 本地已有旧版本就继续用它,避免「网络抖一下附件就消失」
|
|
211
|
+
if (prevAbs && existsSync(prevAbs)) {
|
|
212
|
+
entries.push({ id, name: String(prev.name), path: prevAbs, size: Number(prev.size), contentType: meta.contentType, synced: true, stale: true });
|
|
213
|
+
}
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const name = uniqueName(files, desired, id);
|
|
218
|
+
const abs = join(dir, name);
|
|
219
|
+
try {
|
|
220
|
+
await fsp.mkdir(dirname(abs), { recursive: true });
|
|
221
|
+
await fsp.writeFile(abs, buf);
|
|
222
|
+
} catch (e) {
|
|
223
|
+
failures.push({ id, error: String((e && e.message) || e) });
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
files[id] = { name, size: buf.length, contentType: meta.contentType, syncedAt: nowIso() };
|
|
227
|
+
downloaded++;
|
|
228
|
+
entries.push({ id, name, path: abs, size: buf.length, contentType: meta.contentType, synced: true });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// prune:只在同步全部时生效(理由见函数头)
|
|
232
|
+
const removed = [];
|
|
233
|
+
if (prune && !wanted && !dryRun) {
|
|
234
|
+
for (const id of Object.keys(files)) {
|
|
235
|
+
if (remoteIds.has(id)) continue;
|
|
236
|
+
const abs = join(dir, String(files[id]?.name || ''));
|
|
237
|
+
try {
|
|
238
|
+
if (existsSync(abs)) await fsp.unlink(abs);
|
|
239
|
+
} catch {
|
|
240
|
+
// 删不掉就留着(可能被占用);台账条目仍移除,下次同步不会误认它是最新的
|
|
241
|
+
}
|
|
242
|
+
removed.push(files[id]?.name || id);
|
|
243
|
+
delete files[id];
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (!dryRun) {
|
|
248
|
+
await writeManifest(dir, {
|
|
249
|
+
groupId: normalizeGroupId(groupId) || 0,
|
|
250
|
+
dir,
|
|
251
|
+
updatedAt: nowIso(),
|
|
252
|
+
files,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
dir,
|
|
258
|
+
groupId: normalizeGroupId(groupId) || 0,
|
|
259
|
+
dryRun: !!dryRun,
|
|
260
|
+
totalRemote: remote.items.length,
|
|
261
|
+
target: wanted ? wanted.size : remote.items.length,
|
|
262
|
+
downloaded,
|
|
263
|
+
skipped,
|
|
264
|
+
removed,
|
|
265
|
+
missing,
|
|
266
|
+
failures,
|
|
267
|
+
files: entries,
|
|
268
|
+
};
|
|
269
|
+
}
|
package/src/core/config.js
CHANGED
|
@@ -23,6 +23,12 @@ const EMPTY = () => ({
|
|
|
23
23
|
userId: 0,
|
|
24
24
|
// 当前工作空间:0 = 不传 groupId(后端回落默认组)
|
|
25
25
|
groupId: 0,
|
|
26
|
+
// 读任务时是否自动把附件拉到本机。默认 **关**:开启意味着每次 list/get 都会
|
|
27
|
+
// 触网下载,对「只想看一眼清单」的场景是纯负担;开关在面板「清单」页或
|
|
28
|
+
// `nx-kv setting set --autoSyncAttachments`。
|
|
29
|
+
autoSyncAttachments: false,
|
|
30
|
+
// 附件本地目录覆盖;空 = 用默认 ~/.nx-kv/attachments/<groupId>/
|
|
31
|
+
attachmentsDir: '',
|
|
26
32
|
});
|
|
27
33
|
|
|
28
34
|
let cache = null;
|
|
@@ -37,6 +43,8 @@ function normalize(data) {
|
|
|
37
43
|
email: String(data.email || ''),
|
|
38
44
|
userId: Number(data.userId) || 0,
|
|
39
45
|
groupId: Number(data.groupId) || 0,
|
|
46
|
+
autoSyncAttachments: data.autoSyncAttachments === true,
|
|
47
|
+
attachmentsDir: String(data.attachmentsDir || ''),
|
|
40
48
|
};
|
|
41
49
|
}
|
|
42
50
|
|
|
@@ -92,3 +100,17 @@ export function normalizeGroupId(v) {
|
|
|
92
100
|
const n = Number(v);
|
|
93
101
|
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
94
102
|
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 解析「这次操作作用于哪个工作空间」:显式传入的 --group 优先,否则回落到本机配置
|
|
106
|
+
* 里的当前工作空间。
|
|
107
|
+
*
|
|
108
|
+
* 放进 core 是因为**不止一个模块要用同一条规则**:todo 拿它读写 KV,
|
|
109
|
+
* file 拿它选后端分组与本地附件目录。两处各写一遍的话,一旦有人只改了一处,
|
|
110
|
+
* 就会出现「从 A 组读任务、往 B 组同步附件」—— 这类错不报错,只是静默地对不上。
|
|
111
|
+
*/
|
|
112
|
+
export async function resolveGroupId(groupId) {
|
|
113
|
+
if (groupId !== undefined && groupId !== null && groupId !== '') return normalizeGroupId(groupId);
|
|
114
|
+
const cfg = await loadConfig();
|
|
115
|
+
return normalizeGroupId(cfg.groupId);
|
|
116
|
+
}
|