nx-kv 0.8.4 → 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 +106 -0
- package/README.md +47 -2
- package/assets/kv-submit/SKILL.md +12 -4
- package/assets/kv-submit/references/submit-workflow.md +9 -2
- package/assets/kv-waittask/SKILL.md +23 -1
- package/assets/kv-waittask/references/kv-waittask-workflow.md +26 -4
- 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/localtask/index.js +44 -21
- package/src/modules/localtask/service.js +238 -46
- package/src/modules/localtask/view.jsx +72 -16
- 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-D70_R4vQ.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-2OK9SG2y.js → index-BVbLNACS.js} +2 -2
- package/src/web/public/assets/{view-C5ictDJi.js → view-BBdEL07x.js} +1 -1
- package/src/web/public/assets/view-BlGIceud.js +3 -0
- package/src/web/public/assets/view-CwFWDigR.js +1 -0
- package/src/web/public/assets/{view-gzBv_kjb.js → view-DUy79STG.js} +1 -1
- package/src/web/public/index.html +2 -2
- package/src/modules/localtask/popup.js +0 -285
- package/src/web/public/assets/view-DxwcTgBZ.js +0 -1
- package/src/web/public/assets/view-l_lnGEBC.js +0 -2
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// 后端文件(图床)客户端 —— 对接 dev_ctr_hello 的 /api/v1/files* 与 /files/:fileId。
|
|
2
|
+
//
|
|
3
|
+
// 与 kvapi.js 共用同一套信封判定:**HTTP 恒为 200,成败看 body 里的 `code`**(0 = 成功),
|
|
4
|
+
// 所以这里复用 kvapi 导出的 call(),不另写一份解析。
|
|
5
|
+
//
|
|
6
|
+
// 上传走 multipart(后端 r.GetUploadFile("file")),其余走 JSON。
|
|
7
|
+
//
|
|
8
|
+
// fileId 是后端生成的不透明串:16 字节随机 → 32 位 hex;原文件名带扩展名时
|
|
9
|
+
// 后端会在 id 后再接 `.<ext>`(service/file/file.go 的 newFileId + "." + newExt),
|
|
10
|
+
// 所以 id 偶尔带后缀。判定「像不像 fileId」一律走 attach.js 的 looksLikeFileId。
|
|
11
|
+
import { badInput, external, notFound } from './errors.js';
|
|
12
|
+
import { loadConfig, normalizeGroupId } from './config.js';
|
|
13
|
+
import { call } from './kvapi.js';
|
|
14
|
+
|
|
15
|
+
const TIMEOUT_MS = 60000;
|
|
16
|
+
|
|
17
|
+
// 扩展名 → Content-Type。上传时按文件名猜:一律 octet-stream 会让浏览器
|
|
18
|
+
// 拿到 application/octet-stream 而不内联渲染,图片就「预览不出来」了。
|
|
19
|
+
const MIME = {
|
|
20
|
+
'.png': 'image/png',
|
|
21
|
+
'.jpg': 'image/jpeg',
|
|
22
|
+
'.jpeg': 'image/jpeg',
|
|
23
|
+
'.webp': 'image/webp',
|
|
24
|
+
'.gif': 'image/gif',
|
|
25
|
+
'.bmp': 'image/bmp',
|
|
26
|
+
'.avif': 'image/avif',
|
|
27
|
+
'.svg': 'image/svg+xml',
|
|
28
|
+
'.ico': 'image/x-icon',
|
|
29
|
+
'.pdf': 'application/pdf',
|
|
30
|
+
'.doc': 'application/msword',
|
|
31
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
32
|
+
'.xls': 'application/vnd.ms-excel',
|
|
33
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
34
|
+
'.ppt': 'application/vnd.ms-powerpoint',
|
|
35
|
+
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
36
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
37
|
+
'.md': 'text/markdown; charset=utf-8',
|
|
38
|
+
'.csv': 'text/csv; charset=utf-8',
|
|
39
|
+
'.json': 'application/json; charset=utf-8',
|
|
40
|
+
'.zip': 'application/zip',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.avif', '.svg', '.ico']);
|
|
44
|
+
|
|
45
|
+
const extOf = (name) => {
|
|
46
|
+
const m = /(\.[A-Za-z0-9]{1,8})$/.exec(String(name || ''));
|
|
47
|
+
return m ? m[1].toLowerCase() : '';
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export function contentTypeOf(name, fallback = 'application/octet-stream') {
|
|
51
|
+
return MIME[extOf(name)] || fallback;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isImageName(name, contentType = '') {
|
|
55
|
+
const e = extOf(name);
|
|
56
|
+
if (e) return IMAGE_EXT.has(e);
|
|
57
|
+
return /^image\//i.test(String(contentType));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function normalizeFileMeta(d = {}) {
|
|
61
|
+
return {
|
|
62
|
+
fileId: String(d.fileId ?? ''),
|
|
63
|
+
url: String(d.url ?? ''),
|
|
64
|
+
accessLevel: String(d.accessLevel ?? ''),
|
|
65
|
+
size: Number(d.size) || 0,
|
|
66
|
+
contentType: String(d.contentType ?? ''),
|
|
67
|
+
groupId: Number(d.groupId) || 0,
|
|
68
|
+
groupName: String(d.groupName ?? ''),
|
|
69
|
+
sha256: String(d.sha256 ?? ''),
|
|
70
|
+
md5: String(d.md5 ?? ''),
|
|
71
|
+
tags: Array.isArray(d.tags) ? d.tags.map(String) : [],
|
|
72
|
+
createdAt: String(d.createdAt ?? ''),
|
|
73
|
+
expireAt: String(d.expireAt ?? ''),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const gidQuery = (groupId) => {
|
|
78
|
+
const n = normalizeGroupId(groupId);
|
|
79
|
+
return n === undefined ? '' : `?groupId=${n}`;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** 出图地址:一律**自己拼**,不用后端回的 url。
|
|
83
|
+
* 后端 url 按请求 Host 推断(可能被反代写成内网地址),自己拼才跟 baseUrl 一致,
|
|
84
|
+
* 也才在假后端 / 换域名时都可预期。 */
|
|
85
|
+
export function accessUrlOf(baseUrl, fileId) {
|
|
86
|
+
return String(baseUrl || '').replace(/\/$/, '') + '/files/' + encodeURIComponent(fileId);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function fileAccessUrl(fileId) {
|
|
90
|
+
const cfg = await loadConfig();
|
|
91
|
+
// fileId 可能带扩展名(xxx.docx);encodeURIComponent 不会编码 `.`,安全
|
|
92
|
+
return accessUrlOf(cfg.baseUrl, fileId);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---- 上传(multipart)----
|
|
96
|
+
|
|
97
|
+
async function parseEnvelope(res, ctxLabel) {
|
|
98
|
+
let envelope;
|
|
99
|
+
try {
|
|
100
|
+
envelope = JSON.parse(await res.text());
|
|
101
|
+
} catch {
|
|
102
|
+
throw external(`后端返回了非 JSON(HTTP ${res.status}): ${ctxLabel}`);
|
|
103
|
+
}
|
|
104
|
+
const code = envelope.code ?? res.status;
|
|
105
|
+
if (code !== 0) {
|
|
106
|
+
if (code === 401) throw badInput('未登录或登录已过期(执行 nx-kv auth login <email>)');
|
|
107
|
+
throw external(`后端 code=${code}: ${envelope.message || '(无消息)'}`, { code, path: ctxLabel });
|
|
108
|
+
}
|
|
109
|
+
return envelope.data ?? null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 上传一个文件到当前用户空间(group)。
|
|
114
|
+
* buffer 是完整字节:CLI 从本机文件读、面板从 base64 解 —— 两条路共用这一处。
|
|
115
|
+
*/
|
|
116
|
+
export async function fileUpload({
|
|
117
|
+
name,
|
|
118
|
+
buffer,
|
|
119
|
+
contentType,
|
|
120
|
+
groupId,
|
|
121
|
+
accessLevel = 'public',
|
|
122
|
+
tags = [],
|
|
123
|
+
key = '',
|
|
124
|
+
expireSeconds = 0,
|
|
125
|
+
} = {}) {
|
|
126
|
+
if (!name) throw badInput('上传必须带文件名');
|
|
127
|
+
if (!buffer || !buffer.length) throw badInput('上传内容为空');
|
|
128
|
+
|
|
129
|
+
const cfg = await loadConfig();
|
|
130
|
+
const url = cfg.baseUrl.replace(/\/$/, '') + '/api/v1/files';
|
|
131
|
+
const ct = contentType || contentTypeOf(name);
|
|
132
|
+
|
|
133
|
+
const form = new FormData();
|
|
134
|
+
form.append('file', new Blob([buffer], { type: ct }), String(name));
|
|
135
|
+
form.append('accessLevel', String(accessLevel));
|
|
136
|
+
const gid = normalizeGroupId(groupId);
|
|
137
|
+
if (gid !== undefined) form.append('groupId', String(gid));
|
|
138
|
+
if (expireSeconds) form.append('expireSeconds', String(expireSeconds));
|
|
139
|
+
if (key) form.append('key', String(key));
|
|
140
|
+
for (const t of tags) form.append('tags', String(t));
|
|
141
|
+
|
|
142
|
+
const ctrl = new AbortController();
|
|
143
|
+
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
|
|
144
|
+
let res;
|
|
145
|
+
try {
|
|
146
|
+
// ⚠️ 不能自己设 content-type:multipart 的 boundary 由 FormData 生成,
|
|
147
|
+
// 手写一个会丢掉 boundary,后端解析直接失败。
|
|
148
|
+
res = await fetch(url, {
|
|
149
|
+
method: 'POST',
|
|
150
|
+
headers: cfg.token ? { authorization: `Bearer ${cfg.token}` } : {},
|
|
151
|
+
body: form,
|
|
152
|
+
signal: ctrl.signal,
|
|
153
|
+
});
|
|
154
|
+
} catch (e) {
|
|
155
|
+
if (e && e.name === 'AbortError') throw external(`上传超时(${TIMEOUT_MS}ms): ${name}`);
|
|
156
|
+
throw external(`无法连接后端 ${cfg.baseUrl}: ${e.message}`);
|
|
157
|
+
} finally {
|
|
158
|
+
clearTimeout(timer);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const data = await parseEnvelope(res, 'POST /api/v1/files');
|
|
162
|
+
if (!data || !data.fileId) throw external('上传成功但后端未返回 fileId');
|
|
163
|
+
return normalizeFileMeta(data);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---- 列表 / 元数据 / 删除(JSON)----
|
|
167
|
+
|
|
168
|
+
export async function listFiles({ groupId, limit = 100, offset = 0 } = {}) {
|
|
169
|
+
const n = normalizeGroupId(groupId);
|
|
170
|
+
const q = `?limit=${Number(limit) || 100}&offset=${Number(offset) || 0}` + (n === undefined ? '' : `&groupId=${n}`);
|
|
171
|
+
const data = await call({ method: 'GET', path: '/api/v1/files' + q });
|
|
172
|
+
return {
|
|
173
|
+
total: Number(data?.total) || 0,
|
|
174
|
+
hasMore: data?.hasMore === true,
|
|
175
|
+
items: (data?.items ?? []).map(normalizeFileMeta),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 一次拉全(分页直到 total 或页数上限),供同步用。
|
|
180
|
+
* 上限存在的意义:列表接口异常返回 hasMore=true 时不会把 CLI 卡死。 */
|
|
181
|
+
export async function listAllFiles({ groupId, pageSize = 200, maxPages = 50 } = {}) {
|
|
182
|
+
const out = [];
|
|
183
|
+
let total = 0;
|
|
184
|
+
for (let page = 0; page < maxPages; page++) {
|
|
185
|
+
const r = await listFiles({ groupId, limit: pageSize, offset: page * pageSize });
|
|
186
|
+
out.push(...r.items);
|
|
187
|
+
total = r.total;
|
|
188
|
+
if (!r.items.length || out.length >= total) break;
|
|
189
|
+
}
|
|
190
|
+
return { total, items: out };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function fileInfo(fileId, groupId) {
|
|
194
|
+
if (!fileId) throw badInput('缺少 fileId');
|
|
195
|
+
const data = await call({
|
|
196
|
+
method: 'GET',
|
|
197
|
+
path: `/api/v1/files/${encodeURIComponent(fileId)}/info` + gidQuery(groupId),
|
|
198
|
+
allowMissing: true,
|
|
199
|
+
});
|
|
200
|
+
if (!data) throw notFound(`附件不存在: ${fileId}(可能已被删除或不在当前工作空间)`);
|
|
201
|
+
return normalizeFileMeta(data);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function deleteFile(fileId, groupId) {
|
|
205
|
+
if (!fileId) throw badInput('缺少 fileId');
|
|
206
|
+
await call({
|
|
207
|
+
method: 'DELETE',
|
|
208
|
+
path: `/api/v1/files/${encodeURIComponent(fileId)}` + gidQuery(groupId),
|
|
209
|
+
});
|
|
210
|
+
return { fileId, deleted: true };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 取文件本体:返回未解析的 fetch Response(调用方 pipe / arrayBuffer)。
|
|
215
|
+
* 走的是**无鉴权的出图路由** /files/:fileId —— 与 <img src> 同一条路,
|
|
216
|
+
* 所以「浏览器能预览」与「CLI 能下载」在权限上永远一致。
|
|
217
|
+
*/
|
|
218
|
+
export async function openFileStream(fileId) {
|
|
219
|
+
if (!fileId) throw badInput('缺少 fileId');
|
|
220
|
+
const cfg = await loadConfig();
|
|
221
|
+
const url = await fileAccessUrl(fileId);
|
|
222
|
+
const ctrl = new AbortController();
|
|
223
|
+
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
|
|
224
|
+
let res;
|
|
225
|
+
try {
|
|
226
|
+
res = await fetch(url, {
|
|
227
|
+
headers: cfg.token ? { authorization: `Bearer ${cfg.token}` } : {},
|
|
228
|
+
signal: ctrl.signal,
|
|
229
|
+
});
|
|
230
|
+
} catch (e) {
|
|
231
|
+
if (e && e.name === 'AbortError') throw external(`下载超时(${TIMEOUT_MS}ms): ${fileId}`);
|
|
232
|
+
throw external(`无法连接后端 ${cfg.baseUrl}: ${e.message}`);
|
|
233
|
+
} finally {
|
|
234
|
+
clearTimeout(timer);
|
|
235
|
+
}
|
|
236
|
+
if (!res.ok) throw notFound(`附件下载失败(HTTP ${res.status}): ${fileId}`);
|
|
237
|
+
return res;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function downloadFile(fileId) {
|
|
241
|
+
const res = await openFileStream(fileId);
|
|
242
|
+
return Buffer.from(await res.arrayBuffer());
|
|
243
|
+
}
|
package/src/core/kvapi.js
CHANGED
|
@@ -16,7 +16,9 @@ import { loadConfig, normalizeGroupId } from './config.js';
|
|
|
16
16
|
|
|
17
17
|
const TIMEOUT_MS = 20000;
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
// 信封感知的通用调用器。**导出**是为了让 fileapi.js 复用同一套
|
|
20
|
+
// 「HTTP 恒 200、成败看 code」的判定——两处各写一遍迟早分叉。
|
|
21
|
+
export async function call({ method, path, body, token, baseUrl, allowMissing = false }) {
|
|
20
22
|
const cfg = baseUrl ? { baseUrl, token } : await loadConfig();
|
|
21
23
|
const url = cfg.baseUrl.replace(/\/$/, '') + path;
|
|
22
24
|
|
package/src/core/paths.js
CHANGED
|
@@ -8,11 +8,20 @@ export const APP_DIR = join(homedir(), '.nx-kv');
|
|
|
8
8
|
export const STORE_PATH = join(APP_DIR, 'store.json');
|
|
9
9
|
export const DEFAULT_PORT = 7877;
|
|
10
10
|
|
|
11
|
+
// 附件的本地镜像根目录。**刻意放在 nx-kv 的用户目录下**(~/.nx-kv/attachments/):
|
|
12
|
+
// agent 读附件只认本地路径,放在项目目录里会随 cwd 漂移,还会被 git 捡走。
|
|
13
|
+
export const ATTACHMENTS_DIR = join(APP_DIR, 'attachments');
|
|
14
|
+
|
|
11
15
|
// 允许测试与多实例覆盖存储位置:NX_KV_STORE 环境变量优先
|
|
12
16
|
export function storePathFromEnv() {
|
|
13
17
|
return process.env.NX_KV_STORE || STORE_PATH;
|
|
14
18
|
}
|
|
15
19
|
|
|
20
|
+
// 允许测试与多实例覆盖附件目录:NX_KV_ATTACHMENTS 环境变量优先
|
|
21
|
+
export function attachmentsRootFromEnv() {
|
|
22
|
+
return process.env.NX_KV_ATTACHMENTS || ATTACHMENTS_DIR;
|
|
23
|
+
}
|
|
24
|
+
|
|
16
25
|
// 名称安全校验:拒绝路径穿越(保留中文等合法命名,与 fileops.go 语义一致)。
|
|
17
26
|
//
|
|
18
27
|
// 用于「这个字符串会被用作**目录名**」的场景:skill 名、内置包名、适配器 id。
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// 附件模块(= 后端图床的用户空间视图)。
|
|
2
|
+
//
|
|
3
|
+
// 不声明 `resource`:这里的「集合」是后端图床的投影,`upload/sync` 是它的动作,
|
|
4
|
+
// 硬凑 get/update/remove 五动词只会得到语义别扭的命令。
|
|
5
|
+
// 命令与路由仍然一一对应,所以「面板上能做的,CLI 都能做」依旧成立。
|
|
6
|
+
import * as service from './service.js';
|
|
7
|
+
|
|
8
|
+
// 所有动作都接受 --group 覆盖当前工作空间;默认取本机配置里的当前值
|
|
9
|
+
const GROUP = { type: 'number', hint: '工作空间 id(默认取当前)' };
|
|
10
|
+
|
|
11
|
+
export default {
|
|
12
|
+
id: 'file',
|
|
13
|
+
title: '附件',
|
|
14
|
+
order: 40,
|
|
15
|
+
// 没有独立面板:附件在「清单」页里被引用与预览,单独开一个 tab
|
|
16
|
+
// 会得到第二个「附件列表」,反而不知道以哪个为准。
|
|
17
|
+
view: null,
|
|
18
|
+
|
|
19
|
+
actions: [
|
|
20
|
+
{
|
|
21
|
+
id: 'file.list',
|
|
22
|
+
cli: ['file', 'list'],
|
|
23
|
+
http: ['GET', '/api/file'],
|
|
24
|
+
summary: '列出当前用户空间的附件(含本地已同步路径)',
|
|
25
|
+
flags: {
|
|
26
|
+
group: GROUP,
|
|
27
|
+
limit: { type: 'number', default: 100 },
|
|
28
|
+
offset: { type: 'number', default: 0 },
|
|
29
|
+
},
|
|
30
|
+
run: (ctx) => service.listFilesAction(ctx),
|
|
31
|
+
render: (d) => service.renderFileList(d),
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'file.upload',
|
|
35
|
+
cli: ['file', 'upload'],
|
|
36
|
+
http: ['POST', '/api/file'],
|
|
37
|
+
summary: '上传附件到当前用户空间(--path 本机文件;面板走 --content base64 --name 文件名)',
|
|
38
|
+
flags: {
|
|
39
|
+
path: { type: 'string', hint: '本机文件路径' },
|
|
40
|
+
content: { type: 'string', hint: 'base64 内容(面板上传用,可为 data URL)' },
|
|
41
|
+
name: { type: 'string', hint: '文件名(content 上传时必填)' },
|
|
42
|
+
contentType: { type: 'string', hint: '覆盖 Content-Type(默认按扩展名猜)' },
|
|
43
|
+
accessLevel: { type: 'string', enum: ['public', 'private', 'protected'], default: 'public', hint: '出图权限;public 才能被 <img> 直接预览' },
|
|
44
|
+
group: GROUP,
|
|
45
|
+
},
|
|
46
|
+
run: (ctx) => service.uploadFileAction(ctx),
|
|
47
|
+
render: (d) => service.renderUpload(d),
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: 'file.get',
|
|
51
|
+
cli: ['file', 'get'],
|
|
52
|
+
http: ['GET', '/api/file/item'],
|
|
53
|
+
summary: '查单个附件的元数据与本地路径',
|
|
54
|
+
args: ['fileId'],
|
|
55
|
+
flags: { group: GROUP },
|
|
56
|
+
run: (ctx) => service.getFileAction(ctx.fileId, { group: ctx.group }),
|
|
57
|
+
render: (d) => service.renderFileInfo(d),
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: 'file.remove',
|
|
61
|
+
cli: ['file', 'remove'],
|
|
62
|
+
http: ['DELETE', '/api/file/item'],
|
|
63
|
+
summary: '删除附件(后端软删;本地副本要清用 file sync --prune)',
|
|
64
|
+
args: ['fileId'],
|
|
65
|
+
flags: { group: GROUP },
|
|
66
|
+
run: (ctx) => service.removeFileAction(ctx.fileId, { group: ctx.group }),
|
|
67
|
+
render: (d) => service.renderRemove(d),
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: 'file.content',
|
|
71
|
+
// HTTP 侧直接透传文件本体(面板里 <img src="/api/file/content/<id>"> 预览);
|
|
72
|
+
// CLI 侧只打印地址与本地路径 —— 终端吐二进制没有意义。
|
|
73
|
+
cli: ['file', 'content'],
|
|
74
|
+
http: ['GET', '/api/file/content/:fileId'],
|
|
75
|
+
stream: true,
|
|
76
|
+
summary: '取附件本体:面板内联预览;CLI 打印出图地址与本地路径',
|
|
77
|
+
args: ['fileId'],
|
|
78
|
+
flags: { group: GROUP },
|
|
79
|
+
run: (ctx) => (ctx.__raw
|
|
80
|
+
? service.serveContent(ctx)
|
|
81
|
+
: service.getFileAction(ctx.fileId, { group: ctx.group })),
|
|
82
|
+
render: (d) => service.renderContent(d),
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: 'file.sync',
|
|
86
|
+
cli: ['file', 'sync'],
|
|
87
|
+
http: ['POST', '/api/file/sync'],
|
|
88
|
+
summary: '把附件同步到本机(默认全部;--ids 只同步指定附件)——为了不再依赖网络读附件',
|
|
89
|
+
flags: {
|
|
90
|
+
ids: { type: 'array', hint: '只同步这些 fileId(逗号分隔)' },
|
|
91
|
+
dir: { type: 'string', hint: '本地目录(默认 ~/.nx-kv/attachments/<工作空间>/)' },
|
|
92
|
+
prune: { type: 'boolean', hint: '删掉本地已不在后端的文件(仅全量同步时生效)' },
|
|
93
|
+
dryRun: { type: 'boolean', hint: '只列出将下载什么,不落盘' },
|
|
94
|
+
group: GROUP,
|
|
95
|
+
},
|
|
96
|
+
run: (ctx) => service.syncFilesAction(ctx),
|
|
97
|
+
render: (d) => service.renderSync(d),
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
};
|