nx-kv 0.16.0 → 0.17.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.
Files changed (85) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/package.json +1 -1
  3. package/src/core/localatt.js +137 -0
  4. package/src/modules/docs/docref.js +17 -0
  5. package/src/modules/docs/index.js +5 -2
  6. package/src/modules/docs/knowledge.js +8 -7
  7. package/src/modules/docs/refs.js +18 -5
  8. package/src/modules/file/index.js +9 -5
  9. package/src/modules/file/service.js +75 -9
  10. package/src/modules/localtask/index.js +5 -7
  11. package/src/modules/repos/index.js +5 -4
  12. package/src/modules/repos/service.js +3 -5
  13. package/src/modules/repos/transfer.js +5 -5
  14. package/src/modules/tasktool/attach.js +55 -46
  15. package/src/modules/tasktool/groupctx.js +1 -1
  16. package/src/web/frontend/App.jsx +14 -6
  17. package/src/web/frontend/components/Introduction/index.jsx +44 -13
  18. package/src/web/frontend/components/Introduction/style.css +11 -0
  19. package/src/web/frontend/components/Introduction/use-intro.js +66 -16
  20. package/src/web/frontend/components/board/member-picker.jsx +16 -21
  21. package/src/web/frontend/components/cloud/editor.jsx +1 -1
  22. package/src/web/frontend/components/cloud/group-popup.jsx +3 -1
  23. package/src/web/frontend/components/cloud/upload.js +6 -5
  24. package/src/web/frontend/components/md/attachments.js +12 -10
  25. package/src/web/frontend/components/md/md-panes.jsx +4 -1
  26. package/src/web/frontend/components/md/shortcut-sheet.jsx +19 -50
  27. package/src/web/frontend/components/migrate/dialog.jsx +51 -55
  28. package/src/web/frontend/components/popup/dismiss.js +32 -0
  29. package/src/web/frontend/components/popup/index.jsx +270 -0
  30. package/src/web/frontend/components/popup/style.css +64 -0
  31. package/src/web/frontend/components/repos/transfer-panel.jsx +14 -7
  32. package/src/web/frontend/components/sidebar/index.jsx +4 -3
  33. package/src/web/frontend/components/sidebar/row-menu.jsx +11 -5
  34. package/src/web/frontend/components/sidebar/task-children.jsx +1 -1
  35. package/src/web/frontend/components/toast/index.jsx +40 -0
  36. package/src/web/frontend/components/toast/style.css +24 -0
  37. package/src/web/frontend/components/ui.jsx +11 -113
  38. package/src/web/frontend/main.jsx +1 -1
  39. package/src/web/frontend/modules/md/style.css +54 -33
  40. package/src/web/frontend/page/cloud/index.jsx +5 -3
  41. package/src/web/frontend/page/dir/index.jsx +10 -4
  42. package/src/web/frontend/page/dir/task.jsx +13 -3
  43. package/src/web/frontend/page/doc/index.jsx +2 -1
  44. package/src/web/frontend/page/group/index.jsx +1 -1
  45. package/src/web/frontend/page/overview/index.jsx +11 -2
  46. package/src/web/frontend/page/progress/index.jsx +14 -3
  47. package/src/web/frontend/page/repos/index.jsx +34 -29
  48. package/src/web/frontend/page/settings/index.jsx +9 -3
  49. package/src/web/frontend/page/templates/index.jsx +11 -3
  50. package/src/web/frontend/style-v2.css +7 -42
  51. package/src/web/frontend/style.css +40 -104
  52. package/src/web/public/assets/Introduction-B4k08SH9.css +1 -0
  53. package/src/web/public/assets/Introduction-Bbee0iwv.js +1 -0
  54. package/src/web/public/assets/cloud-B2DmkIpl.js +3 -0
  55. package/src/web/public/assets/dir-DSN1ss82.js +3 -0
  56. package/src/web/public/assets/group-BeYvUIyv.js +1 -0
  57. package/src/web/public/assets/index-CXeLbq_l.css +1 -0
  58. package/src/web/public/assets/index-Cz51X3aE.js +50 -0
  59. package/src/web/public/assets/{md-history-rfsLK89W.js → md-history-CN-7pPNs.js} +2 -2
  60. package/src/web/public/assets/member-picker-tkzaY0OF.js +1 -0
  61. package/src/web/public/assets/overview-BjqVRU69.js +1 -0
  62. package/src/web/public/assets/progress-CyAVplmW.js +3 -0
  63. package/src/web/public/assets/repos-BPj9-JSh.js +11 -0
  64. package/src/web/public/assets/settings-DR4KWF-R.js +1 -0
  65. package/src/web/public/assets/task-DN6Yf0IO.js +2 -0
  66. package/src/web/public/assets/templates-C2o7jy9W.js +2 -0
  67. package/src/web/public/assets/{upload-DLBQnUn4.js → upload-D6_jmUnY.js} +1 -1
  68. package/src/web/public/index.html +2 -3
  69. package/src/web/frontend/components/cloud/row-menu.jsx +0 -52
  70. package/src/web/frontend/components/overlay.jsx +0 -74
  71. package/src/web/public/assets/Introduction-DdUm5Il3.js +0 -1
  72. package/src/web/public/assets/Introduction-Q36q9sjA.css +0 -1
  73. package/src/web/public/assets/cloud-CRC00qO-.js +0 -3
  74. package/src/web/public/assets/dir-CrUiP4aQ.js +0 -3
  75. package/src/web/public/assets/group-CYVEZpI9.js +0 -1
  76. package/src/web/public/assets/index-Did6XKz0.js +0 -50
  77. package/src/web/public/assets/index-pgxfM8xe.css +0 -1
  78. package/src/web/public/assets/jsx-runtime-D3jfb0Ew.js +0 -1
  79. package/src/web/public/assets/member-picker-BGUlAVgA.js +0 -1
  80. package/src/web/public/assets/overview-BFoqWgXk.js +0 -1
  81. package/src/web/public/assets/progress-Df-DZ6uP.js +0 -3
  82. package/src/web/public/assets/repos-Bvg8dLSB.js +0 -11
  83. package/src/web/public/assets/settings-DtmMN_KG.js +0 -1
  84. package/src/web/public/assets/task-D5YReySK.js +0 -2
  85. package/src/web/public/assets/templates-BLGE6arn.js +0 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,41 @@
