@yosai/cli 0.2.1-test.12 → 0.2.1-test.13

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.
@@ -10,7 +10,7 @@ import { decideSingleFileUpload, findSubtreeChildren, uploadDirectory, walkLocal
10
10
  const CONFLICT_POLICIES = ['skip', 'rename'];
11
11
  register({
12
12
  name: 'kb upload',
13
- description: '上传本地文件或整个目录到知识库(目录递归镜像文件夹,multipart 直传,单文件 ≤20MB)',
13
+ description: '上传本地文件或整个目录到知识库(目录递归镜像文件夹;≤20MB 直传,更大文件自动分片上传)',
14
14
  usage: 'yos kb upload <本地文件或目录> --kb <kb_code> [--dir <parent_code>] [--on-conflict <skip|rename>] [--json]',
15
15
  group: '知识库 kb',
16
16
  risk: 'write',
@@ -82,25 +82,17 @@ async function uploadSingle(deps, filePath, kbCode, dirCode, onConflict, json) {
82
82
  return EXIT.OK;
83
83
  }
84
84
  const finalName = decision.finalName;
85
- try {
86
- const result = await deps.uploadFile(filePath, kbCode, dirCode, finalName);
87
- if (json) {
88
- printJson(result);
89
- }
90
- else if (finalName === name) {
91
- print(`已上传:${result.fileName}(${result.fileSize} 字节)→ 节点 ${result.nodeCode}`);
92
- }
93
- else {
94
- print(`已改名上传:${name} → ${result.fileName}(${result.fileSize} 字节)→ 节点 ${result.nodeCode}`);
95
- }
96
- return EXIT.OK;
85
+ const result = await deps.uploadFile(filePath, kbCode, dirCode, finalName);
86
+ if (json) {
87
+ printJson(result);
97
88
  }
98
- catch (e) {
99
- if (e instanceof BusinessError && e.message.includes('超过 20MB')) {
100
- throw new BusinessError(e.message, '更大文件请先在客户端上传后共享,或拆分为多个文件');
101
- }
102
- throw e;
89
+ else if (finalName === name) {
90
+ print(`已上传:${result.fileName}(${result.fileSize} 字节)→ 节点 ${result.nodeCode}`);
91
+ }
92
+ else {
93
+ print(`已改名上传:${name} → ${result.fileName}(${result.fileSize} 字节)→ 节点 ${result.nodeCode}`);
103
94
  }
95
+ return EXIT.OK;
104
96
  }
105
97
  async function uploadDir(deps, localRoot, kbCode, dirCode, onConflict, json) {
106
98
  const { exitCode, aborted, summary } = await uploadDirectory(deps, { localRoot, kbCode, dirCode, onConflict });
@@ -1,9 +1,11 @@
1
- import { readFile, stat } from 'node:fs/promises';
1
+ import { open, readFile, stat } from 'node:fs/promises';
2
2
  import { basename } from 'node:path';
3
3
  import { envNoteOfEndpoint } from './endpoints.js';
4
4
  import { AuthError, BusinessError, NetworkError, UpgradeError } from './errors.js';
5
5
  import { getCliVersion } from './npm-meta.js';
6
- export const MAX_UPLOAD_SIZE_BYTES = 20 * 1024 * 1024;
6
+ export const DIRECT_UPLOAD_THRESHOLD_BYTES = 20 * 1024 * 1024;
7
+ const FALLBACK_PART_SIZE_BYTES = 5 * 1024 * 1024;
8
+ const MAX_PART_SIZE_BYTES = 20 * 1024 * 1024;
7
9
  export const CLI_VERSION_HEADER = 'X-Yos-Cli-Version';
8
10
  export function formatIdentity(identity) {
9
11
  return identity.userName ? `${identity.userName}(${identity.userCode})` : identity.userCode;
@@ -29,24 +31,29 @@ export class OpenApisClient {
29
31
  return this.request('/tool/call', { name, arguments: args });
30
32
  }
31
33
  async request(path, body) {
32
- let response;
33
- try {
34
- response = await fetch(`${this.base}${path}`, {
35
- method: 'POST',
36
- headers: {
37
- 'Content-Type': 'application/json',
38
- 'Authorization': `Bearer ${this.apiKey}`,
39
- [CLI_VERSION_HEADER]: getCliVersion(),
40
- },
41
- body: JSON.stringify(body ?? {}),
42
- });
43
- }
44
- catch (e) {
45
- throw new NetworkError(`无法连接 YOS 服务${envNoteOfBase(this.base)},原因:${e.message}`);
46
- }
47
- return parseEnvelope(response);
34
+ return postJson(this.base, this.apiKey, path, body);
48
35
  }
49
36
  }
37
+ function authHeaders(apiKey) {
38
+ return {
39
+ 'Authorization': `Bearer ${apiKey}`,
40
+ [CLI_VERSION_HEADER]: getCliVersion(),
41
+ };
42
+ }
43
+ async function postJson(base, apiKey, path, body) {
44
+ let response;
45
+ try {
46
+ response = await fetch(`${base}${path}`, {
47
+ method: 'POST',
48
+ headers: { 'Content-Type': 'application/json', ...authHeaders(apiKey) },
49
+ body: JSON.stringify(body ?? {}),
50
+ });
51
+ }
52
+ catch (e) {
53
+ throw new NetworkError(`无法连接 YOS 服务${envNoteOfBase(base)},原因:${e.message}`);
54
+ }
55
+ return parseEnvelope(response);
56
+ }
50
57
  function envNoteOfBase(base) {
51
58
  return envNoteOfEndpoint(base.replace(/\/open-apis$/, ''));
52
59
  }
@@ -110,12 +117,15 @@ export async function uploadKnowledgeFile(endpoint, apiKey, filePath, kbCode, pa
110
117
  catch {
111
118
  throw new BusinessError(`文件不存在或不可读:path=${filePath}`);
112
119
  }
113
- if (fileSize > MAX_UPLOAD_SIZE_BYTES) {
114
- throw new BusinessError(`文件超过 20MB 上限:size=${fileSize},path=${filePath}`);
115
- }
120
+ const uploadName = fileName ?? basename(filePath);
121
+ return fileSize <= DIRECT_UPLOAD_THRESHOLD_BYTES
122
+ ? directUpload(endpoint, apiKey, filePath, kbCode, parentCode, uploadName)
123
+ : partUpload(endpoint, apiKey, filePath, fileSize, kbCode, parentCode, uploadName);
124
+ }
125
+ async function directUpload(endpoint, apiKey, filePath, kbCode, parentCode, uploadName) {
116
126
  const content = await readFile(filePath);
117
127
  const form = new FormData();
118
- form.append('file', new Blob([content]), fileName ?? basename(filePath));
128
+ form.append('file', new Blob([content]), uploadName);
119
129
  form.append('kb_code', kbCode);
120
130
  if (parentCode) {
121
131
  form.append('parent_code', parentCode);
@@ -124,7 +134,7 @@ export async function uploadKnowledgeFile(endpoint, apiKey, filePath, kbCode, pa
124
134
  try {
125
135
  response = await fetch(`${endpoint}/open-apis/knowledge/file_upload`, {
126
136
  method: 'POST',
127
- headers: { 'Authorization': `Bearer ${apiKey}`, [CLI_VERSION_HEADER]: getCliVersion() },
137
+ headers: authHeaders(apiKey),
128
138
  body: form,
129
139
  });
130
140
  }
@@ -133,3 +143,74 @@ export async function uploadKnowledgeFile(endpoint, apiKey, filePath, kbCode, pa
133
143
  }
134
144
  return parseEnvelope(response);
135
145
  }
146
+ async function partUpload(endpoint, apiKey, filePath, fileSize, kbCode, parentCode, uploadName) {
147
+ const handle = await open(filePath, 'r');
148
+ let uploadId;
149
+ try {
150
+ const init = await postJson(`${endpoint}/open-apis`, apiKey, '/knowledge/init_part_upload', { fileName: uploadName, fileSize, kbCode, parentCode });
151
+ if (!init.uploadId) {
152
+ throw new BusinessError('分片上传初始化失败:服务端未返回 uploadId');
153
+ }
154
+ uploadId = init.uploadId;
155
+ const partSize = normalizePartSize(init.partSize);
156
+ const parts = [];
157
+ let position = 0;
158
+ while (position < fileSize) {
159
+ const partNumber = parts.length + 1;
160
+ const length = Math.min(partSize, fileSize - position);
161
+ const buffer = Buffer.allocUnsafe(length);
162
+ const { bytesRead } = await handle.read(buffer, 0, length, position);
163
+ if (bytesRead === 0) {
164
+ throw new BusinessError(`读取本地文件提前结束:path=${filePath},已读=${position},总大小=${fileSize}`);
165
+ }
166
+ const part = await uploadKnowledgePart(endpoint, apiKey, init.uploadId, partNumber, buffer.subarray(0, bytesRead), uploadName);
167
+ if (!part.etag) {
168
+ throw new BusinessError(`分片上传响应缺少 etag:partNumber=${partNumber}`);
169
+ }
170
+ parts.push({ partNumber, etag: part.etag });
171
+ position += bytesRead;
172
+ }
173
+ return await postJson(`${endpoint}/open-apis`, apiKey, '/knowledge/complete_part_upload', { uploadId: init.uploadId, parts, kbCode, parentCode });
174
+ }
175
+ catch (e) {
176
+ if (uploadId) {
177
+ await abortPartUploadQuietly(endpoint, apiKey, uploadId);
178
+ }
179
+ throw e;
180
+ }
181
+ finally {
182
+ await handle.close();
183
+ }
184
+ }
185
+ async function uploadKnowledgePart(endpoint, apiKey, uploadId, partNumber, chunk, uploadName) {
186
+ const form = new FormData();
187
+ form.append('file', new Blob([chunk]), `${uploadName}.part${partNumber}`);
188
+ form.append('upload_id', uploadId);
189
+ form.append('part_number', String(partNumber));
190
+ let response;
191
+ try {
192
+ response = await fetch(`${endpoint}/open-apis/knowledge/upload_part`, {
193
+ method: 'POST',
194
+ headers: authHeaders(apiKey),
195
+ body: form,
196
+ });
197
+ }
198
+ catch (e) {
199
+ throw new NetworkError(`无法连接 YOS 服务${envNoteOfEndpoint(endpoint)},原因:${e.message}`);
200
+ }
201
+ return parseEnvelope(response);
202
+ }
203
+ async function abortPartUploadQuietly(endpoint, apiKey, uploadId) {
204
+ try {
205
+ const response = await fetch(`${endpoint}/open-apis/knowledge/abort_part_upload?upload_id=${encodeURIComponent(uploadId)}`, { method: 'POST', headers: authHeaders(apiKey) });
206
+ await parseEnvelope(response);
207
+ }
208
+ catch {
209
+ }
210
+ }
211
+ function normalizePartSize(raw) {
212
+ if (raw === undefined || !Number.isFinite(raw) || raw <= 0) {
213
+ return FALLBACK_PART_SIZE_BYTES;
214
+ }
215
+ return Math.min(Math.floor(raw), MAX_PART_SIZE_BYTES);
216
+ }
@@ -67,7 +67,7 @@ yos retrieve read 'cortex://kb/KB20261009000001/doc/ND20261009000012:1-60' #
67
67
  # 整理知识库
68
68
  yos docs tree KB20261009000001 # 目录树(资源编码字段是 resourceCode)
69
69
  yos docs mkdir '会议纪要' --kb KB20261009000001 # 返回新节点编码
70
- yos kb upload ./报告.pdf --kb KB20261009000001 # 单文件上传(≤20MB,同名时按 --on-conflict 处理)
70
+ yos kb upload ./报告.pdf --kb KB20261009000001 # 单文件上传(≤20MB 直传,更大自动分片;同名按 --on-conflict 处理)
71
71
  yos docs download ND20261009000012 --out ./归档/ # 取回上传的原文件(仅 docType=file)
72
72
  yos docs count ND20261009000012 # 删除前查影响范围
73
73
 
@@ -148,7 +148,8 @@ yos kb upload ./会议纪要 --kb KB20260921000001 --json # 结
148
148
 
149
149
  硬约束与注意点:
150
150
 
151
- - **单文件 ≤20MB**:`错误: 文件超过 20MB 上限:size=22020096,path=...` + `提示: 更大文件请先在客户端上传后共享,或拆分为多个文件`(exit 1)
151
+ - **单文件大小按扩展名限制**:办公三件套(doc/docx/xls/xlsx/ppt/pptx)与 pdf ≤100MB,纯文本(txt/md/csv/html)与 json ≤10MB,图片(png/jpg/jpeg)≤20MB,其余扩展名 ≤20MB;超限 → `错误: 文件大小超出限制:fileName=xxx.csv,类型=csv,大小=12MB,上限=10MB`(exit 1)
152
+ - **超过 20MB 自动分片**:CLI 自动完成初始化、逐片上传与完成登记,命令参数不变;中途失败会尽力取消上传并保留原始错误
152
153
  - **服务端不做任何同名去重**:落库节点名恒等于本次上传用的文件名,同名副本会真实堆积并放大 `retrieve grep` 的命中数。同名判定与 `rename` 的新名都由 CLI 侧完成,因此绕过 CLI 直传不会有这层保护
153
154
  - 目录上传自动跳过隐藏文件(如 `.DS_Store`),并自动创建缺失的子文件夹(单文件上传不会,需先 `docs mkdir`)
154
155
  - 支持类型(以服务端返回为准):doc/docx/xls/xlsx/ppt/pptx/pdf/md/txt/json/csv/html/png/jpg/jpeg
@@ -108,7 +108,7 @@ yos support upgrade-log --start 2026-10-01 --end 2026-10-10 # 按发布日期
108
108
  ## 5. `support feedback '<内容>'` — 提交反馈(写操作)
109
109
 
110
110
  ```bash
111
- yos support feedback '知识库上传 20MB 以上的 PDF 会失败,希望支持大文件分片'
111
+ yos support feedback '检索结果希望支持按部门筛选'
112
112
  yos support feedback -- '转写结果说话人识别不准,希望提供纠错入口' # 内容以 - 开头时必须加 --
113
113
  ```
114
114
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yosai/cli",
3
- "version": "0.2.1-test.12",
3
+ "version": "0.2.1-test.13",
4
4
  "description": "YOS 能力的 Agent 执行入口:工具调用、知识库管理与文件上传",
5
5
  "type": "module",
6
6
  "bin": {