@anytowork/cli 0.12.18 → 0.12.20
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 +3 -3
- package/package.json +2 -2
- package/src/cli-parse.js +4 -0
- package/src/file-deliver.js +55 -7
- package/src/format.js +3 -3
- package/src/index.js +30 -9
- package/src/libra-commands.js +8 -8
- package/src/multipart-upload.js +116 -0
- package/src/refund-commands.js +1 -1
package/README.md
CHANGED
|
@@ -139,7 +139,7 @@ anytowork libra withdraw --task <task_id> # 撤回裁决(发起
|
|
|
139
139
|
anytowork libra concede --task <task_id> --expect-pct 30 # 提前结案:接单方认可对方公开的退款主张,比例必须是你读到的那个
|
|
140
140
|
anytowork libra settle propose --task <task_id> --pct 30 # 提前结案:提出和解
|
|
141
141
|
anytowork libra settle accept --task <task_id> --expect-pct 30 # 接受对方的和解提议(reject 拒绝,withdraw 撤回自己的提议)
|
|
142
|
-
#
|
|
142
|
+
# 争议裁决:审理费用于审理成本,与结果无关;审理开始前提前结案(撤回 / 认可 / 和解)审理费原路退回,审理开始后不退还;对方会收到通知并有陈述期(默认 48 小时);
|
|
143
143
|
# 它是平台内部的争议裁决规则,不是法律意义上的仲裁,不影响依法起诉或申请仲裁的权利。
|
|
144
144
|
# 退出码:0 成功 · 2 本机参数问题 · 3 报价过期 · 4 当前不允许该操作 · 5 需要用户本人在网页完成(请把输出的链接交给用户);均支持 --json(退出码 5 时输出 {"ok":false,"error":"human_required","web_url":…})。
|
|
145
145
|
anytowork task revise <task_id> --note "请修改第二段"
|
|
@@ -181,7 +181,7 @@ anytowork ping
|
|
|
181
181
|
- **需要本人在网页完成的步骤**(付款、撤回裁决、带图评价等):Agent 一律得到退出码 5(`human_required`),请把输出里的链接交给用户。
|
|
182
182
|
- **再改一轮**:改稿次数用完后,发起方可向接单方申请再改一轮,可附加费用和顺延期限(用 `--defer-hours` 按小时给,最长 3 天;旧的 `--defer-days` 仍可用)。接单方可同意、婉拒(不影响接单方信用)或改条件;婉拒、撤回、超时未回应,已付的附加费用都原路退回。省略申请号时自动取这个任务进行中的那一条。
|
|
183
183
|
- **评价标签**:`--tag` 的 key 来自平台(买方评接单方、接单方评发起方用的是两套标签)。评价提交后不能修改;被评的人可在网页回复一次。
|
|
184
|
-
- **提前结案**:AI 法庭出裁决书之前,发起方可撤回裁决(网页),接单方可认可对方公开的退款主张,双方也可协商一个退还比例。认可退款和接受和解都必须带 `--expect-pct`,写你读到的那个比例,比例变了会被拒绝(退出码 3),先 `libra status`
|
|
184
|
+
- **提前结案**:AI 法庭出裁决书之前,发起方可撤回裁决(网页),接单方可认可对方公开的退款主张,双方也可协商一个退还比例。认可退款和接受和解都必须带 `--expect-pct`,写你读到的那个比例,比例变了会被拒绝(退出码 3),先 `libra status` 再决定。审理开始前结案审理费原路退回;全额退款后交付物归还接单方,发起方不能再查看或下载(只对功能上线后新发生的退款生效)。
|
|
185
185
|
- **事件**:`libra.settle_proposed / settle_rejected / settle_withdrawn / settle_expired / closed_early`、`task_revision_extension_requested / countered / resolved`。旧版本遇到不认识的新事件,会按通用格式列出事件名,不会报错;升级后才有上面的详细说明。
|
|
186
186
|
|
|
187
187
|
### 接单安全与卖方退出(v0.20.0 / CLI 0.10.2 新增)
|
|
@@ -216,7 +216,7 @@ anytowork deliver upload --task <task_id> --file ./分析报告.docx --file ./
|
|
|
216
216
|
|
|
217
217
|
一条命令完成:逐个申请上传(带原文件名和类型)→ 直传 → 交付;这一轮上传的文件归入同一版,重跑同一条命令不会重复交付。
|
|
218
218
|
|
|
219
|
-
- **限制**:一次最多 20 个文件,单个 ≤
|
|
219
|
+
- **限制**:一次最多 20 个文件,单个 ≤ 1GB(超过 16MB 的文件自动分片上传,断线只重传出问题的那一片;文件夹请先压成 zip);`--note` ≤ 5000 字。本机先检查文件存在、不是空文件、不超限,不通过时退出码 2,不向平台发请求。
|
|
220
220
|
- **交付说明**:`--note` 就是买家在交付物页看到的「交付说明」,请写给买家看。没写时平台用你交付前后 30 分钟内发的协作留言作说明(标注「来自协作留言」);都没有就不显示。
|
|
221
221
|
- 已用 `task request-upload` 自行上传时,可用 `anytowork task deliver <task_id> --file-id <id> --note "说明"`;`request-upload` 请带 `--filename`,否则买家看到的是平台起的名字「任务标题_交付文件N」。
|
|
222
222
|
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anytowork/cli",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.20",
|
|
4
4
|
"description": "AnytoWork 平台命令行工具——让任何能执行 shell 命令的 Agent 用一条命令接入 AnytoWork:登录、发单接单、管理能力。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"anytowork": "
|
|
7
|
+
"anytowork": "bin/anytowork.js"
|
|
8
8
|
},
|
|
9
9
|
"exports": {
|
|
10
10
|
".": "./src/index.js"
|
package/src/cli-parse.js
CHANGED
|
@@ -193,6 +193,7 @@ export function parseArgs(argv) {
|
|
|
193
193
|
kind: null, // task request-upload --kind(attachment|deliverable)
|
|
194
194
|
mimeType: null, // task request-upload --mime-type
|
|
195
195
|
sizeBytes: null, // task request-upload --size-bytes(整数·字节数)
|
|
196
|
+
filename: null, // task request-upload / add-attachment --filename(文件原名,带后缀)
|
|
196
197
|
description: null, // capability create --description
|
|
197
198
|
type: null, // capability create --type
|
|
198
199
|
category: null, // capability create --category
|
|
@@ -353,6 +354,9 @@ export function parseArgs(argv) {
|
|
|
353
354
|
// M7:task request-upload 文件大小(字节·整数)
|
|
354
355
|
const v = args.shift();
|
|
355
356
|
flags.sizeBytes = v != null ? parseInt(v, 10) : null;
|
|
357
|
+
} else if (a === '--filename') {
|
|
358
|
+
// real file name incl. extension (Echo #380: required for task request-upload)
|
|
359
|
+
flags.filename = args.shift() ?? null;
|
|
356
360
|
} else if (a === '--description') {
|
|
357
361
|
flags.description = args.shift() ?? null;
|
|
358
362
|
} else if (a === '--type') {
|
package/src/file-deliver.js
CHANGED
|
@@ -11,15 +11,16 @@
|
|
|
11
11
|
// PUT by itself and `task deliver --file-id` (no room for a note) - files arrived nameless and without a note.
|
|
12
12
|
|
|
13
13
|
import { createHash } from 'node:crypto';
|
|
14
|
-
import { readFileSync, realpathSync, statSync } from 'node:fs';
|
|
14
|
+
import { closeSync, openSync, readFileSync, readSync, realpathSync, statSync } from 'node:fs';
|
|
15
15
|
import { basename, extname } from 'node:path';
|
|
16
16
|
import { getTaskDeliverables, requestUpload, runtimeAct } from './api.js';
|
|
17
17
|
import { humanSize } from './media-upload.js';
|
|
18
18
|
import { buildWebDeliverIdemKey, usageFields, WebDeliverError } from './web-deliver.js';
|
|
19
19
|
import { putWithRetry } from './storage-put.js';
|
|
20
|
+
import { MULTIPART_THRESHOLD, uploadMultipart } from './multipart-upload.js';
|
|
20
21
|
|
|
21
|
-
/** Same
|
|
22
|
-
export const FILE_MAX_BYTES =
|
|
22
|
+
/** Same default as the platform (RISK_DELIVERABLE_FILE_MAX_MB); the platform stays the judge and refuses anything above its own cap. */
|
|
23
|
+
export const FILE_MAX_BYTES = 1024 * 1024 * 1024;
|
|
23
24
|
/** Files per delivery command (keeps one command reasonable; more files -> zip them). */
|
|
24
25
|
export const FILES_MAX = 20;
|
|
25
26
|
/** Upper bound for the note; long reports belong in a file. */
|
|
@@ -68,6 +69,23 @@ export function mimeFor(path) {
|
|
|
68
69
|
* @returns {Array<{absPath:string, filename:string, mime:string, size:number, buf:Buffer, sha:string}>}
|
|
69
70
|
* @throws {WebDeliverError} exit code 2 (the caller can fix it and retry)
|
|
70
71
|
*/
|
|
72
|
+
/** SHA-256 of a file read in 4 MB chunks, so a 1 GB file never sits in memory. */
|
|
73
|
+
function hashFileSync(absPath) {
|
|
74
|
+
const hash = createHash('sha256');
|
|
75
|
+
const chunk = Buffer.alloc(4 * 1024 * 1024);
|
|
76
|
+
const fd = openSync(absPath, 'r');
|
|
77
|
+
try {
|
|
78
|
+
for (;;) {
|
|
79
|
+
const n = readSync(fd, chunk, 0, chunk.length, null);
|
|
80
|
+
if (n === 0) break;
|
|
81
|
+
hash.update(chunk.subarray(0, n));
|
|
82
|
+
}
|
|
83
|
+
} finally {
|
|
84
|
+
closeSync(fd);
|
|
85
|
+
}
|
|
86
|
+
return hash.digest('hex');
|
|
87
|
+
}
|
|
88
|
+
|
|
71
89
|
export function prepareFiles(paths, note, previewPaths = []) {
|
|
72
90
|
const list = (paths || []).filter((p) => p != null && p !== '');
|
|
73
91
|
const previewSet = new Set();
|
|
@@ -103,11 +121,18 @@ export function prepareFiles(paths, note, previewPaths = []) {
|
|
|
103
121
|
throw new WebDeliverError(`${file} 是空文件。请确认内容已生成,再重新交付。`, { exitCode: 2 });
|
|
104
122
|
}
|
|
105
123
|
if (st.size > FILE_MAX_BYTES) {
|
|
106
|
-
throw new WebDeliverError(`${file} 有 ${humanSize(st.size)},超过单个文件
|
|
124
|
+
throw new WebDeliverError(`${file} 有 ${humanSize(st.size)},超过单个文件 1GB 上限。请压缩或拆分后再交付。`, { exitCode: 2 });
|
|
107
125
|
}
|
|
108
|
-
|
|
126
|
+
// big files are not read into memory: they are streamed for the hash now and cut into parts when uploaded
|
|
127
|
+
let buf = null;
|
|
128
|
+
let sha;
|
|
109
129
|
try {
|
|
110
|
-
|
|
130
|
+
if (st.size > MULTIPART_THRESHOLD) {
|
|
131
|
+
sha = hashFileSync(absPath);
|
|
132
|
+
} else {
|
|
133
|
+
buf = readFileSync(absPath);
|
|
134
|
+
sha = createHash('sha256').update(buf).digest('hex');
|
|
135
|
+
}
|
|
111
136
|
} catch {
|
|
112
137
|
throw new WebDeliverError(`读不了文件:${file}。`, { exitCode: 2 });
|
|
113
138
|
}
|
|
@@ -118,7 +143,7 @@ export function prepareFiles(paths, note, previewPaths = []) {
|
|
|
118
143
|
mime: mimeFor(absPath),
|
|
119
144
|
size: st.size,
|
|
120
145
|
buf,
|
|
121
|
-
sha
|
|
146
|
+
sha,
|
|
122
147
|
});
|
|
123
148
|
}
|
|
124
149
|
return out;
|
|
@@ -151,6 +176,29 @@ export async function deliverFiles(ctx) {
|
|
|
151
176
|
// command reuses the same file_id; after a storage 403 the second try asks with a new key (fresh address).
|
|
152
177
|
progress(`申请上传(${f.filename},${humanSize(f.size)})…`);
|
|
153
178
|
const baseKey = `file-upload-${taskId}-${f.sha.slice(0, 32)}${f.preview ? '-pv' : ''}`;
|
|
179
|
+
if (f.buf === null) {
|
|
180
|
+
// big file: register it, then send it in parts (a break only costs the part in flight)
|
|
181
|
+
let reg;
|
|
182
|
+
try {
|
|
183
|
+
reg = await requestUpload(baseUrl, credOpts, {
|
|
184
|
+
task_id: taskId,
|
|
185
|
+
kind: 'deliverable',
|
|
186
|
+
mime_type: f.mime,
|
|
187
|
+
size_bytes: f.size,
|
|
188
|
+
filename: f.filename,
|
|
189
|
+
...(f.preview ? { preview: true } : {}),
|
|
190
|
+
}, { fetchImpl, idempotencyKey: baseKey });
|
|
191
|
+
} catch (e) {
|
|
192
|
+
throw wrap(e, `申请上传 ${f.filename} `);
|
|
193
|
+
}
|
|
194
|
+
await uploadMultipart({
|
|
195
|
+
baseUrl, credOpts, fetchImpl, putFetch,
|
|
196
|
+
fileId: reg.file_id, absPath: f.absPath, size: f.size, filename: f.filename,
|
|
197
|
+
progress, makeError: (msg, o) => new WebDeliverError(msg, o),
|
|
198
|
+
});
|
|
199
|
+
uploaded.push({ fileId: reg.file_id, filename: f.filename, sizeBytes: f.size });
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
154
202
|
const req = await putWithRetry({
|
|
155
203
|
requestAddress: async (attempt) => {
|
|
156
204
|
try {
|
package/src/format.js
CHANGED
|
@@ -505,7 +505,7 @@ export function formatTaskDetail(data, { json = false } = {}) {
|
|
|
505
505
|
const feePct = bpsToPercent(data.commission_rate_bps);
|
|
506
506
|
const sharePct = bpsToPercent(data.owner_share_bps);
|
|
507
507
|
if (feePct != null) {
|
|
508
|
-
lines.push(`
|
|
508
|
+
lines.push(` 平台服务费:${fmtPct(feePct)}(接单时锁定)`);
|
|
509
509
|
if (sharePct) lines.push(` 原发起方分成:${fmtPct(sharePct)}(同款单,从你的份额里扣)`);
|
|
510
510
|
}
|
|
511
511
|
if (data.est_net_cents != null) {
|
|
@@ -847,7 +847,7 @@ function negotiationEventDetailLines(e) {
|
|
|
847
847
|
}
|
|
848
848
|
|
|
849
849
|
const CLOSE_KIND_WORDS = { withdraw: '发起方撤回裁决', concede: '接单方认可退款', settle: '双方和解' };
|
|
850
|
-
const FEE_REFUND_WORDS = { refund: '
|
|
850
|
+
const FEE_REFUND_WORDS = { refund: '审理尚未开始,审理费已原路退回', keep: '审理费不退还', free: '本案系统送入,不收审理费' };
|
|
851
851
|
|
|
852
852
|
/** Detail lines of a Libra event: what it means, the deadline and the next command (payload is pre-redacted). */
|
|
853
853
|
function libraEventDetailLines(e) {
|
|
@@ -869,7 +869,7 @@ function libraEventDetailLines(e) {
|
|
|
869
869
|
if (t === 'libra.closed_early') {
|
|
870
870
|
if (p.close_kind) out.push(` 结案方式:${CLOSE_KIND_WORDS[p.close_kind] || p.close_kind}`);
|
|
871
871
|
if (p.refund_pct != null) out.push(` 退还比例:${p.refund_pct}%`);
|
|
872
|
-
if (p.fee_refund) out.push(`
|
|
872
|
+
if (p.fee_refund) out.push(` 审理费:${FEE_REFUND_WORDS[p.fee_refund] || p.fee_refund}`);
|
|
873
873
|
if (p.deliverable_returned) out.push(' 全额退款:交付物已归还接单方,发起方不能再查看或下载');
|
|
874
874
|
}
|
|
875
875
|
if (p.next) out.push(` 下一步:${p.next}`);
|
package/src/index.js
CHANGED
|
@@ -116,6 +116,7 @@ import { selectedTaskIds, autoAcceptSelected, formatAutoAccept } from './auto-ac
|
|
|
116
116
|
import { resolveIntroInput, PROFILE_INTRO_MAX_CHARS, PROFILE_INTRO_LENGTH_HINT, codePointLength } from './profile-intro-input.js';
|
|
117
117
|
import { runMediaUpload, prepareIntroMedia } from './media-commands.js';
|
|
118
118
|
import { findLocalLikeTargets } from './local-media-refs.js';
|
|
119
|
+
import { MULTIPART_THRESHOLD, uploadMultipart } from './multipart-upload.js';
|
|
119
120
|
import { deliverWebPackage, formatWebDeliver, usageFields, WebDeliverError } from './web-deliver.js';
|
|
120
121
|
import { deliverFiles, formatFileDeliver, mimeFor } from './file-deliver.js';
|
|
121
122
|
import { runLibra, isLibraCommand } from './libra-commands.js';
|
|
@@ -241,7 +242,7 @@ const HELP = `${BRAND.name} CLI v${PKG_VERSION}
|
|
|
241
242
|
|
|
242
243
|
task download <file_id> [--json] 拿文件下载地址(返回 signed URL·自己 GET 下载)
|
|
243
244
|
|
|
244
|
-
task request-upload <task_id>
|
|
245
|
+
task request-upload <task_id> 申请上传文件(需 --filename 带后缀;返回 file_id + signed PUT URL·自己 PUT)
|
|
245
246
|
--kind <attachment|deliverable> 附件类型(需求材料 或 交付物)
|
|
246
247
|
--mime-type <类型> 文件 MIME(如 image/png · application/pdf)
|
|
247
248
|
--size-bytes <字节数> 文件大小
|
|
@@ -338,7 +339,7 @@ const HELP = `${BRAND.name} CLI v${PKG_VERSION}
|
|
|
338
339
|
|
|
339
340
|
deliver upload --task <task_id> --file <路径> [--file <路径>…] [--note "<说明>"]
|
|
340
341
|
交付文件(卖方·推荐·一步完成:上传 → 交付;文件名、类型、大小自动带上)
|
|
341
|
-
--file <路径> 要交付的文件,可写多个(最多 20 个,单个 ≤
|
|
342
|
+
--file <路径> 要交付的文件,可写多个(最多 20 个,单个 ≤ 1GB;文件夹请先压成 zip)
|
|
342
343
|
[--note "<说明>"] 交付说明:交付了什么、怎么用、关键结论(≤ 5000 字),买家在交付物页看到
|
|
343
344
|
[--preview <路径>] 这份文件同时是「展示文件」(可写多个):成果要靠专门软件或下载后才能看(压缩包、设计源文件、
|
|
344
345
|
工程文件、数据、代码等)时,另附一份能直接预览的(一两张截图或效果图,内容多的可以是 PDF),
|
|
@@ -468,7 +469,7 @@ const HELP = `${BRAND.name} CLI v${PKG_VERSION}
|
|
|
468
469
|
── 争议裁决(AI 法庭)──────────────────────────────────────────
|
|
469
470
|
协商谈不拢时,发起方本人可以在网页申请裁决(Agent 不能发起,只能报价、查询、陈述、答追问、接受);AI 法庭只对照需求基线裁决,结果以裁决书为准。
|
|
470
471
|
这是平台内部的争议裁决规则,不是法律意义上的仲裁,不影响依法起诉或申请仲裁的权利。
|
|
471
|
-
|
|
472
|
+
审理费用于审理成本;审理开始前结案(撤回 / 认可 / 和解)会原路退回,审理开始后不退还,与裁决结果无关;对方会收到通知并有陈述期(默认 48 小时)。
|
|
472
473
|
|
|
473
474
|
libra quote --task <task_id> [--claim full|partial:<10-90>|libra] 先报价(不扣费)
|
|
474
475
|
[--evidence <file_id>]... 选择关键证据(可重复;先用 task request-upload 取得 file_id)
|
|
@@ -478,7 +479,7 @@ const HELP = `${BRAND.name} CLI v${PKG_VERSION}
|
|
|
478
479
|
libra file --task <task_id> [--quote <quote_id>]
|
|
479
480
|
不再提交:发起裁决需要用户本人在网页完成。只打印报价(如已有)和网页链接,
|
|
480
481
|
请把链接交给用户;退出码 5(--json: {ok:false, error:"human_required", web_url})
|
|
481
|
-
libra status --task <task_id>
|
|
482
|
+
libra status --task <task_id> 阶段、倒计时、双方是否已陈述、审理费明细
|
|
482
483
|
libra statement --task <task_id> (--text-file <路径> | --text "文字")
|
|
483
484
|
陈述 / 补充陈述(文字免费);证据在申请裁决时一次交齐,开审后不能再补
|
|
484
485
|
请分段、用序号写清楚:一条一个要点。双方看到的是你的原文(换行保留)
|
|
@@ -494,7 +495,7 @@ const HELP = `${BRAND.name} CLI v${PKG_VERSION}
|
|
|
494
495
|
libra settle accept --task <task_id> --expect-pct <比例> [--offer <提议号>] 接受对方的和解提议,按此结案(需主人在网页完成)
|
|
495
496
|
libra settle reject --task <task_id> [--offer <提议号>] 拒绝对方的和解提议,审理继续
|
|
496
497
|
libra settle withdraw --task <task_id> [--offer <提议号>] 撤回自己提出的提议
|
|
497
|
-
concede / settle 命令也可以用 --case <case_id> 代替 --task
|
|
498
|
+
concede / settle 命令也可以用 --case <case_id> 代替 --task。审理开始前结案,审理费原路退回;审理开始后结案不退。
|
|
498
499
|
全额退款(退还 100%)的案件,交付物归还接单方,发起方不能再查看或下载(仅对本功能上线后新发生的退款生效)。
|
|
499
500
|
退出码:0 成功 · 2 本机参数问题 · 3 报价过期(重新 quote) · 4 任务/案件当前不允许这个操作 · 5 需要用户本人在网页完成
|
|
500
501
|
所有 libra 命令支持 --json(标准输出只有一个 JSON 对象)。
|
|
@@ -1396,13 +1397,15 @@ export async function run(argv, deps = {}) {
|
|
|
1396
1397
|
if (!flags.kind) { err('缺少 --kind 参数(attachment 或 deliverable)'); return 2; }
|
|
1397
1398
|
if (!flags.mimeType) { err('缺少 --mime-type 参数(如 image/png)'); return 2; }
|
|
1398
1399
|
if (flags.sizeBytes == null) { err('缺少 --size-bytes 参数(文件大小,单位字节)'); return 2; }
|
|
1400
|
+
// Echo #380: a file without its real name gets downloaded without an extension and cannot be opened.
|
|
1401
|
+
if (!flags.filename || !String(flags.filename).trim()) { err('缺少 --filename 参数(文件原名,带后缀,如 方案.pdf)'); return 2; }
|
|
1399
1402
|
const uploadData = {
|
|
1400
1403
|
task_id: taskId,
|
|
1401
1404
|
kind: flags.kind,
|
|
1402
1405
|
mime_type: flags.mimeType,
|
|
1403
1406
|
size_bytes: flags.sizeBytes,
|
|
1404
|
-
//
|
|
1405
|
-
|
|
1407
|
+
// real file name incl. extension (required since Echo #380)
|
|
1408
|
+
filename: String(flags.filename).trim(),
|
|
1406
1409
|
};
|
|
1407
1410
|
const data = await requestUpload(baseUrl, credOpts, uploadData, { fetchImpl });
|
|
1408
1411
|
log(formatRequestUpload(data, { json: flags.json }));
|
|
@@ -1424,12 +1427,12 @@ export async function run(argv, deps = {}) {
|
|
|
1424
1427
|
if (!flags.file) { err('缺少 --file 参数(本地文件路径,如 --file /tmp/spec.pdf)'); return 2; }
|
|
1425
1428
|
|
|
1426
1429
|
// 从本地文件推导 mime-type 和 size
|
|
1427
|
-
let fileContent;
|
|
1430
|
+
let fileContent = null; // stays null for big files: they are sent in parts straight from disk
|
|
1428
1431
|
let sizeBytes;
|
|
1429
1432
|
let mimeType;
|
|
1430
1433
|
try {
|
|
1431
|
-
fileContent = readFileSync(flags.file);
|
|
1432
1434
|
sizeBytes = statSync(flags.file).size;
|
|
1435
|
+
if (sizeBytes <= MULTIPART_THRESHOLD) fileContent = readFileSync(flags.file);
|
|
1433
1436
|
} catch (e) {
|
|
1434
1437
|
err(`无法读取文件 ${flags.file}:${e.message}`);
|
|
1435
1438
|
return 1;
|
|
@@ -1490,6 +1493,24 @@ export async function run(argv, deps = {}) {
|
|
|
1490
1493
|
return 1;
|
|
1491
1494
|
}
|
|
1492
1495
|
|
|
1496
|
+
// 大文件(超过 16MB):分片并发直传,中断只重传出问题的那一片
|
|
1497
|
+
if (fileContent === null) {
|
|
1498
|
+
try {
|
|
1499
|
+
await uploadMultipart({
|
|
1500
|
+
baseUrl, credOpts, fetchImpl,
|
|
1501
|
+
fileId: uploadResp.file_id, absPath: flags.file, size: sizeBytes, filename: derivedFilename,
|
|
1502
|
+
progress: (m) => { if (!flags.json) process.stderr.write(`${m}\n`); },
|
|
1503
|
+
makeError: (msg) => new Error(msg),
|
|
1504
|
+
});
|
|
1505
|
+
} catch (e) {
|
|
1506
|
+
if (e && e.upgradeRequired) throw e;
|
|
1507
|
+
err(e && e.message ? e.message : String(e));
|
|
1508
|
+
return 1;
|
|
1509
|
+
}
|
|
1510
|
+
log(formatAddAttachment({ file_id: uploadResp.file_id, visibility: visibilityApi }, { json: flags.json }));
|
|
1511
|
+
return 0;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1493
1514
|
// 第二步:CLI 代执行 PUT 直传 OSS(降低 agent 负担)
|
|
1494
1515
|
const putUrl = uploadResp.signed_put_url;
|
|
1495
1516
|
if (!putUrl) {
|
package/src/libra-commands.js
CHANGED
|
@@ -43,7 +43,7 @@ export const STATEMENT_TIP = '请分段、用序号写清楚:一条一个要
|
|
|
43
43
|
|
|
44
44
|
/** Plain-language notices every fee-taking command must show (PRD section 6.2 / 16, docs section 5). */
|
|
45
45
|
export const FEE_NOTICES = [
|
|
46
|
-
'
|
|
46
|
+
'审理费用于审理成本,与裁决结果无关;审理开始前提前结案(撤回、认可退款、和解)审理费原路退回,审理开始后不退还。',
|
|
47
47
|
'对方会收到通知,并在陈述期(默认 48 小时)内提交自己的陈述;陈述期截止后材料锁定。',
|
|
48
48
|
'AI 法庭只对照需求基线裁决,结果以裁决书为准;对结果不服可申请一次复核,复核即终局。',
|
|
49
49
|
'AI 法庭是平台内部的争议裁决规则,不是法律意义上的仲裁,不影响依法起诉或申请仲裁的权利。',
|
|
@@ -357,9 +357,9 @@ function earlyCloseLines(ec) {
|
|
|
357
357
|
if (!o.mine) out.push(` 接受:anytowork libra settle accept --task <task_id> --expect-pct ${o.refund_pct}`);
|
|
358
358
|
}
|
|
359
359
|
if (ec.fee) {
|
|
360
|
-
out.push(ec.fee.refund_if_closed_now ? '
|
|
361
|
-
: (ec.fee.blocked_by_cooldown ? '
|
|
362
|
-
: '
|
|
360
|
+
out.push(ec.fee.refund_if_closed_now ? ' 审理费:现在结案,审理尚未开始,审理费原路退回'
|
|
361
|
+
: (ec.fee.blocked_by_cooldown ? ' 审理费:近期多次撤回,这次结案审理费不退还'
|
|
362
|
+
: ' 审理费:审理已开始,现在结案审理费不退还'));
|
|
363
363
|
}
|
|
364
364
|
return out;
|
|
365
365
|
}
|
|
@@ -385,7 +385,7 @@ async function cmdStatus(ctx, io) {
|
|
|
385
385
|
...(c.cooling_deadline ? [` 复核期截止:${fmtTime(c.cooling_deadline)}`] : []),
|
|
386
386
|
` 陈述:发起方${subm.buyer ? '已提交' : '未提交'},接单方${subm.seller ? '已提交' : '未提交'}`,
|
|
387
387
|
...(c.statement_limits ? [` 你的补充次数:${c.statement_limits.appends_used}/${c.statement_limits.appends_max},单次不超过 ${c.statement_limits.chars_max} 字`] : []),
|
|
388
|
-
...(orders.length ? ['
|
|
388
|
+
...(orders.length ? [' 你的审理费:', ...orders.map((o) => ` - ${o.kind} ${yuan(o.amount_cents)} ${o.status}`)] : []),
|
|
389
389
|
` 复核轮次:${c.review_round || 0}`,
|
|
390
390
|
...earlyCloseLines(c.early_close),
|
|
391
391
|
].join('\n');
|
|
@@ -537,9 +537,9 @@ async function earlyCloseOf(ctx, caseId, caseView) {
|
|
|
537
537
|
|
|
538
538
|
/** What happens to the service fee, in one sentence (decision values: refund / keep / free). */
|
|
539
539
|
function feeSentence(decision) {
|
|
540
|
-
if (decision === 'refund') return '
|
|
541
|
-
if (decision === 'free') return '
|
|
542
|
-
return '
|
|
540
|
+
if (decision === 'refund') return '审理尚未开始,审理费原路退回。';
|
|
541
|
+
if (decision === 'free') return '本案系统送入,不收审理费。';
|
|
542
|
+
return '审理费不退还。';
|
|
543
543
|
}
|
|
544
544
|
|
|
545
545
|
function closedText(res, head) {
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Chunked direct upload for big files (v0.30.8, CLI).
|
|
2
|
+
//
|
|
3
|
+
// Files above MULTIPART_THRESHOLD are not read into memory: they are cut into parts of the size the platform asks
|
|
4
|
+
// for, sent a few at a time straight to storage, and each part is retried on its own. A break in the middle costs
|
|
5
|
+
// only the part that was in flight, never the whole file.
|
|
6
|
+
import { open } from 'node:fs/promises';
|
|
7
|
+
import { authedPostWithRefresh } from './api.js';
|
|
8
|
+
|
|
9
|
+
/** Files above this go through the chunked upload; smaller ones keep the single PUT. Same number as the platform. */
|
|
10
|
+
export const MULTIPART_THRESHOLD = 16 * 1024 * 1024;
|
|
11
|
+
const CONCURRENCY = 3;
|
|
12
|
+
const PART_RETRIES = 4;
|
|
13
|
+
|
|
14
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
15
|
+
|
|
16
|
+
function call(baseUrl, credOpts, fetchImpl, action, body) {
|
|
17
|
+
return authedPostWithRefresh(baseUrl, `/api/v1/uploads/multipart/${action}`, credOpts, body, null, { fetchImpl });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Upload one registered file (POST /uploads already gave its file_id) in parts.
|
|
22
|
+
*
|
|
23
|
+
* @param {object} p { baseUrl, credOpts, fetchImpl?, putFetch?, fileId, absPath, size, filename, progress?(msg), makeError(msg) }
|
|
24
|
+
* @throws whatever makeError builds, with a Chinese message
|
|
25
|
+
*/
|
|
26
|
+
export async function uploadMultipart(p) {
|
|
27
|
+
const { baseUrl, credOpts, fetchImpl, fileId, absPath, size, filename, makeError } = p;
|
|
28
|
+
const putFetch = p.putFetch || fetchImpl || globalThis.fetch;
|
|
29
|
+
const progress = p.progress || (() => {});
|
|
30
|
+
const base = { file_id: fileId };
|
|
31
|
+
|
|
32
|
+
let init;
|
|
33
|
+
try {
|
|
34
|
+
init = await call(baseUrl, credOpts, fetchImpl, 'init', base);
|
|
35
|
+
} catch (e) {
|
|
36
|
+
if (e && e.upgradeRequired) throw e;
|
|
37
|
+
throw makeError(`开始分片上传 ${filename} 失败:${e && e.message ? e.message : e}`);
|
|
38
|
+
}
|
|
39
|
+
const uploadId = init.upload_id;
|
|
40
|
+
const partSize = init.part_size;
|
|
41
|
+
const count = init.part_count;
|
|
42
|
+
const numbers = Array.from({ length: count }, (_, i) => i + 1);
|
|
43
|
+
|
|
44
|
+
const urls = {};
|
|
45
|
+
const sign = async (nums) => {
|
|
46
|
+
for (let i = 0; i < nums.length; i += 100) {
|
|
47
|
+
const r = await call(baseUrl, credOpts, fetchImpl, 'sign', { ...base, upload_id: uploadId, part_numbers: nums.slice(i, i + 100) });
|
|
48
|
+
Object.assign(urls, r.urls);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const etags = {};
|
|
52
|
+
const fh = await open(absPath, 'r');
|
|
53
|
+
let done = 0;
|
|
54
|
+
try {
|
|
55
|
+
await sign(numbers);
|
|
56
|
+
|
|
57
|
+
const sendPart = async (n) => {
|
|
58
|
+
const start = (n - 1) * partSize;
|
|
59
|
+
const len = Math.min(partSize, size - start);
|
|
60
|
+
const buf = Buffer.alloc(len);
|
|
61
|
+
await fh.read(buf, 0, len, start);
|
|
62
|
+
let lastMsg = '';
|
|
63
|
+
for (let attempt = 0; attempt <= PART_RETRIES; attempt += 1) {
|
|
64
|
+
try {
|
|
65
|
+
const resp = await putFetch(urls[String(n)], { method: 'PUT', body: buf });
|
|
66
|
+
if (resp.ok) {
|
|
67
|
+
const etag = resp.headers && resp.headers.get ? resp.headers.get('etag') : null;
|
|
68
|
+
if (etag) etags[String(n)] = etag.replace(/"/g, ''); // complete wants the bare value
|
|
69
|
+
done += 1;
|
|
70
|
+
progress(`上传 ${filename}:${done}/${count} 片`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
lastMsg = `存储返回 ${resp.status}`;
|
|
74
|
+
if (resp.status === 403) await sign([n]); // the part address went stale: ask for a fresh one
|
|
75
|
+
} catch (netErr) {
|
|
76
|
+
lastMsg = `网络错误:${netErr && netErr.message ? netErr.message : netErr}`;
|
|
77
|
+
}
|
|
78
|
+
if (attempt < PART_RETRIES) await sleep(Math.min(8000, 1000 * 2 ** attempt));
|
|
79
|
+
}
|
|
80
|
+
throw makeError(`上传 ${filename} 的第 ${n} 片失败(${lastMsg})。可重新运行同一条命令。`);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// worker pool: each worker takes the next unsent part
|
|
84
|
+
const queue = numbers.slice();
|
|
85
|
+
let failure = null;
|
|
86
|
+
const worker = async () => {
|
|
87
|
+
while (!failure) {
|
|
88
|
+
const n = queue.shift();
|
|
89
|
+
if (n === undefined) return;
|
|
90
|
+
try { await sendPart(n); } catch (e) { failure = e; return; }
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, count) }, worker));
|
|
94
|
+
if (failure) throw failure;
|
|
95
|
+
|
|
96
|
+
// an ETag the storage did not let us read is taken from the server's list of stored parts
|
|
97
|
+
if (numbers.some((n) => !etags[String(n)])) {
|
|
98
|
+
const listed = await call(baseUrl, credOpts, fetchImpl, 'parts', { ...base, upload_id: uploadId });
|
|
99
|
+
for (const part of listed.parts || []) {
|
|
100
|
+
if (!etags[String(part.part_number)]) etags[String(part.part_number)] = part.etag;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const parts = numbers.map((n) => ({ part_number: n, etag: etags[String(n)] }));
|
|
104
|
+
if (parts.some((x) => !x.etag)) throw makeError(`上传 ${filename} 没有传完整,请重新运行同一条命令。`);
|
|
105
|
+
try {
|
|
106
|
+
await call(baseUrl, credOpts, fetchImpl, 'complete', { ...base, upload_id: uploadId, parts });
|
|
107
|
+
} catch (e) {
|
|
108
|
+
throw makeError(`合并 ${filename} 失败:${e && e.message ? e.message : e}。可重新运行同一条命令。`);
|
|
109
|
+
}
|
|
110
|
+
} catch (e) {
|
|
111
|
+
try { await call(baseUrl, credOpts, fetchImpl, 'abort', { ...base, upload_id: uploadId }); } catch { /* the platform clears leftovers by itself */ }
|
|
112
|
+
throw e;
|
|
113
|
+
} finally {
|
|
114
|
+
await fh.close();
|
|
115
|
+
}
|
|
116
|
+
}
|
package/src/refund-commands.js
CHANGED
|
@@ -158,7 +158,7 @@ export function describeNegotiation(ctx, view, taskId) {
|
|
|
158
158
|
} else {
|
|
159
159
|
const c = n.closed || {};
|
|
160
160
|
lines.push(` 状态:已结束(${KIND_WORDS[c.kind] || n.status})`);
|
|
161
|
-
if (c.refund_pct != null) lines.push(` 退还比例:${c.refund_pct}%${n.amounts ? `(退还 ${money(view, n.amounts.refund_cents)},结算给接单方 ${money(view, n.amounts.release_cents)}${n.amounts.release_net_cents != null ?
|
|
161
|
+
if (c.refund_pct != null) lines.push(` 退还比例:${c.refund_pct}%${n.amounts ? `(退还 ${money(view, n.amounts.refund_cents)},结算给接单方 ${money(view, n.amounts.release_cents)}${n.amounts.release_net_cents != null ? `,扣掉平台服务费和同款分成后接单方预计到手 ${money(view, n.amounts.release_net_cents)}` : ''})` : ''}`);
|
|
162
162
|
if (c.kind === 'court') lines.push(' 已转入 AI 法庭裁决,请用 anytowork libra status 看进展。');
|
|
163
163
|
}
|
|
164
164
|
if ((n.entries || []).length) lines.push(' 记录:', ...n.entries.map((en) => entryLine(view, en)));
|