weaver-work-cli 0.1.23 → 0.1.24
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/README.md +23 -2
- package/dist/internal/e10/auth/commands.js +194 -16
- package/dist/internal/e10/auth/xiaoe.js +15 -6
- package/dist/internal/e10/context.js +3 -3
- package/dist/internal/e10/request-runtime.js +46 -4
- package/dist/shortcuts/caizhilian/file-upload/metadata.js +124 -0
- package/dist/shortcuts/caizhilian/file-upload/multipart.js +47 -0
- package/dist/shortcuts/caizhilian/file-upload/protocol.js +116 -0
- package/dist/shortcuts/caizhilian/file-upload/receipt.js +37 -0
- package/dist/shortcuts/caizhilian/host.js +13 -5
- package/dist/shortcuts/caizhilian/operations/handlers/file-upload.js +72 -179
- package/dist/shortcuts/caizhilian/operations/handlers/folder.js +701 -4
- package/dist/shortcuts/caizhilian/operations/registry.js +5 -1
- package/dist/shortcuts/caizhilian/operations/specs.js +52 -0
- package/dist/shortcuts/workflow/continuation.js +14 -9
- package/dist/shortcuts/workflow/endpoints.js +11 -1
- package/dist/shortcuts/workflow/operations/flow-status.js +63 -0
- package/dist/shortcuts/workflow/operations/form.js +650 -518
- package/dist/shortcuts/workflow/operations/read.js +374 -13
- package/dist/shortcuts/workflow/operations/shared.js +3 -0
- package/dist/shortcuts/workflow/operations/write.js +5 -5
- package/dist/shortcuts/workflow/operations.js +8 -6
- package/docs/caizhilian.md +8 -2
- package/docs/e10-auth.md +18 -3
- package/package.json +1 -1
- package/skills/weaver-e10-caizhilian/SKILL.md +8 -3
- package/skills/weaver-e10-caizhilian/references/file-upload.md +100 -295
- package/skills/weaver-e10-caizhilian/references/folder.md +31 -3
- package/skills/weaver-e10-caizhilian/references/source-manifest.json +536 -25
- package/skills/weaver-e10-jiuchuanhui/SKILL.md +9 -1
- package/skills/weaver-e10-shared/SKILL.md +12 -2
- package/skills/weaver-e10-shared/references/e10-auth-and-session.md +21 -3
- package/skills/weaver-e10-workflow/SKILL.md +10 -8
- package/skills/weaver-e10-workflow/references/source-manifest.json +36 -31
- package/skills/weaver-e10-workflow/references/workflow-batch.md +2 -4
- package/skills/weaver-e10-workflow/references/workflow-create.md +58 -36
- package/skills/weaver-e10-workflow/references/workflow-edit.md +2 -1
- package/skills/weaver-e10-workflow/references/workflow-memory.md +2 -2
- package/skills/weaver-e10-workflow/references/workflow-operate.md +2 -1
- package/skills/weaver-e10-workflow/references/workflow-query.md +9 -7
- package/skills/weaver-e10-workflow/references/workflow-share.md +2 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Exact-length multipart body with bounded file slices and RFC 5987 UTF-8 filenames. */
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { open } from 'node:fs/promises';
|
|
4
|
+
import { Readable } from 'node:stream';
|
|
5
|
+
import { assertUploadSnapshot, READ_FLAGS } from './metadata.js';
|
|
6
|
+
function quoted(value) { return value.replace(/[\r\n\0]/g, '_').replace(/\\/g, '\\\\').replace(/"/g, '\\"'); }
|
|
7
|
+
function encodedFilename(value) {
|
|
8
|
+
return encodeURIComponent(value).replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
9
|
+
}
|
|
10
|
+
export function uploadMultipart(fields, file) {
|
|
11
|
+
const boundary = `weaver-${randomBytes(16).toString('hex')}`;
|
|
12
|
+
const parts = [];
|
|
13
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
14
|
+
const text = value === null || value === undefined ? '' : typeof value === 'string' ? value : JSON.stringify(value);
|
|
15
|
+
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${quoted(key)}"\r\n\r\n${text}\r\n`));
|
|
16
|
+
}
|
|
17
|
+
if (file) {
|
|
18
|
+
const fallback = quoted(file.meta.name.replace(/[^\x20-\x7e]/g, '_'));
|
|
19
|
+
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${fallback}"; `
|
|
20
|
+
+ `filename*=UTF-8''${encodedFilename(file.meta.name)}\r\nContent-Type: ${file.meta.mime_type}\r\n\r\n`));
|
|
21
|
+
}
|
|
22
|
+
const tail = Buffer.from(`${file ? '\r\n' : ''}--${boundary}--\r\n`);
|
|
23
|
+
const body = Readable.from((async function* () {
|
|
24
|
+
yield* parts;
|
|
25
|
+
if (file && file.size > 0) {
|
|
26
|
+
const handle = await open(file.meta.path, READ_FLAGS);
|
|
27
|
+
try {
|
|
28
|
+
assertUploadSnapshot(file.meta, await handle.stat());
|
|
29
|
+
const stream = handle.createReadStream({ start: file.start, end: file.start + file.size - 1, autoClose: false });
|
|
30
|
+
try {
|
|
31
|
+
for await (const bytes of stream)
|
|
32
|
+
yield bytes;
|
|
33
|
+
assertUploadSnapshot(file.meta, await handle.stat());
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
stream.destroy();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
await handle.close();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
yield tail;
|
|
44
|
+
})(), { objectMode: false });
|
|
45
|
+
return { body, contentType: `multipart/form-data; boundary=${boundary}`,
|
|
46
|
+
contentLength: parts.reduce((total, bytes) => total + bytes.length, tail.length + (file?.size ?? 0)) };
|
|
47
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/** Upload strategy owner, adapted from weaver-file-upload to the CLI-owned authenticated host. */
|
|
2
|
+
import { CaizhilianCliError, caizhilianValidationError } from '../errors.js';
|
|
3
|
+
import { ExitCode } from '../../../core/errors.js';
|
|
4
|
+
import { uploadMultipart } from './multipart.js';
|
|
5
|
+
const object = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
6
|
+
const invalid = (subtype, message) => { throw caizhilianValidationError(subtype, message); };
|
|
7
|
+
function checkedData(response) {
|
|
8
|
+
const envelope = object(response);
|
|
9
|
+
const data = object(envelope.data);
|
|
10
|
+
const code = envelope.code === undefined || envelope.code === null ? '' : String(envelope.code).toLowerCase();
|
|
11
|
+
if (code && !['0', '200', 'success'].includes(code))
|
|
12
|
+
invalid('upload_business_failed', '文件服务未确认操作成功');
|
|
13
|
+
for (const record of [envelope, data]) {
|
|
14
|
+
if (record.status === false || record.success === false || record.fail === true) {
|
|
15
|
+
invalid('upload_business_failed', '文件服务未确认操作成功');
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
if (!envelope.data || typeof envelope.data !== 'object' || Array.isArray(envelope.data)) {
|
|
19
|
+
invalid('upload_response_invalid', '文件服务响应缺少有效数据对象');
|
|
20
|
+
}
|
|
21
|
+
return data;
|
|
22
|
+
}
|
|
23
|
+
function fileRecord(data) {
|
|
24
|
+
const candidates = [[data], [object(data.fileObj)], [object(data.uploadModuleDto)],
|
|
25
|
+
[object(data.uploadModuleDto?.fileObj), object(data.uploadModuleDto)], [object(data.imUploadInfoDto)],
|
|
26
|
+
[object(data.imUploadInfoDto?.fileObj), object(data.imUploadInfoDto)]];
|
|
27
|
+
for (const [record, parent] of candidates) {
|
|
28
|
+
const id = record.fileId ?? record.fileid;
|
|
29
|
+
if (typeof id === 'number' && !Number.isSafeInteger(id))
|
|
30
|
+
invalid('file_id_invalid', '文件 ID 精度不可靠');
|
|
31
|
+
if ((typeof id === 'string' || typeof id === 'number') && String(id).trim()) {
|
|
32
|
+
return { id: String(id).trim(), sizes: [record.size, parent?.size, data.size] };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
function terminal(data, file) {
|
|
38
|
+
const record = fileRecord(data);
|
|
39
|
+
if (!record || data.endChunk === false)
|
|
40
|
+
invalid('upload_not_terminal', '上传未返回已完成的非空文件 ID');
|
|
41
|
+
for (const reported of record.sizes) {
|
|
42
|
+
if (reported === undefined || reported === null || reported === '')
|
|
43
|
+
continue;
|
|
44
|
+
const size = Number(reported);
|
|
45
|
+
if (!Number.isSafeInteger(size) || size < 0 || (size !== 0 && size !== file.size)) {
|
|
46
|
+
invalid('upload_size_mismatch', '服务端文件大小与本地文件不一致');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return record.id;
|
|
50
|
+
}
|
|
51
|
+
async function send(host, path, fields, timeoutMs, file) {
|
|
52
|
+
const multipart = uploadMultipart(fields, file);
|
|
53
|
+
try {
|
|
54
|
+
return checkedData(await host.request({ method: 'POST', path, query: { sf_request_type: 'ajax' },
|
|
55
|
+
streamMultipart: multipart, timeoutMs, maxResponseBytes: 4 * 1024 * 1024, requireHttp200: true,
|
|
56
|
+
tolerateBusinessError: true,
|
|
57
|
+
operation: 'caizhilian.file-upload.upload.apply' }));
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
const failure = error;
|
|
61
|
+
if (file && (failure?.type === 'network' || failure?.subtype === 'http_error')) {
|
|
62
|
+
throw new CaizhilianCliError('partial', 'write_uncertain', '上传请求已发出,但未确认结果;请勿自动重复上传', {
|
|
63
|
+
retryable: false, details: { execution_state: 'unknown', retry_safe: false }, exitCode: ExitCode.Partial,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
multipart.body.destroy();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export async function uploadToFileService(host, file, module, context, timeoutMs) {
|
|
73
|
+
// Node stat retains fractional milliseconds; E10 parses these fields as integer milliseconds.
|
|
74
|
+
const lastModified = String(Math.floor(file.mtime_ms));
|
|
75
|
+
const fields = { ...context.fields, compressImg: 'false', module, name: file.name, size: String(file.size),
|
|
76
|
+
MD5: file.md5, lastModified, lastModifiedDate: lastModified };
|
|
77
|
+
const pre = await send(host, '/api/file/preUploadCheck', fields, timeoutMs);
|
|
78
|
+
const type = pre.uploadType;
|
|
79
|
+
if (!['second', 'common', 'chunk'].includes(type))
|
|
80
|
+
invalid('upload_type_invalid', '文件服务返回未知上传策略');
|
|
81
|
+
if (type === 'second')
|
|
82
|
+
return { file_id: terminal(pre, file), upload_type: type };
|
|
83
|
+
if (typeof pre.preUploadId !== 'string' || !pre.preUploadId.trim()) {
|
|
84
|
+
// E10 may return preUploadId as a safe numeric ID.
|
|
85
|
+
if (typeof pre.preUploadId !== 'number' || !Number.isSafeInteger(pre.preUploadId) || pre.preUploadId <= 0) {
|
|
86
|
+
invalid('upload_precheck_invalid', '预上传检查未返回有效 preUploadId');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const uploadFields = { ...fields, preUploadId: String(pre.preUploadId), uploadType: type };
|
|
90
|
+
if (type === 'common') {
|
|
91
|
+
const data = await send(host, '/api/file/module/upload', uploadFields, timeoutMs, { meta: file, start: 0, size: file.size });
|
|
92
|
+
return { file_id: terminal(data, file), upload_type: type };
|
|
93
|
+
}
|
|
94
|
+
const chunkMiB = Number(pre.chunkDto?.chunkSize);
|
|
95
|
+
if (!Number.isSafeInteger(chunkMiB) || chunkMiB <= 0 || file.size <= 0) {
|
|
96
|
+
invalid('chunk_size_invalid', '分片上传需要非空文件和正整数 MiB 分片大小');
|
|
97
|
+
}
|
|
98
|
+
const chunkSize = chunkMiB * 1024 * 1024;
|
|
99
|
+
if (!Number.isSafeInteger(chunkSize) || chunkSize <= 0)
|
|
100
|
+
invalid('chunk_size_invalid', '分片大小必须是有效的正 MiB 值');
|
|
101
|
+
const count = Math.max(1, Math.ceil(file.size / chunkSize));
|
|
102
|
+
if (pre.chunkDto?.chunkCount !== undefined && Number(pre.chunkDto.chunkCount) !== count) {
|
|
103
|
+
invalid('chunk_count_mismatch', '服务端分片数量与本地计算不一致');
|
|
104
|
+
}
|
|
105
|
+
for (let index = 0; index < count; index++) {
|
|
106
|
+
const start = index * chunkSize;
|
|
107
|
+
const size = Math.min(chunkSize, file.size - start);
|
|
108
|
+
const data = await send(host, '/api/file/module/upload', { ...uploadFields, chunk: index, chunks: count,
|
|
109
|
+
uploadShardSize: String(size) }, timeoutMs, { meta: file, start, size });
|
|
110
|
+
if (index === count - 1)
|
|
111
|
+
return { file_id: terminal(data, file), upload_type: 'chunk' };
|
|
112
|
+
if (data.endChunk !== false || fileRecord(data))
|
|
113
|
+
invalid('chunk_terminal_early', '中间分片缺少明确未完成状态或提前返回文件 ID');
|
|
114
|
+
}
|
|
115
|
+
return invalid('upload_not_terminal', '分片上传未确认完成');
|
|
116
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmod, mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, resolve } from 'node:path';
|
|
5
|
+
import { CaizhilianCliError } from '../errors.js';
|
|
6
|
+
import { ExitCode } from '../../../core/errors.js';
|
|
7
|
+
export async function writeUploadReceipt(env, chatId, module, file, context, verified) {
|
|
8
|
+
const session = await env.host.getSession();
|
|
9
|
+
const identity = JSON.stringify({ baseUrl: session.baseUrl, userId: session.userId, tenantKey: session.tenantKey });
|
|
10
|
+
const result = { version: 'weaver_file_upload_result.v1', status: 'completed', business_verified: true,
|
|
11
|
+
upload_type: verified.upload_type, file: { file_id: verified.file_id, name: file.name, size: file.size,
|
|
12
|
+
mime_type: file.mime_type, extension: file.extension, is_image: file.is_image },
|
|
13
|
+
integrity: { md5: file.md5, sha256: file.sha256 },
|
|
14
|
+
request_evidence: { module, context_keys: context.keys, context_sha256: context.sha256,
|
|
15
|
+
environment_identity_sha256: createHash('sha256').update(identity).digest('hex'),
|
|
16
|
+
login_mode: 'weaver-work-cli', automatic_fallback: false } };
|
|
17
|
+
const configPath = env.ctx.config?.path();
|
|
18
|
+
const stateRoot = configPath ? dirname(configPath) : env.ctx.env?.WEAVER_WORK_CLI_HOME
|
|
19
|
+
|| env.ctx.env?.WORK_CLI_HOME || resolve(homedir(), '.weaver-work-cli');
|
|
20
|
+
const directory = resolve(stateRoot, 'caizhilian', 'file-upload', 'runs', chatId, 'receipts');
|
|
21
|
+
const filename = resolve(directory, `upload-${randomUUID()}.json`);
|
|
22
|
+
const content = `${JSON.stringify(result, null, 2)}\n`;
|
|
23
|
+
try {
|
|
24
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
25
|
+
if (process.platform !== 'win32')
|
|
26
|
+
await chmod(directory, 0o700);
|
|
27
|
+
await writeFile(filename, content, { flag: 'wx', mode: 0o600 });
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
throw new CaizhilianCliError('partial', 'receipt_write_failed', '文件已上传,但本地回执保存失败;请勿重复上传', {
|
|
31
|
+
retryable: false, partialData: { file_id: verified.file_id, upload_type: verified.upload_type },
|
|
32
|
+
details: { execution_state: 'completed', retry_safe: false }, exitCode: ExitCode.Partial,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return { ...result, file_id: verified.file_id, file_name: file.name, size: file.size, ext: file.extension,
|
|
36
|
+
receipt_ref: filename, receipt_sha256: createHash('sha256').update(content).digest('hex') };
|
|
37
|
+
}
|
|
@@ -110,9 +110,13 @@ export function createCaizhilianHost(ctx, dependencies = {}) {
|
|
|
110
110
|
});
|
|
111
111
|
}
|
|
112
112
|
async function doRequest(request, binary) {
|
|
113
|
-
const { method, path, query, body, operation, form, multipart, headers: extraHeaders = {}, timeoutMs = CAIZHILAN_TIMEOUT_MS, } = request;
|
|
113
|
+
const { method, path, query, body, operation, form, multipart, streamMultipart, maxResponseBytes, headers: extraHeaders = {}, timeoutMs = CAIZHILAN_TIMEOUT_MS, } = request;
|
|
114
114
|
assertCaizhilianPath(path);
|
|
115
|
-
const resolvedMethod = method || (body === undefined && form === undefined && multipart === undefined
|
|
115
|
+
const resolvedMethod = method || (body === undefined && form === undefined && multipart === undefined
|
|
116
|
+
&& streamMultipart === undefined ? 'GET' : 'POST');
|
|
117
|
+
const headers = { 'X-Requested-With': 'XMLHttpRequest', ...extraHeaders, ...(streamMultipart ? {
|
|
118
|
+
'Content-Type': streamMultipart.contentType, 'Content-Length': String(streamMultipart.contentLength),
|
|
119
|
+
} : {}) };
|
|
116
120
|
const rt = await runtime();
|
|
117
121
|
const response = binary
|
|
118
122
|
? await rt.requestBinary({
|
|
@@ -124,7 +128,9 @@ export function createCaizhilianHost(ctx, dependencies = {}) {
|
|
|
124
128
|
body: multipart === undefined ? body : undefined,
|
|
125
129
|
form: multipart === undefined && form === undefined ? undefined : formBody(form ?? {}),
|
|
126
130
|
formData: multipart,
|
|
127
|
-
|
|
131
|
+
streamBody: streamMultipart?.body,
|
|
132
|
+
maxResponseBytes,
|
|
133
|
+
headers,
|
|
128
134
|
timeoutMs,
|
|
129
135
|
})
|
|
130
136
|
: await rt.requestJson({
|
|
@@ -136,7 +142,9 @@ export function createCaizhilianHost(ctx, dependencies = {}) {
|
|
|
136
142
|
body: multipart === undefined ? body : undefined,
|
|
137
143
|
form: multipart === undefined && form === undefined ? undefined : formBody(form ?? {}),
|
|
138
144
|
formData: multipart,
|
|
139
|
-
|
|
145
|
+
streamBody: streamMultipart?.body,
|
|
146
|
+
maxResponseBytes,
|
|
147
|
+
headers,
|
|
140
148
|
timeoutMs,
|
|
141
149
|
});
|
|
142
150
|
return response;
|
|
@@ -156,7 +164,7 @@ export function createCaizhilianHost(ctx, dependencies = {}) {
|
|
|
156
164
|
}
|
|
157
165
|
throw caizhilianNetworkError('protocol', `接口返回不是合法 JSON(HTTP ${response.status})`, { retryable: false });
|
|
158
166
|
}
|
|
159
|
-
if (response.status < 200 || response.status >= 300) {
|
|
167
|
+
if (response.status < 200 || response.status >= 300 || (request.requireHttp200 && response.status !== 200)) {
|
|
160
168
|
throw new CaizhilianCliError('api', 'http_error', `采知连接口 HTTP ${response.status}`, {
|
|
161
169
|
code: response.status,
|
|
162
170
|
details: { status: response.status, message: caizhilianBusinessMessage(response.value) },
|
|
@@ -1,192 +1,85 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
1
|
+
/** Weaver file upload integrated with the existing CLI confirmation and authentication boundary. */
|
|
2
|
+
import { caizhilianConfirmationError, caizhilianValidationError } from '../../errors.js';
|
|
3
|
+
import { inspectUploadFile, readUploadContext, RESERVED_CONTEXT_FIELDS, uploadChatId, uploadModule, uploadTimeout } from '../../file-upload/metadata.js';
|
|
4
|
+
import { uploadToFileService } from '../../file-upload/protocol.js';
|
|
5
|
+
import { writeUploadReceipt } from '../../file-upload/receipt.js';
|
|
6
|
+
import { issueWriteLease } from '../confirm.js';
|
|
7
|
+
import { caizhilianPrepared, caizhilianSuccess } from '../types.js';
|
|
8
|
+
const PREFIX = 'caizhilian.file-upload';
|
|
9
|
+
const COMMAND = 'weaver-work-cli --json caizhilian run';
|
|
10
|
+
const CONTEXT_PURPOSES = {
|
|
11
|
+
source: '上传来源场景', fieldId: '附件字段或控件 ID', sourceRefId: '来源业务对象引用 ID',
|
|
12
|
+
authModule: '服务端权限校验模块', customParam: '模块扩展参数', refId: '最终关联对象 ID',
|
|
13
|
+
commonText: '通用场景参数', folderId: '目标文件夹 ID',
|
|
14
|
+
};
|
|
15
|
+
async function getContract() {
|
|
16
|
+
return caizhilianSuccess(`${PREFIX}.contract`, { contract: {
|
|
17
|
+
version: 'agent_callable_interface_contract.v1', authentication: 'weaver-work-cli', automatic_fallback: false,
|
|
18
|
+
upload_strategies: ['second', 'common', 'chunk'],
|
|
19
|
+
actions: ['contract', 'prepare', 'upload.prepare', 'upload.apply'],
|
|
20
|
+
canonical_commands: ['contract', 'prepare', 'upload.prepare', 'upload.apply'].map((name) => `${COMMAND} ${PREFIX}.${name}${name === 'contract' ? '' : ' --input-json <businessInputJson>'}`
|
|
21
|
+
+ (name === 'upload.apply' ? ' --continuation <token> --yes' : '')),
|
|
22
|
+
required: { prepare: ['file_path', 'module'], 'upload.prepare': ['file_path', 'module', 'user_confirmed'],
|
|
23
|
+
'upload.apply': ['file_path', 'module', 'user_confirmed', 'continuation', 'confirm'] },
|
|
24
|
+
optional: { prepare: ['context_file', 'chat_id', 'timeout_seconds'],
|
|
25
|
+
'upload.prepare': ['context_file', 'chat_id', 'timeout_seconds'],
|
|
26
|
+
'upload.apply': ['context_file', 'chat_id', 'timeout_seconds'] },
|
|
27
|
+
user_input_guide: { required: [
|
|
28
|
+
{ name: 'file_path', purpose: '需要上传的本地普通文件绝对路径,直接复用宿主附件路径' },
|
|
29
|
+
{ name: 'module', purpose: '真实业务模块;不得猜测或使用默认模块' },
|
|
30
|
+
], conditionally_required: [], optional: [
|
|
31
|
+
{ name: 'context_file', purpose: '可选业务上下文 JSON 文件绝对路径,最多 64 KiB' },
|
|
32
|
+
{ name: 'chat_id', purpose: '隔离对话回执;默认 cli,由宿主注入' },
|
|
33
|
+
{ name: 'timeout_seconds', purpose: '上传请求及响应读取超时(1–3600 秒,默认 300)' },
|
|
34
|
+
...Object.entries(CONTEXT_PURPOSES).map(([name, purpose]) => ({ name: `context_file.${name}`, purpose })),
|
|
35
|
+
] }, reserved_context_fields: RESERVED_CONTEXT_FIELDS,
|
|
36
|
+
success_evidence: ['business_verified', 'file.file_id', 'receipt_ref', 'receipt_sha256'],
|
|
37
|
+
login: '登录由 weaver-work-cli auth 托管,本模块不读取其他 Skill 登录态或启动独立浏览器',
|
|
38
|
+
} });
|
|
26
39
|
}
|
|
27
|
-
function
|
|
28
|
-
|
|
40
|
+
function requireUploadAuthorization(args) {
|
|
41
|
+
if (args.user_confirmed !== true) {
|
|
42
|
+
throw caizhilianConfirmationError(`${PREFIX}.upload`, { hint: '上传必须来自当前用户明确授权,并传 user_confirmed=true' });
|
|
43
|
+
}
|
|
29
44
|
}
|
|
30
|
-
function
|
|
31
|
-
const
|
|
32
|
-
|
|
45
|
+
async function inspect(args) {
|
|
46
|
+
const module = uploadModule(args.module);
|
|
47
|
+
const chatId = uploadChatId(args.chat_id);
|
|
48
|
+
const timeoutMs = uploadTimeout(args.timeout_seconds);
|
|
49
|
+
const context = await readUploadContext(args.context_file);
|
|
50
|
+
const file = await inspectUploadFile(args.file_path);
|
|
51
|
+
return { file, context, module, chatId, timeoutMs };
|
|
33
52
|
}
|
|
34
|
-
function
|
|
35
|
-
const
|
|
36
|
-
return
|
|
53
|
+
function metadataPreview(prepared) {
|
|
54
|
+
const { file, context, module, chatId } = prepared;
|
|
55
|
+
return { file_path: file.path, file_name: file.name, ext: file.extension, size: file.size,
|
|
56
|
+
md5: file.md5, sha256: file.sha256, module, chat_id: chatId, context_keys: context.keys,
|
|
57
|
+
context_sha256: context.sha256 };
|
|
37
58
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
return caizhilianSuccess(ACTION, {
|
|
41
|
-
contract: {
|
|
42
|
-
version: 'agent_callable_interface_contract.v1',
|
|
43
|
-
actions: ['contract', 'prepare', 'upload'],
|
|
44
|
-
required: {
|
|
45
|
-
prepare: ['file_path', 'module'],
|
|
46
|
-
upload: ['file_path', 'module', 'user_confirmed'],
|
|
47
|
-
},
|
|
48
|
-
optional: {
|
|
49
|
-
prepare: ['context_file'],
|
|
50
|
-
upload: ['context_file', 'login_mode'],
|
|
51
|
-
},
|
|
52
|
-
},
|
|
53
|
-
});
|
|
59
|
+
async function prepareLocal(args) {
|
|
60
|
+
return caizhilianSuccess(`${PREFIX}.prepare`, metadataPreview(await inspect(args)));
|
|
54
61
|
}
|
|
55
|
-
// ── 准备上传(prepare/apply)──────────────────────────────────────
|
|
56
62
|
async function prepareUpload(args, env) {
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
try {
|
|
62
|
-
stat = statSync(filePath);
|
|
63
|
-
}
|
|
64
|
-
catch (error) {
|
|
65
|
-
throw caizhilianValidationError('file_not_found', `文件不存在: ${filePath}`);
|
|
66
|
-
}
|
|
67
|
-
if (!stat.isFile()) {
|
|
68
|
-
throw caizhilianValidationError('file_not_regular', `不是普通文件: ${filePath}`);
|
|
69
|
-
}
|
|
70
|
-
// 计算元数据
|
|
71
|
-
const fileName = basename(filePath);
|
|
72
|
-
const ext = extname(filePath).slice(1).toLowerCase();
|
|
73
|
-
const size = stat.size;
|
|
74
|
-
const md5 = md5File(filePath);
|
|
75
|
-
const sha256 = sha256File(filePath);
|
|
76
|
-
// 签发 continuation
|
|
77
|
-
const continuation = await env.continuation.issue({
|
|
78
|
-
operation: 'caizhilian.file-upload.upload.apply',
|
|
79
|
-
filePath,
|
|
80
|
-
module,
|
|
81
|
-
fileName,
|
|
82
|
-
ext,
|
|
83
|
-
size,
|
|
84
|
-
md5,
|
|
85
|
-
sha256,
|
|
86
|
-
});
|
|
87
|
-
return caizhilianSuccess('caizhilian.file-upload.prepare', {
|
|
88
|
-
file_path: filePath,
|
|
89
|
-
file_name: fileName,
|
|
90
|
-
ext,
|
|
91
|
-
size,
|
|
92
|
-
md5,
|
|
93
|
-
sha256,
|
|
94
|
-
module,
|
|
95
|
-
continuation,
|
|
96
|
-
});
|
|
63
|
+
const prepared = await inspect(args);
|
|
64
|
+
const lease = await issueWriteLease(env, `${PREFIX}.upload.apply`, args, { file_path: prepared.file.path,
|
|
65
|
+
module: prepared.module, file: prepared.file, context_sha256: prepared.context.sha256 });
|
|
66
|
+
return caizhilianPrepared(`${PREFIX}.upload.prepare`, metadataPreview(prepared), lease);
|
|
97
67
|
}
|
|
98
68
|
async function uploadApply(args, env, lease) {
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const ext = lease.payload?.ext;
|
|
103
|
-
const size = lease.payload?.size;
|
|
104
|
-
const md5 = lease.payload?.md5;
|
|
105
|
-
const sha256 = lease.payload?.sha256;
|
|
106
|
-
// 预检查
|
|
107
|
-
const preCheckResponse = await env.host.request({
|
|
108
|
-
method: 'POST',
|
|
109
|
-
path: PRE_UPLOAD_CHECK_PATH,
|
|
110
|
-
body: {
|
|
111
|
-
fileName,
|
|
112
|
-
fileSize: size,
|
|
113
|
-
module,
|
|
114
|
-
md5,
|
|
115
|
-
},
|
|
116
|
-
operation: 'caizhilian.file-upload.upload.apply',
|
|
117
|
-
});
|
|
118
|
-
const preCheckData = asRecord(preCheckResponse);
|
|
119
|
-
if (!preCheckData.status && !preCheckData.success) {
|
|
120
|
-
throw new CaizhilianCliError('api', 'business_error', String(preCheckData.msg || preCheckData.message || '预检查失败'), {
|
|
121
|
-
code: preCheckData.code ?? preCheckData.status,
|
|
122
|
-
details: { action: 'caizhilian.file-upload.upload.apply', message: String(preCheckData.msg || preCheckData.message || '预检查失败') },
|
|
123
|
-
exitCode: 1,
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
const preCheckResult = asRecord(preCheckData.data);
|
|
127
|
-
// 判断上传策略
|
|
128
|
-
const uploadType = preCheckResult.uploadType || 'common';
|
|
129
|
-
const preUploadId = preCheckResult.preUploadId;
|
|
130
|
-
// 如果是 second 策略,直接返回已有 file ID
|
|
131
|
-
if (uploadType === 'second') {
|
|
132
|
-
const fileId = preCheckResult.fileId || preCheckResult.fileid || preCheckResult.uploadModuleDto?.fileObj?.fileId;
|
|
133
|
-
if (!fileId) {
|
|
134
|
-
throw new CaizhilianCliError('api', 'business_error', 'second 策略未返回 file ID', {
|
|
135
|
-
details: { action: 'caizhilian.file-upload.upload.apply' },
|
|
136
|
-
exitCode: 1,
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
return caizhilianSuccess('caizhilian.file-upload.upload.apply', {
|
|
140
|
-
file_id: String(fileId),
|
|
141
|
-
file_name: fileName,
|
|
142
|
-
size,
|
|
143
|
-
ext,
|
|
144
|
-
upload_type: uploadType,
|
|
145
|
-
});
|
|
146
|
-
}
|
|
147
|
-
// common/chunk 策略:上传文件
|
|
148
|
-
const fileContent = readFileSync(filePath);
|
|
149
|
-
const formData = new FormData();
|
|
150
|
-
formData.append('file', new Blob([fileContent]), fileName);
|
|
151
|
-
formData.append('preUploadId', preUploadId || '');
|
|
152
|
-
formData.append('module', module);
|
|
153
|
-
const uploadResponse = await env.host.request({
|
|
154
|
-
method: 'POST',
|
|
155
|
-
path: UPLOAD_PATH,
|
|
156
|
-
body: formData,
|
|
157
|
-
operation: 'caizhilian.file-upload.upload.apply',
|
|
158
|
-
});
|
|
159
|
-
const uploadData = asRecord(uploadResponse);
|
|
160
|
-
if (!uploadData.status && !uploadData.success) {
|
|
161
|
-
throw new CaizhilianCliError('api', 'business_error', String(uploadData.msg || uploadData.message || '上传失败'), {
|
|
162
|
-
code: uploadData.code ?? uploadData.status,
|
|
163
|
-
details: { action: 'caizhilian.file-upload.upload.apply', message: String(uploadData.msg || uploadData.message || '上传失败') },
|
|
164
|
-
exitCode: 1,
|
|
165
|
-
});
|
|
69
|
+
const prepared = await inspect(args);
|
|
70
|
+
if (prepared.file.sha256 !== lease.file?.sha256 || prepared.file.size !== lease.file?.size) {
|
|
71
|
+
throw caizhilianValidationError('file_changed', '文件与已确认内容不一致,请重新准备上传');
|
|
166
72
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
if (!fileId) {
|
|
170
|
-
throw new CaizhilianCliError('api', 'business_error', '上传未返回 file ID', {
|
|
171
|
-
details: { action: 'caizhilian.file-upload.upload.apply' },
|
|
172
|
-
exitCode: 1,
|
|
173
|
-
});
|
|
73
|
+
if (prepared.context.sha256 !== lease.context_sha256) {
|
|
74
|
+
throw caizhilianValidationError('context_changed', '业务上下文与已确认内容不一致,请重新准备上传');
|
|
174
75
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
size,
|
|
179
|
-
ext,
|
|
180
|
-
upload_type: uploadType,
|
|
181
|
-
});
|
|
76
|
+
const verified = await uploadToFileService(env.host, prepared.file, prepared.module, prepared.context, prepared.timeoutMs);
|
|
77
|
+
const result = await writeUploadReceipt(env, prepared.chatId, prepared.module, prepared.file, prepared.context, verified);
|
|
78
|
+
return caizhilianSuccess(`${PREFIX}.upload.apply`, result);
|
|
182
79
|
}
|
|
183
|
-
// ── 处理器汇总 ────────────────────────────────────────────
|
|
184
80
|
export const caizhilianFileUploadHandlers = {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
apply: uploadApply,
|
|
190
|
-
targetKeys: ['file_path', 'module'],
|
|
191
|
-
},
|
|
81
|
+
[`${PREFIX}.contract`]: { execute: getContract },
|
|
82
|
+
[`${PREFIX}.prepare`]: { execute: prepareLocal },
|
|
83
|
+
[`${PREFIX}.upload`]: { validateInput: requireUploadAuthorization, prepare: prepareUpload,
|
|
84
|
+
apply: uploadApply, targetKeys: ['file_path', 'module'] },
|
|
192
85
|
};
|