2
2
 
3
3
  本文件记录对外可见的变更。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。
4
4
 
5
+ ## [0.17.0] - 2026-11-04 参考仓库/看板改独立弹窗 + 弹窗组件收敛 + 本地任务附件落本机仓
6
+
7
+ ### Added
8
+
9
+ - **本地任务附件改走本机附件仓(任务 #15 验收反馈)**:面板编辑器贴图/选文件、
10
+ `localtask attach --path` 一律落 `~/.nx-kv/attachments/local/`(不发网,与云端
11
+ 图床零关联)——修掉「云端不可达时界面看着有图、agent 领取却 `count: 0`」的
12
+ 断链。领取标注本机仓优先(命中即给绝对路径,全命中零网络),云端清单(todo)
13
+ 的附件链路保持不变。`file upload --local` 是本机仓的直入口;`/api/file/content`
14
+ 本机仓优先、未命中再透传云端(面板预览离线可用)。
15
+ - **简介「补充文档」支持分类标签 tag**(任务 #27):同一份简介下的多篇补充文档
16
+ 可以按标签分组,列表里按标签成段,不再靠标题前缀辨认。
17
+
18
+ ### Changed
19
+
20
+ - **参考仓库、看板管理从页面内联区块搬进独立弹窗**(筛选器右侧的两个入口,
21
+ 虚线框 + ↗ 与筛选 chip 区分)。它们都是「隔一阵来维护一次」的元信息,平铺在
22
+ 页面上会把开发仓库主表顶到两屏之外;主表因此重新回到首屏。
23
+ - 参考仓库弹窗用宽版(1180px):多列表格在 880px 里会将「绑定」列挤成一团;
24
+ 表格自己当滚动容器,表头 sticky;名字列允许任意位置折断(带点的长标识原本
25
+ 会冲出去盖住远程地址列)。
26
+ - **弹窗栈**:弹窗里再开弹窗(参考仓库 → 绑定 / 克隆)时,Esc 只关最上层;
27
+ 导入导出全屏面板(z-30)在捕获阶段接管 Esc 并阻断,不会连带关掉下面的弹窗。
28
+ - 参考表格与看板行的字号提一档(td 13px、行高与留白同步),与新主表一致。
29
+ - **弹窗组件收敛(重构,界面行为不变)**:全站模态卡、确认/输入框、浮层菜单
30
+ 收编到 `components/popup/`(Modal / useDialog / PopMenu / RowMenu / useDismiss),
31
+ 轻提示收编到 `components/toast/`;此前散在各处的 `.dlg` / 两套互斥的 `.modal`
32
+ 定义 / `.mdw-sheet` / `.gmenu` 全部合并为 `.pp-*` 一套。Esc、点外关闭、busy 禁关
33
+ 从此只有一个实现,浏览器原生 `alert/confirm/prompt` 清零。
34
+
35
+ ### Fixed
36
+
37
+ - **md 编辑器**:点中块时不再跳行高;占位符获得焦点即隐;标题上下留白统一
38
+ (任务 #31)。
39
+
5
40
  ## [0.16.0] - 2026-11-04 占位成员可救可清 + 仓库页可读性 + 前后端分层解耦
6
41
 
7
42
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nx-kv",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "nx-kv — KV 清单管理中枢(远程)+ 本机本地任务队列(localtask):serve 驱动 CLI 与 Web 面板,同源同构,附带可安装 skill 供 agent 提交 / 领取 / 阻塞接收任务",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,137 @@
1
+ // 本地附件仓:**本地任务**(localtask 队列)的贴图/文件原件,落在本机磁盘上。
2
+ //
3
+ // 口径(任务 #15 验收反馈 2026-11-04):本地任务的贴图走本地存储,与云端图床
4
+ // **零关联**——上传不碰网、预览不透传、agent 领取直接读本地路径。云端清单
5
+ // (todo)的附件仍走云端图床(core/fileapi.js + core/attachstore.js 镜像),
6
+ // 两条链路互不依赖;只有「正文引用的附件 id」是同一套语法(modules/todo/attach.js)。
7
+ //
8
+ // 目录:~/.nx-kv/attachments/local/(NX_KV_ATTACHMENTS 可改根目录;与各
9
+ // 工作空间的数字目录天然不撞)。台账 manifest.json 形状与 attachstore 一致
10
+ // (files: fileId → {name,size,contentType,…}),复用同一套读写函数。
11
+ //
12
+ // 正文引用语法不变:`![名](fileId)`,fileId 同样是 32 位 hex——面板/解析端
13
+ // 不需要知道 id 背后是本地仓还是云端;读取时先查本地仓、未命中再走云端。
14
+ import { randomBytes } from 'node:crypto';
15
+ import fsp from 'node:fs/promises';
16
+ import { existsSync } from 'node:fs';
17
+ import { createReadStream } from 'node:fs';
18
+ import { dirname, join, parse as parsePath } from 'node:path';
19
+ import { attachmentsRootFromEnv } from './paths.js';
20
+ import { contentTypeOf } from './fileapi.js';
21
+ import {
22
+ localNameFor,
23
+ readManifest as readMirrorManifest,
24
+ safeFileName,
25
+ writeManifest as writeMirrorManifest,
26
+ } from './attachstore.js';
27
+
28
+ export const LOCAL_STORE_DIRNAME = 'local';
29
+
30
+ // 台账读写直接复用镜像那套(同形状:files 是 fileId → 记录)。groupId 字段
31
+ // 对本地仓没有意义(readManifest 缺省补 0),写入时不带即可。
32
+ const readManifest = (dir) => readMirrorManifest(dir);
33
+ const writeManifest = (dir, data) => writeMirrorManifest(dir, data);
34
+
35
+ /** 本地附件仓根目录(每个实例独立:NX_KV_ATTACHMENTS 覆盖根时测试天然隔离)。 */
36
+ export function localDir() {
37
+ return join(attachmentsRootFromEnv(), LOCAL_STORE_DIRNAME);
38
+ }
39
+
40
+ const nowIso = () => new Date().toISOString();
41
+
42
+ /**
43
+ * 新条目的 id:32 位 hex(16 字节随机)。与云端 fileId 同构——
44
+ * looksLikeFileId(modules/todo/attach.js)照常认,正文语法零分叉。
45
+ * 撞车概率 2^-128,先查本地仓再查云端的读取顺序天然消歧。
46
+ */
47
+ function newFileId() {
48
+ return randomBytes(16).toString('hex');
49
+ }
50
+
51
+ // 同目录不重名的落点(`名字 (2).ext`)——台账是唯一真相源,文件名只是给人看的。
52
+ function uniqueName(taken, name) {
53
+ if (!taken.has(name)) return name;
54
+ const { name: stem, ext } = parsePath(name);
55
+ let n = 1;
56
+ let candidate = name;
57
+ while (taken.has(candidate)) {
58
+ n += 1;
59
+ candidate = `${stem} (${n})${ext}`;
60
+ }
61
+ return candidate;
62
+ }
63
+
64
+ /**
65
+ * 上传一个附件到本地仓:落盘 + 记台账,返回元数据。
66
+ * 不碰网络——本机 serve 进程内一次落盘即完成。
67
+ *
68
+ * @param {{name: string, buffer: Buffer, contentType?: string}} p
69
+ * @returns {Promise<{fileId: string, name: string, size: number, contentType: string, path: string, storage: 'local'}>}
70
+ */
71
+ export async function localUpload({ name, buffer, contentType = '' } = {}) {
72
+ if (!Buffer.isBuffer(buffer) || !buffer.length) throw new Error('上传内容为空');
73
+ const base = safeFileName(name);
74
+ if (!base || base === 'file') throw new Error('上传需要文件名');
75
+ const dir = localDir();
76
+ const man = await readManifest(dir);
77
+ const id = newFileId();
78
+ // 落盘文件名人读优先(嵌入的说明文字一路跟过来),缺扩展名按 contentType 补
79
+ const fileName = uniqueName(
80
+ new Set(Object.values(man.files).map((r) => String(r?.name || ''))),
81
+ localNameFor(id, base, contentType || contentTypeOf(base))
82
+ );
83
+ const abs = join(dir, fileName);
84
+ await fsp.mkdir(dirname(abs), { recursive: true });
85
+ await fsp.writeFile(abs, buffer);
86
+ const ct = contentType || contentTypeOf(fileName);
87
+ man.files[id] = { name: fileName, size: buffer.length, contentType: ct, createdAt: nowIso() };
88
+ man.updatedAt = nowIso();
89
+ await writeManifest(dir, { ...man, dir });
90
+ return { fileId: id, name: fileName, size: buffer.length, contentType: ct, path: abs, storage: 'local' };
91
+ }
92
+
93
+ /** 台账 + 落盘文件都在 → 返回本地元数据;没传过或文件被手删 → null。 */
94
+ export async function localResolve(fileId) {
95
+ const id = String(fileId ?? '').trim();
96
+ if (!id) return null;
97
+ const dir = localDir();
98
+ const man = await readManifest(dir);
99
+ const rec = man.files[id];
100
+ if (!rec || !rec.name) return null;
101
+ const abs = join(dir, String(rec.name));
102
+ if (!existsSync(abs)) return null;
103
+ return {
104
+ fileId: id,
105
+ path: abs,
106
+ name: String(rec.name),
107
+ size: Number(rec.size) || 0,
108
+ contentType: String(rec.contentType || ''),
109
+ storage: 'local',
110
+ };
111
+ }
112
+
113
+ /** 本地仓全部在盘条目(id → 元数据),供领取侧批量标注。 */
114
+ export async function localIndex() {
115
+ const dir = localDir();
116
+ const man = await readManifest(dir);
117
+ const index = new Map();
118
+ for (const [id, rec] of Object.entries(man.files)) {
119
+ const abs = join(dir, String(rec?.name || ''));
120
+ if (rec?.name && existsSync(abs)) {
121
+ index.set(id, {
122
+ fileId: id,
123
+ path: abs,
124
+ name: String(rec.name),
125
+ size: Number(rec.size) || 0,
126
+ contentType: String(rec.contentType || ''),
127
+ storage: 'local',
128
+ });
129
+ }
130
+ }
131
+ return { dir, index };
132
+ }
133
+
134
+ /** 本地文件读流(HTTP 透传给面板 <img> 用);调用方先 localResolve 确认在盘。 */
135
+ export function localReadStream(absPath) {
136
+ return createReadStream(absPath);
137
+ }
@@ -50,6 +50,23 @@ export function sanitizeDocRef(raw) {
50
50
  }
51
51
  }
52
52
 
53
+ /**
54
+ * DocRef + 链接级注解(note/tag)合成列表条目。
55
+ *
56
+ * 为什么收在 DocRef 模块:docLinks 条目 = DocRef + 注解,而注解要过三道关
57
+ * (写入口合并 / 读归一化回贴 / 导出保留),三处口径必须一致——
58
+ * 实现只此一份,docs/refs 与 repos/service、transfer 都从这里拿。
59
+ * 空串/缺省 = 不落字段(对已有条目即「清掉」)。
60
+ */
61
+ export function withLinkMeta(ref, raw = {}) {
62
+ const note = typeof raw.note === 'string' ? raw.note.trim() : '';
63
+ const tag = typeof raw.tag === 'string' ? raw.tag.trim() : '';
64
+ const entry = { ...ref };
65
+ if (note) entry.note = note;
66
+ if (tag) entry.tag = tag;
67
+ return entry;
68
+ }
69
+
53
70
  // ─── docId(前后端统一寻址)────────────────────────────────────────────
54
71
  //
55
72
  // 自有:rel:<相对路径>(裸相对路径视为自有——人类可以直接给路径)
@@ -207,7 +207,9 @@ const renderNoteRef = (d) =>
207
207
  const renderLink = (d) => {
208
208
  if (d.removed !== undefined) return `已移除组绑定: ${d.removed}(剩 ${d.count} 条)`;
209
209
  const lines = [`「${d.boardName}」的组绑定(${d.count} 条):`];
210
- for (const l of d.links) lines.push(` ${l.rel ? 'rel:' + l.rel : 'path:' + l.path}${l.note ? ` · ${l.note}` : ''}`);
210
+ for (const l of d.links) {
211
+ lines.push(` ${l.rel ? 'rel:' + l.rel : 'path:' + l.path}${l.tag ? ` [${l.tag}]` : ''}${l.note ? ` · ${l.note}` : ''}`);
212
+ }
211
213
  return lines.join('\n');
212
214
  };
213
215
 
@@ -331,11 +333,12 @@ export default {
331
333
  id: 'docs.link',
332
334
  cli: ['docs', 'link'],
333
335
  http: ['POST', '/api/docs/link'],
334
- summary: '维护组的附加文档列表(--doc <自有路径|绝对路径> + --note;--remove <序号> 移除;--before <序号> 插位)',
336
+ summary: '维护组的附加文档列表(--doc <自有路径|绝对路径> + --note/--tag;--remove <序号> 移除;--before <序号> 插位;同 doc 重复添加 = 更新)',
335
337
  args: ['board'],
336
338
  flags: {
337
339
  doc: { type: 'string', hint: '自有树路径 = 自有文档;盘符/绝对路径 = 外部只读引用' },
338
340
  note: { type: 'string', hint: '条目说明' },
341
+ tag: { type: 'string', hint: '分类标签(如 qa;简介行渲染成小 chip;空串 = 清掉)' },
339
342
  remove: { type: 'number', hint: '移除第 N 条(1 起)' },
340
343
  before: { type: 'number', hint: '插到第 N 条之前(缺省追加到末尾)' },
341
344
  },
@@ -125,8 +125,9 @@ export async function repoKnowledge(locator, { md = false } = {}) {
125
125
  const docLinks = [];
126
126
  const externalDirs = [];
127
127
  for (const l of Array.isArray(b.docLinks) ? b.docLinks : []) {
128
+ const meta = l.tag ? { tag: l.tag } : {};
128
129
  if (l.rel) {
129
- docLinks.push({ note: l.note || '', ...(await readOwn(l.rel, { warnings, label: `组绑定·${l.rel}` })) });
130
+ docLinks.push({ note: l.note || '', ...meta, ...(await readOwn(l.rel, { warnings, label: `组绑定·${l.rel}` })) });
130
131
  continue;
131
132
  }
132
133
  const abs = l.path && store.resolveExternal(l.path, closure);
@@ -136,11 +137,11 @@ export async function repoKnowledge(locator, { md = false } = {}) {
136
137
  }
137
138
  const st = await import('node:fs/promises').then((m) => m.stat(abs).catch(() => null));
138
139
  if (!st) {
139
- docLinks.push({ note: l.note || '', missing: true, docId: paths.extDocId(abs) });
140
+ docLinks.push({ note: l.note || '', ...meta, missing: true, docId: paths.extDocId(abs) });
140
141
  } else if (st.isDirectory()) {
141
- externalDirs.push({ path: abs, note: l.note || '', files: await listDirMd(abs) });
142
+ externalDirs.push({ path: abs, note: l.note || '', ...meta, files: await listDirMd(abs) });
142
143
  } else {
143
- docLinks.push({ note: l.note || '', ...(await readExternalDoc(abs, { warnings, label: `组绑定·${l.path}` })) });
144
+ docLinks.push({ note: l.note || '', ...meta, ...(await readExternalDoc(abs, { warnings, label: `组绑定·${l.path}` })) });
144
145
  }
145
146
  }
146
147
  boards.push({ name: b.name, key: bkey, intro: bIntro, note, docLinks, externalDirs });
@@ -250,16 +251,16 @@ export async function groupContextFor(cwd) {
250
251
  );
251
252
  peers.push({ name: mname, note: peerNote.body || (b.repoNotes || {})[mkey] || '' });
252
253
  }
253
- // 组绑定参考文件(给可读路径——agent 直接读盘)
254
+ // 组绑定参考文件(给可读路径——agent 直接读盘);tag 随条目走(有才带)
254
255
  const docLinks = [];
255
256
  for (const l of Array.isArray(b.docLinks) ? b.docLinks : []) {
256
257
  if (l.rel) {
257
- docLinks.push({ rel: l.rel, abs: join(paths.docsRoot(), l.rel), note: l.note || '' });
258
+ docLinks.push({ rel: l.rel, abs: join(paths.docsRoot(), l.rel), note: l.note || '', ...(l.tag ? { tag: l.tag } : {}) });
258
259
  continue;
259
260
  }
260
261
  const abs = l.path && store.resolveExternal(l.path, closure);
261
262
  if (!abs) continue;
262
- docLinks.push({ path: abs, abs, note: l.note || '' });
263
+ docLinks.push({ path: abs, abs, note: l.note || '', ...(l.tag ? { tag: l.tag } : {}) });
263
264
  }
264
265
  groups.push({
265
266
  name: b.name,
@@ -10,6 +10,7 @@ import * as paths from './paths.js';
10
10
  import { normalizeKey } from '../../core/keys.js';
11
11
  import { reposBridge } from './repos-bridge.js';
12
12
  import { badInput, notFound, conflict } from '../../core/errors.js';
13
+ import { withLinkMeta } from './docref.js';
13
14
 
14
15
  // 看板定位(id / key / 名称):boardKeyOf 由 bridge 注入(key 口径必须与 repos 完全一致)
15
16
  function findBoardIn(s, idOrKey, { normalizeKey: nk, boardKeyOf }) {
@@ -95,10 +96,13 @@ export async function setNoteDocRef({ board, repo, rel, path, reset } = {}) {
95
96
  * 维护组的 docLinks。
96
97
  * doc --doc <rel|绝对路径>(盘符/绝对 → 外部,否则自有)
97
98
  * note 条目说明
99
+ * tag 分类标签(如 qa——简介行的补充链接会渲染成小 chip;传空串=清掉)
98
100
  * remove --remove <序号>(1 起)
99
101
  * before --before <序号>(插到该条之前;缺省追加)
102
+ *
103
+ * 同 rel/path 重复添加 = 更新该条(note/tag 以本次为准)——改标签就是重加一遍。
100
104
  */
101
- export async function setDocLink({ board, doc, note, remove, before } = {}) {
105
+ export async function setDocLink({ board, doc, note, tag, remove, before } = {}) {
102
106
  if (!board) throw badInput('缺少看板(id / key / 名称)');
103
107
  const { mutateStore, boardKeyOf } = await reposBridge();
104
108
  return mutateStore((s) => {
@@ -124,7 +128,8 @@ export async function setDocLink({ board, doc, note, remove, before } = {}) {
124
128
  ? { path: String(doc).trim() }
125
129
  : { rel: String(doc || '') }
126
130
  );
127
- const entry = note ? { ...ref, note: String(note).trim() } : ref;
131
+ // 条目 = DocRef + 注解(note/tag);合成逻辑收在 docref.withLinkMeta
132
+ const entry = withLinkMeta(ref, { note, tag });
128
133
  const at = b.docLinks.findIndex((x) => (x.rel || '') === (entry.rel || '') && (x.path || '') === (entry.path || ''));
129
134
  if (at >= 0) b.docLinks.splice(at, 1);
130
135
  const want = before !== undefined ? idxOf(before) : -1;
@@ -162,18 +167,26 @@ export async function listRepoDocs(locator) {
162
167
  };
163
168
  }
164
169
 
165
- /** 往项目的介绍文档集里加一篇(rel / path / ref+rel 三选一) */
170
+ /** 往项目的介绍文档集里加一篇(rel / path / ref+rel 三选一;note/tag 可选注解)。
171
+ *
172
+ * 同 rel/path 重复添加 = 更新该条(与组级 setDocLink 同一语义)——
173
+ * 此前是盲目 push:改个说明就得先 remove 再 add,还顺手丢掉 note
174
+ * (assertDocRef 只认 rel/path,注解要在这里显式贴回去)。
175
+ */
166
176
  export async function addRepoDoc(locator, raw) {
167
177
  const { getRepo, deriveKey, mutateStore } = await reposBridge();
168
178
  const repo = await getRepo(locator);
169
179
  const docRef = paths.assertDocRef(raw);
180
+ const entry = withLinkMeta(docRef, { note: raw && raw.note, tag: raw && raw.tag });
170
181
  return mutateStore((s) => {
171
182
  const r = s.repos.find((x) => deriveKey(x) === deriveKey(repo));
172
183
  if (!r) throw notFound('repo 不存在: ' + locator);
173
184
  if (!Array.isArray(r.docLinks)) r.docLinks = [];
174
- r.docLinks.push(docRef);
185
+ const at = r.docLinks.findIndex((x) => (x.rel || '') === (entry.rel || '') && (x.path || '') === (entry.path || ''));
186
+ if (at >= 0) r.docLinks.splice(at, 1);
187
+ r.docLinks.push(entry);
175
188
  r.updatedAt = new Date().toISOString();
176
- return { key: deriveKey(r), name: r.name, added: docRef, count: r.docLinks.length };
189
+ return { key: deriveKey(r), name: r.name, added: entry, count: r.docLinks.length };
177
190
  });
178
191
  }
179
192
 
@@ -1,8 +1,11 @@
1
- // 附件模块(= 后端图床的用户空间视图)。
1
+ // 附件模块(双后端:云端图床的用户空间视图 + 本机附件仓)。
2
2
  //
3
- // 不声明 `resource`:这里的「集合」是后端图床的投影,`upload/sync` 是它的动作,
4
- // 硬凑 get/update/remove 五动词只会得到语义别扭的命令。
3
+ // 不声明 `resource`:这里的「集合」是附件存储的投影(云端图床 / 本机仓),
4
+ // `upload/sync` 是它的动作,硬凑 get/update/remove 五动词只会得到语义别扭的命令。
5
5
  // 命令与路由仍然一一对应,所以「面板上能做的,CLI 都能做」依旧成立。
6
+ //
7
+ // 两条链路的分工(任务 #15 口径):云端清单(todo)附件走图床;
8
+ // 本地任务(localtask)贴图走 --local 本机仓——不碰网、零云端依赖。
6
9
  import * as service from './service.js';
7
10
 
8
11
  // 所有动作都接受 --group 覆盖当前工作空间;默认取本机配置里的当前值
@@ -33,13 +36,14 @@ export default {
33
36
  id: 'file.upload',
34
37
  cli: ['file', 'upload'],
35
38
  http: ['POST', '/api/file'],
36
- summary: '上传附件到当前用户空间(--path 本机文件;面板走 --content base64 --name 文件名)',
39
+ summary: '上传附件(--local 存本机附件仓,本地任务贴图用;默认传当前用户空间的云端图床)',
37
40
  flags: {
38
41
  path: { type: 'string', hint: '本机文件路径' },
39
42
  content: { type: 'string', hint: 'base64 内容(面板上传用,可为 data URL)' },
40
43
  name: { type: 'string', hint: '文件名(content 上传时必填)' },
41
44
  contentType: { type: 'string', hint: '覆盖 Content-Type(默认按扩展名猜)' },
42
- accessLevel: { type: 'string', enum: ['public', 'private', 'protected'], default: 'public', hint: '出图权限;public 才能被 <img> 直接预览' },
45
+ local: { type: 'boolean', hint: '存本机附件仓(~/.nx-kv/attachments/local/),不碰云端' },
46
+ accessLevel: { type: 'string', enum: ['public', 'private', 'protected'], default: 'public', hint: '出图权限;public 才能被 <img> 直接预览(--local 时无意义)' },
43
47
  group: GROUP,
44
48
  },
45
49
  run: (ctx) => service.uploadFileAction(ctx),
@@ -1,8 +1,9 @@
1
- // 附件模块:用户空间里的图片 / 文档等二进制原件。
2
- //
3
- // 后端是 dev_ctr_hello 的图床(/api/v1/files*,上传 multipart,出图 /files/:fileId)。
4
- // 本模块只做「业务编排 + 人读渲染」,网络细节全在 core/fileapi.js,
5
- // 本地镜像细节全在 core/attachstore.js。
1
+ // 附件模块:用户空间里的图片 / 文档等二进制原件,**双后端**:
2
+ // - 云端图床(dev_ctr_hello /api/v1/files*,上传 multipart,出图 /files/:fileId)
3
+ // —— 云端清单(todo)的附件走这边,本地镜像细节在 core/attachstore.js;
4
+ // - 本地附件仓(core/localatt.js)—— 本地任务(localtask)的贴图走这边,
5
+ // 不碰网:上传落本机盘、预览读本机文件、agent 直接拿本地路径。
6
+ // 本模块只做「业务编排 + 人读渲染」,网络细节全在 core/fileapi.js。
6
7
  //
7
8
  // 与 todo 的分工:附件**内容**归这里;附件**怎么被任务引用**归 todo
8
9
  // (任务正文里的 ![说明](fileId),见 modules/todo/attach.js)。
@@ -16,6 +17,7 @@ import {
16
17
  accessUrlOf, contentTypeOf, deleteFile, fileInfo, fileUpload, isImageName, listFiles, openFileStream,
17
18
  } from '../../core/fileapi.js';
18
19
  import { localDirFor, localIndex, resolveLocal, syncAttachments } from '../../core/attachstore.js';
20
+ import { localDir, localReadStream, localResolve, localUpload } from '../../core/localatt.js';
19
21
 
20
22
  const fmtBytes = (n) => {
21
23
  const v = Number(n) || 0;
@@ -65,8 +67,7 @@ export async function listFilesAction({ group, limit = 100, offset = 0 } = {}) {
65
67
  }
66
68
 
67
69
  export async function uploadFileAction(patch = {}) {
68
- const { path: localPath, content, name, contentType, group, accessLevel = 'public' } = patch;
69
- const gid = await resolveGroupId(group);
70
+ const { path: localPath, content, name, contentType, group, accessLevel = 'public', local: toLocal } = patch;
70
71
  let buffer;
71
72
  let fileName;
72
73
 
@@ -86,6 +87,26 @@ export async function uploadFileAction(patch = {}) {
86
87
  throw badInput('上传需要 --path <本机文件>,或 --content <base64> --name <文件名>');
87
88
  }
88
89
 
90
+ // 本地任务贴图 → 本地附件仓:不碰网、不解析工作空间(group 是云端图床的归属维度),
91
+ // 上传即已在本地,agent 直接读 localPath——这条链路与云端零关联。
92
+ if (toLocal) {
93
+ const rec = await localUpload({ name: fileName, buffer, contentType: contentType || contentTypeOf(fileName) });
94
+ return {
95
+ fileId: rec.fileId,
96
+ name: rec.name,
97
+ size: rec.size,
98
+ contentType: rec.contentType,
99
+ url: `/api/file/content/${rec.fileId}`,
100
+ isImage: isImageName(rec.name, rec.contentType),
101
+ localPath: rec.path,
102
+ localDir: localDir(),
103
+ storage: 'local',
104
+ markdown: snippetOf(rec.name, rec.fileId),
105
+ };
106
+ }
107
+
108
+ const cfg = await loadConfig();
109
+ const gid = await resolveGroupId(group);
89
110
  const meta = await fileUpload({
90
111
  name: fileName,
91
112
  buffer,
@@ -93,18 +114,33 @@ export async function uploadFileAction(patch = {}) {
93
114
  groupId: gid,
94
115
  accessLevel,
95
116
  });
96
- const cfg = await loadConfig();
97
117
  return {
98
118
  ...meta,
99
119
  url: accessUrlOf(cfg.baseUrl, meta.fileId),
100
120
  isImage: isImageName(fileName, meta.contentType),
101
121
  localDir: localDirFor(gid, cfg.attachmentsDir),
122
+ storage: 'cloud',
102
123
  markdown: snippetOf(fileName, meta.fileId),
103
124
  };
104
125
  }
105
126
 
106
127
  export async function getFileAction(fileId, { group } = {}) {
107
128
  if (!fileId) throw badInput('缺少 fileId');
129
+ // 本地仓命中就不问云端:本地任务贴图的查询零网络、离线可用。
130
+ // 两套 id 同构(32 位 hex),先本地后云端的顺序天然消歧。
131
+ const hit = await localResolve(fileId);
132
+ if (hit) {
133
+ return {
134
+ fileId: hit.fileId,
135
+ name: hit.name,
136
+ size: hit.size,
137
+ contentType: hit.contentType,
138
+ url: `/api/file/content/${hit.fileId}`,
139
+ isImage: isImageName(hit.name, hit.contentType),
140
+ localPath: hit.path,
141
+ storage: 'local',
142
+ };
143
+ }
108
144
  const cfg = await loadConfig();
109
145
  const gid = await resolveGroupId(group);
110
146
  const meta = await fileInfo(fileId, gid);
@@ -115,6 +151,7 @@ export async function getFileAction(fileId, { group } = {}) {
115
151
  isImage: isImageName(meta.fileId, meta.contentType),
116
152
  localPath: local?.path || '',
117
153
  localName: local?.name || '',
154
+ storage: 'cloud',
118
155
  };
119
156
  }
120
157
 
@@ -138,10 +175,24 @@ export async function syncFilesAction({ group, ids, dir, prune, dryRun } = {}) {
138
175
  return { ...result, usedDir: localDirFor(gid, dir || cfg.attachmentsDir) };
139
176
  }
140
177
 
141
- /** HTTP 原始流:把后端的出图内容原样透给浏览器(面板里 <img src> 直接用)。 */
178
+ /** HTTP 原始流:附件本体直接透给浏览器(面板里 <img src> 直接用)。
179
+ * 本地仓命中 → 读本机文件(离线可预览,本地任务贴图不依赖云端);
180
+ * 未命中 → 云端出图原样透传(云端清单附件的老路径,行为不变)。 */
142
181
  export async function serveContent(ctx) {
143
182
  const { res } = ctx.__raw || {};
144
183
  if (!res) throw badInput('内部错误:缺少响应对象');
184
+ const hit = await localResolve(ctx.fileId);
185
+ if (hit) {
186
+ res.writeHead(200, { 'content-type': hit.contentType || 'application/octet-stream', 'cache-control': 'no-store' });
187
+ await new Promise((done, fail) => {
188
+ const rs = localReadStream(hit.path);
189
+ rs.once('error', fail);
190
+ res.once('close', done);
191
+ res.once('finish', done);
192
+ rs.pipe(res);
193
+ });
194
+ return;
195
+ }
145
196
  const up = await openFileStream(ctx.fileId);
146
197
  const ct = up.headers.get('content-type') || 'application/octet-stream';
147
198
  res.writeHead(200, { 'content-type': ct, 'cache-control': 'no-store' });
@@ -167,6 +218,14 @@ export function renderFileList(d) {
167
218
  }
168
219
 
169
220
  export function renderUpload(d) {
221
+ if (d.storage === 'local') {
222
+ return [
223
+ `已上传 ${d.fileId}(${fmtBytes(d.size)} · ${d.contentType})→ 本机附件仓`,
224
+ `本地: ${d.localPath}`,
225
+ `贴进任务正文(编辑时也可直接选图上传):`,
226
+ ` ${d.isImage ? d.markdown.image : d.markdown.file}`,
227
+ ].join('\n');
228
+ }
170
229
  return [
171
230
  `已上传 ${d.fileId}(${fmtBytes(d.size)} · ${d.contentType})`,
172
231
  `地址: ${d.url}`,
@@ -177,6 +236,13 @@ export function renderUpload(d) {
177
236
  }
178
237
 
179
238
  export function renderFileInfo(d) {
239
+ if (d.storage === 'local') {
240
+ return [
241
+ `${d.fileId} · ${d.isImage ? '图片' : '文件'} · ${fmtBytes(d.size)} · ${d.contentType}`,
242
+ `地址: ${d.url}`,
243
+ `本地: ${d.localPath}(本机附件仓,不依赖云端)`,
244
+ ].join('\n');
245
+ }
180
246
  return [
181
247
  `${d.fileId} · ${d.isImage ? '图片' : '文件'} · ${fmtBytes(d.size)} · ${d.contentType}`,
182
248
  `地址: ${d.url}`,
@@ -355,21 +355,20 @@ export default {
355
355
  : ''),
356
356
  },
357
357
  {
358
- // 附件追加(与云端 todo attach 同构):上传/复用 fileId → markdown 嵌入正文末尾。
359
- // 面板编辑器贴的是同一条语法(components/md/attachments.js),两端渲染一致。
358
+ // 附件追加(与云端 todo attach 同构):--path 落**本机附件仓**(零云端)→ 嵌入正文末尾;
359
+ // --fileId 引用已有附件(本机仓 / 云端 id)。面板编辑器贴同一条语法(components/md/attachments.js)。
360
360
  id: 'localtask.attach',
361
361
  cli: ['localtask', 'attach'],
362
362
  http: ['POST', '/api/localtasks/attach'],
363
- summary: '给任务追加附件:--path 上传本机文件,或 --fileId 追加已上传附件,以 markdown 嵌入正文末尾(不替换正文)',
363
+ summary: '给任务追加附件:--path 存本机附件仓(不碰云端),或 --fileId 引用已有附件,以 markdown 嵌入正文末尾(不替换正文)',
364
364
  flags: {
365
365
  id: { type: 'number', required: true, hint: '任务编号' },
366
366
  cwd: CWD,
367
367
  key: KEY,
368
- path: { type: 'string', hint: '本机文件路径(先上传再嵌入)' },
369
- fileId: { type: 'string', hint: '已上传附件的 id(32 位 hex,可带扩展名)' },
368
+ path: { type: 'string', hint: '本机文件路径(落 ~/.nx-kv/attachments/local/ 再嵌入)' },
369
+ fileId: { type: 'string', hint: '已有附件的 id(32 位 hex,可带扩展名;本机仓或云端)' },
370
370
  name: { type: 'string', hint: '嵌入文字与本地文件名(默认取原文件名)' },
371
371
  kind: { type: 'string', hint: 'image | file(不给按文件名/扩展名判)' },
372
- accessLevel: { type: 'string', hint: 'public | private | protected(默认 public;仅 --path 上传时生效)' },
373
372
  },
374
373
  run: async (ctx) => {
375
374
  const { attachToTask } = await import('../tasktool/attach.js');
@@ -381,7 +380,6 @@ export default {
381
380
  fileId: ctx.fileId,
382
381
  name: ctx.name,
383
382
  kind: ctx.kind,
384
- accessLevel: ctx.accessLevel,
385
383
  });
386
384
  },
387
385
  render: (d) =>
@@ -272,7 +272,7 @@ export default {
272
272
  args: ['id'],
273
273
  run: (ctx) => listRepoDocs(ctx.id),
274
274
  render: (d) => {
275
- const one = (x, i) => ` ${i}. ${x.rel ? '自有 ' + x.rel : x.ref ? 'ref ' + x.ref + '/' + x.rel : '外部 ' + x.path}${x.note ? ' #' + x.note : ''}`;
275
+ const one = (x, i) => ` ${i}. ${x.rel ? '自有 ' + x.rel : x.ref ? 'ref ' + x.ref + '/' + x.rel : '外部 ' + x.path}${x.tag ? ` [${x.tag}]` : ''}${x.note ? ` # ${x.note}` : ''}`;
276
276
  const lines = [`【${d.repo.name}】介绍文档 ${d.count} 篇`];
277
277
  if (d.main) lines.push(one(d.main, 1));
278
278
  d.links.forEach((x, i) => lines.push(one(x, i + 2)));
@@ -283,7 +283,7 @@ export default {
283
283
  id: 'repo.doc.add',
284
284
  cli: ['repo', 'doc', 'add'],
285
285
  http: ['POST', '/api/repos/docs'],
286
- summary: '给项目加一篇介绍文档:--rel 自有树路径 / --path 外部绝对路径 / --ref <参考仓库key> --file <仓库内相对路径>',
286
+ summary: '给项目加一篇介绍文档:--rel 自有树路径 / --path 外部绝对路径 / --ref <参考仓库key> --file <仓库内相对路径>(同 rel/path 重复添加 = 更新)',
287
287
  args: ['id'],
288
288
  flags: {
289
289
  rel: { type: 'string', hint: '自有树内相对路径(如 repo/github.com/me/x.md)' },
@@ -291,8 +291,9 @@ export default {
291
291
  ref: { type: 'string', hint: '参考仓库 key(type=ref 的本机仓库)' },
292
292
  file: { type: 'string', hint: '参考仓库内的相对路径(配合 --ref)' },
293
293
  note: { type: 'string', hint: '这篇的说明' },
294
+ tag: { type: 'string', hint: '分类标签(如 qa;简介行渲染成小 chip;空串 = 清掉)' },
294
295
  },
295
- run: (ctx) => addRepoDoc(ctx.id, { rel: ctx.rel, path: ctx.path, ref: ctx.ref, file: ctx.file, note: ctx.note }),
296
+ run: (ctx) => addRepoDoc(ctx.id, { rel: ctx.rel, path: ctx.path, ref: ctx.ref, file: ctx.file, note: ctx.note, tag: ctx.tag }),
296
297
  render: (d) => `已加一篇介绍文档(共 ${d.count} 篇)`,
297
298
  },
298
299
  {
@@ -612,7 +613,7 @@ export default {
612
613
  lines.push(` ◇ 组「${b.name}」`);
613
614
  if (b.intro && !b.intro.missing) lines.push(` 组介绍: ${b.intro.docId}`);
614
615
  if (b.note && !b.note.missing) lines.push(` 组内描述: ${b.note.docId}`);
615
- for (const l of b.docLinks) lines.push(` 绑定: ${l.docId}${l.note ? ` · ${l.note}` : ''}${l.missing ? '(缺失)' : ''}`);
616
+ for (const l of b.docLinks) lines.push(` 绑定: ${l.docId}${l.tag ? ` [${l.tag}]` : ''}${l.note ? ` · ${l.note}` : ''}${l.missing ? '(缺失)' : ''}`);
616
617
  for (const x of b.externalDirs) lines.push(` 外部目录: ${x.path}(${x.files.length} 个文档,按需读盘)`);
617
618
  }
618
619
  if (d.refs.length) {
@@ -14,7 +14,7 @@ import fsp from 'node:fs/promises';
14
14
  import { APP_DIR } from '../../core/paths.js';
15
15
  import { openPath } from '../../core/open.js';
16
16
  import { badInput, notFound, conflict } from '../../core/errors.js';
17
- import { sanitizeDocRef } from '../docs/docref.js';
17
+ import { sanitizeDocRef, withLinkMeta } from '../docs/docref.js';
18
18
  import { sanitizeGetLoop } from '../tasktool/getloop.js';
19
19
 
20
20
  // ─── 存储(NX_KV_REPOS_STORE 可覆盖;测试隔离)──────────────────────────
@@ -94,8 +94,7 @@ function normalizeDocRefs(store) {
94
94
  .map((l) => {
95
95
  const d = sanitizeDocRef(l);
96
96
  if (!d) return null;
97
- const note = l && typeof l.note === 'string' ? l.note.trim() : '';
98
- return note ? { ...d, note } : d;
97
+ return withLinkMeta(d, l);
99
98
  })
100
99
  .filter(Boolean);
101
100
  if (links.length) r.docLinks = links;
@@ -109,8 +108,7 @@ function normalizeDocRefs(store) {
109
108
  .map((l) => {
110
109
  const d = sanitizeDocRef(l);
111
110
  if (!d) return null;
112
- const note = l && typeof l.note === 'string' ? l.note.trim() : '';
113
- return note ? { ...d, note } : d;
111
+ return withLinkMeta(d, l);
114
112
  })
115
113
  .filter(Boolean);
116
114
  if (links.length) b.docLinks = links;