dsh-mobile-plugin 0.4.1 → 0.4.4
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 +71 -74
- package/dsh-mobile-credentials/lib/credentials.js +39 -16
- package/lib/impl.js +0 -4
- package/lib/index.js +1 -1
- package/lib/mobile/allowed-zone.js +100 -0
- package/lib/mobile/archive-clean.js +4 -3
- package/lib/mobile/attachments.js +5 -1
- package/lib/mobile/file-read.js +168 -0
- package/lib/mobile/history-derive.js +67 -98
- package/lib/mobile/local-images.js +38 -7
- package/lib/mobile/model-vendors.js +105 -0
- package/lib/mobile/plugin-market.js +475 -0
- package/lib/mobile/plugin-self.js +1 -1
- package/lib/mobile/plugin-update.js +1 -1
- package/lib/mobile/relay-control.js +131 -7
- package/lib/mobile/session-cwd.js +28 -0
- package/lib/mobile/session-file.js +1 -12
- package/lib/mobile/stats-collect.cjs +146 -15
- package/lib/mobile/stats-ledger.cjs +249 -59
- package/lib/mobile/stats-scheduler.cjs +41 -0
- package/lib/mobile/stream.js +30 -110
- package/lib/mobile/uploads.js +68 -5
- package/lib/mobile/vendor-logos.cjs +30 -0
- package/lib/mobile/wire-cut.js +212 -0
- package/lib/panel/index.html +2 -2
- package/lib/relay/apply-ledger.cjs +52 -0
- package/lib/relay/core.js +14 -3
- package/lib/relay/migrate.js +90 -0
- package/lib/relay/protocol.js +4 -0
- package/lib/relay/verify.js +52 -0
- package/lib/routes/control-plane.js +12 -3
- package/lib/routes/mobile-api.js +28 -2
- package/lib/session-cleanup.cjs +10 -0
- package/lib/shared/device-headers.js +12 -4
- package/lib/shell/updater.js +2 -2
- package/lib/sidecar-facade-worker.cjs +55 -8
- package/lib/sidecar-facade.cjs +11 -1
- package/lib/sidecar-lifecycle.cjs +14 -1
- package/lib/update-install.cjs +13 -6
- package/package.json +1 -1
- package/lib/auto-archive.cjs +0 -98
package/lib/mobile/stream.js
CHANGED
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
* 生命周期:所有失败路径都必须经 logStream 留痕(守卫逐条钉着字面量),收尾一定给客户端一帧
|
|
16
16
|
* `session/resync-required`;keepAlive 定时器要 unref。
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
* 草稿填充窗口 250ms
|
|
20
|
-
*
|
|
18
|
+
* 出流量收在两处:草稿增量按 200ms 合并成一帧(一帧固定成本两三百字节,token 才十几字节),
|
|
19
|
+
* 草稿填充窗口 250ms。过程明细(推理、工具参数、工具结果正文与 diff)一律不下发手机,直播间
|
|
20
|
+
* 与历史两条路走同一份实现,见 `wire-cut.js`。
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
const { sendJson } = require('../shared/http-util');
|
|
@@ -35,6 +35,7 @@ const { createTextImageBudget } = require('./local-images.js');
|
|
|
35
35
|
const { callSessions } = require('./rpc.js');
|
|
36
36
|
const { unwrap } = require('./envelope.js');
|
|
37
37
|
const { normalizeJobs } = require('./jobs.js');
|
|
38
|
+
const { cutEvent, createToolNameIndex } = require('./wire-cut.js');
|
|
38
39
|
const approvals = require('./approvals.js');
|
|
39
40
|
const questions = require('./questions.js');
|
|
40
41
|
const streamRegistry = require('./stream-registry.js');
|
|
@@ -88,7 +89,13 @@ const REPLAY_MAX_GAP = 200;
|
|
|
88
89
|
*/
|
|
89
90
|
const DRAFT_FLUSH_MS = 200;
|
|
90
91
|
const DRAFT_FLUSH_BYTES = 1024;
|
|
91
|
-
/**
|
|
92
|
+
/**
|
|
93
|
+
* 可合并的增量类型。
|
|
94
|
+
*
|
|
95
|
+
* 文本按块序号拼接;工具参数增量也在这里并,但它的参数正文不下发(见 wire-cut.js),并帧
|
|
96
|
+
* 只为把「有工具开始了」那一串帧按规定窗口压成一条。别因为参数是空的就把这类帧整条丢掉:
|
|
97
|
+
* App 靠它建流式工具块、靠 `id` 与 tool/result 的 callId 配对。
|
|
98
|
+
*/
|
|
92
99
|
const MERGEABLE_CHUNK_TYPES = new Set(['text-delta', 'tool-call-delta']);
|
|
93
100
|
|
|
94
101
|
/**
|
|
@@ -101,24 +108,6 @@ const MERGEABLE_CHUNK_TYPES = new Set(['text-delta', 'tool-call-delta']);
|
|
|
101
108
|
*/
|
|
102
109
|
const DRAFT_PAD_INTERVAL_MS = 250;
|
|
103
110
|
|
|
104
|
-
/**
|
|
105
|
-
* 推理(思考)增量在实时流里只推开头这么多字,之后改推轻量进度帧。
|
|
106
|
-
*
|
|
107
|
-
* 运行中那段思考正文用户基本不读(它自己滚、而且长得飞快),却按 token 一帧地发,是
|
|
108
|
-
* 这条流里帧数最多的一类。完整思考仍然在每一步落定的 `assistant/message` 里跟着来,
|
|
109
|
-
* 也仍然在 `/session/messages` 的历史里 —— 这里省的只是「逐字推」那一段。
|
|
110
|
-
*/
|
|
111
|
-
const REASONING_LIVE_CHARS = 600;
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* 工具结果在实时流里最多带这么长的可见输出。
|
|
115
|
-
*
|
|
116
|
-
* 工具输出(读文件、跑命令、构建日志)是这条流里单帧最大的一类,几十到几百 KB 很常见,
|
|
117
|
-
* 而运行中那几秒用户看的是工具卡的状态与一两行摘要;要看全文时走历史那一条
|
|
118
|
-
* (`/session/messages` 的块与补丁),那里给的是全文。
|
|
119
|
-
*/
|
|
120
|
-
const TOOL_RESULT_LIVE_CHARS = 2000;
|
|
121
|
-
|
|
122
111
|
/**
|
|
123
112
|
* keepalive 节奏,同时也是 `session/heartbeat` 的间隔。
|
|
124
113
|
*
|
|
@@ -291,9 +280,6 @@ function createFrameSealer(req) {
|
|
|
291
280
|
};
|
|
292
281
|
}
|
|
293
282
|
|
|
294
|
-
/** 工具结果被截断时留在正文末尾的说明(客户端原样显示,不参与解析)。 */
|
|
295
|
-
const TOOL_RESULT_LIVE_ELIDED = '\n…(运行中只推前一段,展开时取全文)';
|
|
296
|
-
|
|
297
283
|
/** 草稿增量的合并键:同一步、同一块、同一类型才可并。 */
|
|
298
284
|
function chunkKeyOf(event) {
|
|
299
285
|
const data = event && event.data;
|
|
@@ -305,8 +291,8 @@ function chunkKeyOf(event) {
|
|
|
305
291
|
/**
|
|
306
292
|
* 把后到的增量并进已经攒着的那一帧。
|
|
307
293
|
*
|
|
308
|
-
* 只并「客户端按块序号累积」的两类:文本(拼接 `text
|
|
309
|
-
* 其余类型(block-start/end
|
|
294
|
+
* 只并「客户端按块序号累积」的两类:文本(拼接 `text`)与工具参数(正文不下发,并只为省帧)。
|
|
295
|
+
* 其余类型(block-start/end、协议 v2 的其它通知)一律不并 —— 它们带着语义边界,
|
|
310
296
|
* 合并等于把边界抹掉。
|
|
311
297
|
*/
|
|
312
298
|
function mergeDraft(into, from) {
|
|
@@ -318,11 +304,8 @@ function mergeDraft(into, from) {
|
|
|
318
304
|
return { ...into, time: from.time, data: { ...into.data, chunk: { ...a, text: a.text + b.text } } };
|
|
319
305
|
}
|
|
320
306
|
if (a.type === 'tool-call-delta') {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
chunk.argumentsDelta = (typeof a.argumentsDelta === 'string' ? a.argumentsDelta : '') + b.argumentsDelta;
|
|
324
|
-
}
|
|
325
|
-
return { ...into, time: from.time, data: { ...into.data, chunk } };
|
|
307
|
+
// 参数正文已经被 wire-cut.js 那条规则丢掉了,这一帧只剩身份字段,带上后一帧的时间即可。
|
|
308
|
+
return { ...into, time: from.time, data: { ...into.data, chunk: a } };
|
|
326
309
|
}
|
|
327
310
|
return null;
|
|
328
311
|
}
|
|
@@ -402,73 +385,6 @@ function createDraftBuffer({ push }) {
|
|
|
402
385
|
return { accept, flush, flushAll, isClosed: () => closed };
|
|
403
386
|
}
|
|
404
387
|
|
|
405
|
-
/**
|
|
406
|
-
* 推理增量的实时截断(见 `REASONING_LIVE_CHARS`)。
|
|
407
|
-
*
|
|
408
|
-
* 每一步只推开头那一段,之后整段丢掉 —— 不新增帧类型,也就不动 App:组头那句「正在思考」
|
|
409
|
-
* 本身一直在闪,用户看到的就是「它还在想」;完整正文在 `assistant/message` 与历史里,
|
|
410
|
-
* 一个字都不丢。返回 `null` 表示这一帧不写。
|
|
411
|
-
*/
|
|
412
|
-
function createReasoningGate() {
|
|
413
|
-
const state = { step: null, sent: 0 };
|
|
414
|
-
return (event) => {
|
|
415
|
-
const data = event && event.data;
|
|
416
|
-
const chunk = data && data.chunk;
|
|
417
|
-
if (!chunk || typeof chunk.index !== 'number' || typeof data.turn !== 'number') return event;
|
|
418
|
-
const step = `${data.turn}:${data.step}`;
|
|
419
|
-
if (step !== state.step) {
|
|
420
|
-
state.step = step;
|
|
421
|
-
state.sent = 0;
|
|
422
|
-
}
|
|
423
|
-
const remaining = REASONING_LIVE_CHARS - state.sent;
|
|
424
|
-
if (remaining <= 0) return null;
|
|
425
|
-
if (chunk.text.length <= remaining) {
|
|
426
|
-
state.sent += chunk.text.length;
|
|
427
|
-
return event;
|
|
428
|
-
}
|
|
429
|
-
const text = chunk.text.slice(0, remaining);
|
|
430
|
-
state.sent += text.length;
|
|
431
|
-
return { ...event, data: { ...data, chunk: { ...chunk, text } } };
|
|
432
|
-
};
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
/** 实时流里把工具结果的可见输出截到 `TOOL_RESULT_LIVE_CHARS`(两种内容形状都认)。 */
|
|
436
|
-
function trimTextParts(parts, state) {
|
|
437
|
-
let changed = false;
|
|
438
|
-
const next = parts.map((part) => {
|
|
439
|
-
if (!part || typeof part !== 'object') return part;
|
|
440
|
-
if (Array.isArray(part.content)) {
|
|
441
|
-
const nested = trimTextParts(part.content, state);
|
|
442
|
-
if (nested === part.content) return part;
|
|
443
|
-
changed = true;
|
|
444
|
-
return { ...part, content: nested };
|
|
445
|
-
}
|
|
446
|
-
if (typeof part.text !== 'string' || !part.text) return part;
|
|
447
|
-
if (state.budget <= 0) {
|
|
448
|
-
changed = true;
|
|
449
|
-
return { ...part, text: TOOL_RESULT_LIVE_ELIDED.trimStart() };
|
|
450
|
-
}
|
|
451
|
-
if (part.text.length <= state.budget) {
|
|
452
|
-
state.budget -= part.text.length;
|
|
453
|
-
return part;
|
|
454
|
-
}
|
|
455
|
-
const text = `${part.text.slice(0, state.budget)}${TOOL_RESULT_LIVE_ELIDED}`;
|
|
456
|
-
state.budget = 0;
|
|
457
|
-
changed = true;
|
|
458
|
-
return { ...part, text };
|
|
459
|
-
});
|
|
460
|
-
return changed ? next : parts;
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
function truncateToolResultOutput(event) {
|
|
464
|
-
const data = event && event.data;
|
|
465
|
-
const message = data && data.message;
|
|
466
|
-
if (!message || !Array.isArray(message.content)) return event;
|
|
467
|
-
const content = trimTextParts(message.content, { budget: TOOL_RESULT_LIVE_CHARS });
|
|
468
|
-
if (content === message.content) return event;
|
|
469
|
-
return { ...event, data: { ...data, message: { ...message, content } } };
|
|
470
|
-
}
|
|
471
|
-
|
|
472
388
|
async function handleStream(ctx, req, res) {
|
|
473
389
|
const url = new URL(req.url, 'http://x');
|
|
474
390
|
const sessionId = url.searchParams.get('sessionId');
|
|
@@ -557,6 +473,8 @@ async function handleStream(ctx, req, res) {
|
|
|
557
473
|
// 本机路径图片的内联预算:**一条流共用一份**,与历史那一页共用一份同口径。
|
|
558
474
|
// 此前实时帧是每帧一预算,一条消息里几张截图就能把几百 KB 到几 MB 的 base64 推过去。
|
|
559
475
|
const imageBudget = createTextImageBudget();
|
|
476
|
+
// 过程明细的减法(wire-cut.js)与它要的工具名索引:整条流共用一份,回放与实时帧都往里吸。
|
|
477
|
+
const toolNames = createToolNameIndex();
|
|
560
478
|
|
|
561
479
|
try {
|
|
562
480
|
// **先开实时流、再回放**:之前是先 replay 再 mux,两步之间的 follow 还没订阅,
|
|
@@ -572,7 +490,7 @@ async function handleStream(ctx, req, res) {
|
|
|
572
490
|
|
|
573
491
|
// 断线续传:回放 history 中 seq > resumeSeq 的事件(收敛状态)。
|
|
574
492
|
if (resumeSeq != null && resumeSeq > 0) {
|
|
575
|
-
const replay = await replaySince(ctx, sessionId, resumeSeq, res, ac.signal, (seq) => { lastDeliveredSeq = Math.max(lastDeliveredSeq, seq); }, streamAddress, sealFrame, imageBudget);
|
|
493
|
+
const replay = await replaySince(ctx, sessionId, resumeSeq, res, ac.signal, (seq) => { lastDeliveredSeq = Math.max(lastDeliveredSeq, seq); }, streamAddress, sealFrame, imageBudget, toolNames);
|
|
576
494
|
if (replay.gap && !ac.signal.aborted) {
|
|
577
495
|
const value = { type: 'session/resync-required', sessionId, resumeSeq, oldestSeq: replay.oldestSeq };
|
|
578
496
|
// 旧实现在这里直接 `return`:既不清 keepAlive 也不 end,漏出一条永不收尾的连接。
|
|
@@ -594,10 +512,8 @@ async function handleStream(ctx, req, res) {
|
|
|
594
512
|
}
|
|
595
513
|
return 'sent';
|
|
596
514
|
};
|
|
597
|
-
// 草稿合并 +
|
|
598
|
-
// `createDraftBuffer` / `createReasoningGate` 的注释)。
|
|
515
|
+
// 草稿合并 + 整条流共用一份内联图片预算(见上面两组常量与 `createDraftBuffer` 的注释)。
|
|
599
516
|
const drafts = createDraftBuffer({ push });
|
|
600
|
-
const reasoningGate = createReasoningGate();
|
|
601
517
|
/**
|
|
602
518
|
* 非草稿帧写之前先把攒着的草稿写出去:草稿是按发生顺序攒的,晚写一步就会变成
|
|
603
519
|
* 「收尾的 assistant/message 先到、被并过的正文后到」,界面上同一段话画两遍。
|
|
@@ -619,7 +535,9 @@ async function handleStream(ctx, req, res) {
|
|
|
619
535
|
// 预算按**整条流**共享:本机路径的图片内联(local-images.js)此前是每帧一份,
|
|
620
536
|
// 一条消息里几张截图就能把几百 KB 到几 MB 的 base64 推进去。
|
|
621
537
|
let event = await resolveEventAttachments(ctx, sessionId, p.event, { referencesOnly: true, budget: imageBudget });
|
|
622
|
-
|
|
538
|
+
// 先吸工具名再减:结果自己不带工具名,正文留不留取决于它(见 wire-cut.js)。
|
|
539
|
+
toolNames.absorb(event);
|
|
540
|
+
event = cutEvent(event, toolNames);
|
|
623
541
|
const chunk = event.data && event.data.chunk;
|
|
624
542
|
if (seq !== null || !chunk || typeof chunk.type !== 'string') {
|
|
625
543
|
// 持久事件(或不是增量的会话事件):先把攒着的草稿写掉,再按原样写这一帧。
|
|
@@ -628,10 +546,8 @@ async function handleStream(ctx, req, res) {
|
|
|
628
546
|
if (result === 'closed') { clientGone = true; break; }
|
|
629
547
|
if (seq !== null) lastDeliveredSeq = seq;
|
|
630
548
|
} else if (chunk.type === 'reasoning-delta') {
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
await drafts.accept(kept, { ...frame, payload: { ...p, event: kept } });
|
|
634
|
-
if (drafts.isClosed()) { clientGone = true; break; }
|
|
549
|
+
// 推理不出本机:整帧丢掉(不新增帧类型,也就不动 App 的帧裁决)。
|
|
550
|
+
continue;
|
|
635
551
|
} else if (MERGEABLE_CHUNK_TYPES.has(chunk.type)) {
|
|
636
552
|
// 被 shed 的只可能是没有 seq 的增量(见 writeSse 的软水位闸);它不推进水位,
|
|
637
553
|
// 同一轮收尾的 assistant/message 会把完整正文补上。
|
|
@@ -696,7 +612,7 @@ async function handleStream(ctx, req, res) {
|
|
|
696
612
|
* 从会话 history 回放 seq > sinceSeq 的事件(升序),逐个经附件解析后写 SSE。
|
|
697
613
|
* 通过 onDelivered(seq) 回报已交付的最大 seq,供上层去重。
|
|
698
614
|
*/
|
|
699
|
-
async function replaySince(ctx, sessionId, sinceSeq, res, signal, onDelivered, address, sealFrame = null, imageBudget = null) {
|
|
615
|
+
async function replaySince(ctx, sessionId, sinceSeq, res, signal, onDelivered, address, sealFrame = null, imageBudget = null, toolNames = null) {
|
|
700
616
|
try {
|
|
701
617
|
// 子会话的重放也要带父会话(同 historyAddress 的口径),否则宿主回 agent-busy,
|
|
702
618
|
// 这条断线续传静默退化成「后面缺一段」。
|
|
@@ -727,10 +643,14 @@ async function replaySince(ctx, sessionId, sinceSeq, res, signal, onDelivered, a
|
|
|
727
643
|
return { gap: true, oldestSeq: oldestSeq ?? sinceSeq + 1 };
|
|
728
644
|
}
|
|
729
645
|
let written = 0;
|
|
646
|
+
// 回放戴着与实时流同一副眼镜:这一段里同样有推理、工具参数与工具结果正文(见 wire-cut.js)。
|
|
647
|
+
// 工具名先从整窗吸一遍 —— 窗口里那条 `tool/call` 可能排在游标之前、不在候选里。
|
|
648
|
+
if (toolNames) toolNames.absorbAll(events);
|
|
730
649
|
for (const entry of candidates) {
|
|
731
650
|
if (signal.aborted) break;
|
|
732
651
|
const e = entry?.event || entry;
|
|
733
|
-
const
|
|
652
|
+
const resolved = await resolveEventAttachments(ctx, sessionId, e, { referencesOnly: true });
|
|
653
|
+
const event = cutEvent(resolved, toolNames);
|
|
734
654
|
// Keep replay data identical to live SSE data: the client reducer consumes
|
|
735
655
|
// the event's own type (turn/start, assistant/message, etc.).
|
|
736
656
|
const payload = { type: 'session/event', sessionId, event };
|
package/lib/mobile/uploads.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* 移动端文件分片上传:把手机分享进来的文件落到宿主机的收件目录,或落到指定的那个目录。
|
|
5
5
|
*
|
|
6
6
|
* 四步:begin(登记文件名,拿 uploadId 与片大小)→ chunk(按 index 顺序追加)→ finish
|
|
7
7
|
* (落盘成 `<收件根>/<日期>/<文件名>`,重名自动加后缀)→ abort(放弃)。
|
|
8
8
|
*
|
|
9
|
+
* 落点两条路:不带 `destPath` 时仍进固定收件箱(分享进来的文件本来就没有归处,攥在
|
|
10
|
+
* `~/.dsh/mobile-inbox` 里等用户自己搬);带 `destPath` 时落进那个目录 —— 右栏文件浏览器
|
|
11
|
+
* 「传到这里」要的就是它。后者必须在 `allowed-zone.js` 的允许区内(工作区 ∪ 当前会话项目
|
|
12
|
+
* 目录),能力面与按路径读文件同一份判据,写入不可能比读更宽。
|
|
13
|
+
*
|
|
9
14
|
* 分片而不是一次传完:单请求因此与中转的 `MAX_BODY_BYTES`、readJsonBody 的 256KB 上限无关,
|
|
10
15
|
* 手机网络不稳时也能只重传坏掉的那一片。片内容走 base64 而不是原始字节:App 侧没有发二进制
|
|
11
16
|
* body 的先例,图片通道本来就以 base64 走 JSON;代价是 33% 膨胀,仍远低于各层上限。
|
|
@@ -22,6 +27,7 @@ const path = require('node:path');
|
|
|
22
27
|
const crypto = require('node:crypto');
|
|
23
28
|
|
|
24
29
|
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
30
|
+
const { allowedRoots, inAllowedZone } = require('./allowed-zone.js');
|
|
25
31
|
|
|
26
32
|
// 片大小的**起步值** 256KB(base64 后约 341KB):客户端一次请求的硬预算是 8s
|
|
27
33
|
// (`app/src/api/core.ts` 的 REQUEST_TIMEOUT_MS),一上来就给大片的机器会在慢速上行
|
|
@@ -203,12 +209,65 @@ function fail(res, status, code, message) {
|
|
|
203
209
|
return sendJson(res, status, { ok: false, error: { code, message } });
|
|
204
210
|
}
|
|
205
211
|
|
|
206
|
-
/**
|
|
207
|
-
|
|
212
|
+
/**
|
|
213
|
+
* begin 里那个可选落点:解析、判允许区、确认它是个**已经存在的目录**。
|
|
214
|
+
*
|
|
215
|
+
* 返回 `{ dir }`(字符串,未指定时为空串)或 `{ status, code, message }`。目录不存在与
|
|
216
|
+
* 「不是目录」都按 400 报:这条路上 404 只留给 uploadId,混用会让 App 把「目录没了」
|
|
217
|
+
* 当成「上传丢了」,从而把一个还在的上传重投一遍。
|
|
218
|
+
*
|
|
219
|
+
* `sessionId` 与 `/file/read` 同义:给上它,这条会话的项目目录也算在允许区里(右栏那份
|
|
220
|
+
* 「当前目录」未必被登记成了工作区)。
|
|
221
|
+
*/
|
|
222
|
+
async function resolveDestDir(ctx, body) {
|
|
223
|
+
if (body.destPath === undefined || body.destPath === null) return { dir: '' };
|
|
224
|
+
if (typeof body.destPath !== 'string' || !body.destPath.trim()) {
|
|
225
|
+
return { status: 400, code: 'bad-request', message: 'destPath 必须是绝对目录路径' };
|
|
226
|
+
}
|
|
227
|
+
const asked = body.destPath.trim();
|
|
228
|
+
if (!path.isAbsolute(asked)) {
|
|
229
|
+
return { status: 400, code: 'bad-request', message: 'destPath 必须是绝对路径' };
|
|
230
|
+
}
|
|
231
|
+
const dir = path.resolve(asked);
|
|
232
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
|
|
233
|
+
let roots;
|
|
234
|
+
try {
|
|
235
|
+
roots = await allowedRoots(ctx, sessionId);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
return {
|
|
238
|
+
status: 502,
|
|
239
|
+
code: (error && error.code) || 'workspace-list-failed',
|
|
240
|
+
message: String((error && error.message) || error),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
if (!inAllowedZone(roots, dir)) {
|
|
244
|
+
return {
|
|
245
|
+
status: 403,
|
|
246
|
+
code: 'outside-allowed',
|
|
247
|
+
message: '这个目录不在能写的范围里(只放行工作区目录和这条会话的项目目录)',
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
let info;
|
|
251
|
+
try {
|
|
252
|
+
info = await fsp.stat(dir);
|
|
253
|
+
} catch {
|
|
254
|
+
info = null;
|
|
255
|
+
}
|
|
256
|
+
if (!info || !info.isDirectory()) {
|
|
257
|
+
return { status: 400, code: 'not-a-directory', message: 'destPath 不是一个已经存在的目录' };
|
|
258
|
+
}
|
|
259
|
+
return { dir };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** POST /api/mobile/v1/upload/begin { name, destPath?, sessionId? } → { ok, uploadId, chunkSize }。 */
|
|
263
|
+
async function handleUploadBegin(ctx, req, res) {
|
|
208
264
|
const body = await readJsonBody(req);
|
|
209
265
|
if (!body || typeof body.name !== 'string' || !body.name.trim()) {
|
|
210
266
|
return fail(res, 400, 'bad-request', '需要 { name }');
|
|
211
267
|
}
|
|
268
|
+
// 落点先判:越界 / 不是目录在这一步就回,不先建暂存再报错 —— 残片要躺满 24 小时才清。
|
|
269
|
+
const destination = await resolveDestDir(ctx, body);
|
|
270
|
+
if (destination.status) return fail(res, destination.status, destination.code, destination.message);
|
|
212
271
|
const stagingDir = path.join(inboxRoot(), '.staging');
|
|
213
272
|
try {
|
|
214
273
|
await fsp.mkdir(stagingDir, { recursive: true });
|
|
@@ -227,6 +286,8 @@ async function handleUploadBegin(_ctx, req, res) {
|
|
|
227
286
|
}
|
|
228
287
|
const job = {
|
|
229
288
|
name: sanitizeName(body.name),
|
|
289
|
+
// 指定落点就记在这里,finish 才用得上(中途不再判一次范围:begin 已经认过)。
|
|
290
|
+
destDir: destination.dir,
|
|
230
291
|
stagingPath,
|
|
231
292
|
received: 0,
|
|
232
293
|
bytes: 0,
|
|
@@ -290,9 +351,11 @@ async function handleUploadFinish(_ctx, req, res) {
|
|
|
290
351
|
}
|
|
291
352
|
removeUpload(uploadId, job);
|
|
292
353
|
|
|
293
|
-
const dir = path.join(inboxRoot(), today());
|
|
354
|
+
const dir = job.destDir || path.join(inboxRoot(), today());
|
|
294
355
|
try {
|
|
295
|
-
|
|
356
|
+
// 指定落点时目录在 begin 已确认存在:中途被删了就该按落盘失败报,不能悄悄重建一个
|
|
357
|
+
// 同名目录替用户做主。
|
|
358
|
+
if (!job.destDir) await fsp.mkdir(dir, { recursive: true });
|
|
296
359
|
const target = await uniquePath(dir, job.name);
|
|
297
360
|
await fsp.rename(job.stagingPath, target);
|
|
298
361
|
// 记住这一份:发送图片时用它把「分片上传的字节」换回内联块,见 resolveFinishedUpload。
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 厂商品牌图标(统计页「模型用量」那一行前面那一枚)。
|
|
5
|
+
*
|
|
6
|
+
* 来源:Simple Icons(<https://simple-icons.org>,图标数据 CC0,商标归各自厂商)。
|
|
7
|
+
* App 侧用 `SvgXml` 渲染,颜色由主题给 —— 所以每个图标里**不许带 fill / style / defs / 渐变**,
|
|
8
|
+
* 只留 `<path>`(可以在 path 上保留 fill-rule / clip-rule)。
|
|
9
|
+
* 一段图标就几百字节到一两 KB;随 /stats/overview 只下发用到的那几个。
|
|
10
|
+
*
|
|
11
|
+
* openai / microsoft 取自仍带这两枚的最后一个 Simple Icons 版本(15.22.0 / 11.15.0)。
|
|
12
|
+
*/
|
|
13
|
+
const VENDOR_LOGOS = {
|
|
14
|
+
deepseek: { name: 'DeepSeek', icon: '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M23.748 4.651c-.254-.124-.364.113-.512.233-.051.04-.094.09-.137.137-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.155-.708-.311-.955-.65-.172-.24-.219-.509-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.094.172.187.129.323-.082.28-.18.553-.266.833-.055.179-.137.218-.328.14a5.5 5.5 0 0 1-1.737-1.179c-.857-.828-1.631-1.743-2.597-2.46a12 12 0 0 0-.689-.47c-.985-.957.13-1.743.387-1.836.27-.098.094-.433-.778-.428-.872.003-1.67.295-2.687.685a3 3 0 0 1-.465.136 9.6 9.6 0 0 0-2.883-.101c-1.885.21-3.39 1.1-4.497 2.622C.082 8.776-.231 10.854.152 13.02c.403 2.284 1.568 4.175 3.36 5.653 1.857 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.132-.284 4.994-1.86.47.234.962.328 1.78.398.629.058 1.235-.031 1.705-.129.735-.155.684-.836.418-.961-2.155-1.004-1.682-.595-2.112-.926 1.095-1.295 2.768-3.598 3.284-6.733.05-.346.115-.834.108-1.114-.004-.171.035-.238.23-.257a4.2 4.2 0 0 0 1.545-.475c1.397-.763 1.96-2.016 2.093-3.517.02-.23-.004-.467-.247-.588M11.58 18.168c-2.088-1.642-3.101-2.183-3.52-2.16-.39.024-.32.472-.234.763.09.288.207.487.371.74.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.168-1.361-.801-2.5-1.86-3.301-3.306-.775-1.393-1.225-2.888-1.299-4.482-.02-.385.094-.522.477-.592a4.7 4.7 0 0 1 1.53-.038c2.131.311 3.946 1.264 5.467 2.774.868.86 1.525 1.887 2.202 2.89.72 1.066 1.494 2.082 2.48 2.915.348.291.626.513.892.677-.802.09-2.14.109-3.055-.615zm1.001-6.44a.306.306 0 0 1 .415-.287.3.3 0 0 1 .113.074.3.3 0 0 1 .086.214c0 .17-.136.307-.308.307a.303.303 0 0 1-.306-.307m3.11 1.596c-.2.081-.4.151-.591.16a1.25 1.25 0 0 1-.798-.254c-.274-.23-.47-.358-.551-.758a1.7 1.7 0 0 1 .015-.588c.07-.327-.007-.537-.238-.727-.188-.156-.426-.199-.689-.199a.6.6 0 0 1-.254-.078.253.253 0 0 1-.114-.358 1 1 0 0 1 .192-.21c.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.392.451.462.576.685.915.176.264.336.536.446.848.066.194-.02.353-.25.45"/></svg>' },
|
|
15
|
+
anthropic: { name: 'Anthropic', icon: '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z"/></svg>' },
|
|
16
|
+
openai: { name: 'OpenAI', icon: '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z"/></svg>' },
|
|
17
|
+
google: { name: 'Google', icon: '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81"/></svg>' },
|
|
18
|
+
alibaba: { name: 'Alibaba', icon: '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M23.919 14.545 20.817 9.17l1.47-2.544a.56.56 0 0 0 0-.566l-1.633-2.83a.57.57 0 0 0-.49-.283h-6.207L12.487.402a.57.57 0 0 0-.49-.284H8.732a.56.56 0 0 0-.49.284L5.139 5.775h-2.94a.56.56 0 0 0-.49.284L.077 8.887a.56.56 0 0 0 0 .567L3.18 14.83l-1.47 2.545a.56.56 0 0 0 0 .566l1.634 2.83a.57.57 0 0 0 .49.283h6.205l1.47 2.545a.57.57 0 0 0 .49.284h3.266a.57.57 0 0 0 .49-.284l3.104-5.375h2.94a.57.57 0 0 0 .49-.283l1.634-2.828a.55.55 0 0 0-.004-.568M8.733.686l1.634 2.828-1.634 2.828H21.8L20.164 9.17H7.425L5.63 6.06Zm1.306 19.801-6.205-.002 1.634-2.83h3.265L2.201 6.344h3.267q3.182 5.517 6.367 11.032zm10.124-5.66L18.53 12l-6.532 11.315-1.634-2.83c2.129-3.673 4.25-7.351 6.373-11.028h3.592l3.102 5.374z"/></svg>' },
|
|
19
|
+
moonshot: { name: 'Moonshot', icon: '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m1.053 16.91 9.538 2.55a21 20.981 0 0 0 .06 2.031l5.956 1.592a12 11.99 0 0 1-15.554-6.172m-1.02-5.79 11.352 3.035a21 20.981 0 0 0-.469 2.01l10.817 2.89a12 11.99 0 0 1-1.845 2.004L.658 15.918a12 11.99 0 0 1-.625-4.796m1.593-5.146L13.573 9.17a21 20.981 0 0 0-1.01 1.874l11.297 3.02a21 20.981 0 0 1-.67 2.362l-11.55-3.087L.125 10.26a12 11.99 0 0 1 1.499-4.285ZM6.067 1.58l11.285 3.016a21 20.981 0 0 0-1.688 1.719l7.824 2.091a21 20.981 0 0 1 .513 2.664L2.107 5.218a12 11.99 0 0 1 3.96-3.638M21.68 4.866 7.222 1.003A12 11.99 0 0 1 21.68 4.866"/></svg>' },
|
|
20
|
+
meta: { name: 'Meta', icon: '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M6.915 4.03c-1.968 0-3.683 1.28-4.871 3.113C.704 9.208 0 11.883 0 14.449c0 .706.07 1.369.21 1.973a6.624 6.624 0 0 0 .265.86 5.297 5.297 0 0 0 .371.761c.696 1.159 1.818 1.927 3.593 1.927 1.497 0 2.633-.671 3.965-2.444.76-1.012 1.144-1.626 2.663-4.32l.756-1.339.186-.325c.061.1.121.196.183.3l2.152 3.595c.724 1.21 1.665 2.556 2.47 3.314 1.046.987 1.992 1.22 3.06 1.22 1.075 0 1.876-.355 2.455-.843a3.743 3.743 0 0 0 .81-.973c.542-.939.861-2.127.861-3.745 0-2.72-.681-5.357-2.084-7.45-1.282-1.912-2.957-2.93-4.716-2.93-1.047 0-2.088.467-3.053 1.308-.652.57-1.257 1.29-1.82 2.05-.69-.875-1.335-1.547-1.958-2.056-1.182-.966-2.315-1.303-3.454-1.303zm10.16 2.053c1.147 0 2.188.758 2.992 1.999 1.132 1.748 1.647 4.195 1.647 6.4 0 1.548-.368 2.9-1.839 2.9-.58 0-1.027-.23-1.664-1.004-.496-.601-1.343-1.878-2.832-4.358l-.617-1.028a44.908 44.908 0 0 0-1.255-1.98c.07-.109.141-.224.211-.327 1.12-1.667 2.118-2.602 3.358-2.602zm-10.201.553c1.265 0 2.058.791 2.675 1.446.307.327.737.871 1.234 1.579l-1.02 1.566c-.757 1.163-1.882 3.017-2.837 4.338-1.191 1.649-1.81 1.817-2.486 1.817-.524 0-1.038-.237-1.383-.794-.263-.426-.464-1.13-.464-2.046 0-2.221.63-4.535 1.66-6.088.454-.687.964-1.226 1.533-1.533a2.264 2.264 0 0 1 1.088-.285z"/></svg>' },
|
|
21
|
+
mistral: { name: 'Mistral', icon: '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M17.143 3.429v3.428h-3.429v3.429h-3.428V6.857H6.857V3.43H3.43v13.714H0v3.428h10.286v-3.428H6.857v-3.429h3.429v3.429h3.429v-3.429h3.428v3.429h-3.428v3.428H24v-3.428h-3.43V3.429z"/></svg>' },
|
|
22
|
+
bytedance: { name: 'ByteDance', icon: '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M19.8772 1.4685L24 2.5326v18.9426l-4.1228 1.0563V1.4685zm-13.3481 9.428l4.115 1.0641v8.9786l-4.115 1.0642v-11.107zM0 2.572l4.115 1.0642v16.7354L0 21.428V2.572zm17.4553 5.6205v11.107l-4.1228-1.0642V9.2568l4.1228-1.0642z"/></svg>' },
|
|
23
|
+
baidu: { name: 'Baidu', icon: '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M9.154 0C7.71 0 6.54 1.658 6.54 3.707c0 2.051 1.171 3.71 2.615 3.71 1.446 0 2.614-1.659 2.614-3.71C11.768 1.658 10.6 0 9.154 0zm7.025.594C14.86.58 13.347 2.589 13.2 3.927c-.187 1.745.25 3.487 2.179 3.735 1.933.25 3.175-1.806 3.422-3.364.252-1.555-.995-3.364-2.362-3.674a1.218 1.218 0 0 0-.261-.03zM3.582 5.535a2.811 2.811 0 0 0-.156.008c-2.118.19-2.428 3.24-2.428 3.24-.287 1.41.686 4.425 3.297 3.864 2.617-.561 2.262-3.68 2.183-4.362-.125-1.018-1.292-2.773-2.896-2.75zm16.534 1.753c-2.308 0-2.617 2.119-2.617 3.616 0 1.43.121 3.425 2.988 3.362 2.867-.063 2.553-3.238 2.553-3.988 0-.745-.62-2.99-2.924-2.99zm-8.264 2.478c-1.424.014-2.708.925-3.323 1.947-1.118 1.868-2.863 3.05-3.112 3.363-.25.309-3.61 2.116-2.864 5.42.746 3.301 3.365 3.237 3.365 3.237s1.93.19 4.171-.31c2.24-.495 4.17.123 4.17.123s5.233 1.748 6.665-1.616c1.43-3.364-.808-5.109-.808-5.109s-2.99-2.306-4.736-4.798c-1.072-1.665-2.348-2.268-3.528-2.257zm-2.234 3.84l1.542.024v8.197H7.758c-1.47-.291-2.055-1.292-2.13-1.462-.072-.173-.488-.976-.268-2.343.635-2.049 2.447-2.196 2.447-2.196h1.81zm3.964 2.39v3.881c.096.413.612.488.612.488h1.614v-4.343h1.689v5.782h-3.915c-1.517-.39-1.59-1.465-1.59-1.465v-4.317zm-5.458 1.147c-.66.197-.978.708-1.05.928-.076.22-.247.78-.1 1.269.294 1.095 1.248 1.144 1.248 1.144h1.37v-3.34z"/></svg>' },
|
|
24
|
+
tencent: { name: 'Tencent', icon: '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 0a1 1 0 0 1 0 24 1 1 0 0 1 0-24m1.65 1.12c.533.097 1.023.233 1.41.404 6.086 2.686 7.398 9.217 1.603 14.341a3.782 3.782 0 0 1-6.126-1.75 3.66 3.66 0 0 1-.095-1.622c-1.934.6-3.295 2.305-3.524 4.45-.204 1.912.324 4.044 2.056 5.634A11 11 0 1 0 13.65 1.12M2.748 6.21A11 11 0 0 0 8.9 22.55c-5.377-4.022-6.06-8.4-2.43-13.87a2.234 2.234 0 1 0-3.722-2.47m10.157-5.172c4.408 1.33 3.61 5.41 2.447 6.924-.86 1.117-2.922 1.46-3.708 2.238a4.18 4.18 0 0 0-1.212 2.292A5.3 5.3 0 0 1 12 12.258a5.672 5.672 0 0 0 1.404-11.169 11 11 0 0 0-.5-.052"/></svg>' },
|
|
25
|
+
minimax: { name: 'MiniMax', icon: '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M11.43 3.92a.86.86 0 1 0-1.718 0v14.236a1.999 1.999 0 0 1-3.997 0V9.022a.86.86 0 1 0-1.718 0v3.87a1.999 1.999 0 0 1-3.997 0V11.49a.57.57 0 0 1 1.139 0v1.404a.86.86 0 0 0 1.719 0V9.022a1.999 1.999 0 0 1 3.997 0v9.134a.86.86 0 0 0 1.719 0V3.92a1.998 1.998 0 1 1 3.996 0v11.788a.57.57 0 1 1-1.139 0zm10.572 3.105a2 2 0 0 0-1.999 1.997v7.63a.86.86 0 0 1-1.718 0V3.923a1.999 1.999 0 0 0-3.997 0v16.16a.86.86 0 0 1-1.719 0V18.08a.57.57 0 1 0-1.138 0v2a1.998 1.998 0 0 0 3.996 0V3.92a.86.86 0 0 1 1.719 0v12.73a1.999 1.999 0 0 0 3.996 0V9.023a.86.86 0 1 1 1.72 0v6.686a.57.57 0 0 0 1.138 0V9.022a2 2 0 0 0-1.998-1.997"/></svg>' },
|
|
26
|
+
nvidia: { name: 'NVIDIA', icon: '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M8.948 8.798v-1.43a6.7 6.7 0 0 1 .424-.018c3.922-.124 6.493 3.374 6.493 3.374s-2.774 3.851-5.75 3.851c-.398 0-.787-.062-1.158-.185v-4.346c1.528.185 1.837.857 2.747 2.385l2.04-1.714s-1.492-1.952-4-1.952a6.016 6.016 0 0 0-.796.035m0-4.735v2.138l.424-.027c5.45-.185 9.01 4.47 9.01 4.47s-4.08 4.964-8.33 4.964c-.37 0-.733-.035-1.095-.097v1.325c.3.035.61.062.91.062 3.957 0 6.82-2.023 9.593-4.408.459.371 2.34 1.263 2.73 1.652-2.633 2.208-8.772 3.984-12.253 3.984-.335 0-.653-.018-.971-.053v1.864H24V4.063zm0 10.326v1.131c-3.657-.654-4.673-4.46-4.673-4.46s1.758-1.944 4.673-2.262v1.237H8.94c-1.528-.186-2.73 1.245-2.73 1.245s.68 2.412 2.739 3.11M2.456 10.9s2.164-3.197 6.5-3.533V6.201C4.153 6.59 0 10.653 0 10.653s2.35 6.802 8.948 7.42v-1.237c-4.84-.6-6.492-5.936-6.492-5.936z"/></svg>' },
|
|
27
|
+
microsoft: { name: 'Microsoft', icon: '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M0 0v11.408h11.408V0zm12.594 0v11.408H24V0zM0 12.594V24h11.408V12.594zm12.594 0V24H24V12.594z"/></svg>' },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
module.exports = { VENDOR_LOGOS };
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 移动线路的减法:宿主事件里哪些字段不下发给手机。
|
|
5
|
+
*
|
|
6
|
+
* App 的对话页不看「过程」(跑了哪些命令、读了哪些文件),所以推理、工具参数、工具结果的
|
|
7
|
+
* 正文与 write/edit 的 diff 都不上线路。工具参数这一笔两头都要收:落定的 tool-call 块只剩
|
|
8
|
+
* 骨架,流式 `tool-call-delta` 里逐帧推的那份 `argumentsDelta`(`write` / `edit` 就是整份
|
|
9
|
+
* 文件内容)也只留帧、不留内容。留下三样:工具结果里的图片(渲染层要画),以及三张轮尾卡的
|
|
10
|
+
* 料 —— 计划卡读 `exit_plan_mode` 的 `plan`、扩展卡读 `extension_present` 的 `extension`、
|
|
11
|
+
* 定时任务卡解析 `schedule_create` 结果的 JSON。
|
|
12
|
+
*
|
|
13
|
+
* 直播帧循环(stream.js)与历史派生(history-derive.js)调的是同一份实现。同一条规则写两份,
|
|
14
|
+
* 迟早漂成「直播里还带着推理、拉一次历史又没有了」。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 三张轮尾卡要用的工具:只有它们的 `arguments` 原文留着。
|
|
19
|
+
*
|
|
20
|
+
* 与 `history-derive.js` 的 `ENTRY_OUTCOME_TOOLS`(薄入口给哪些出卡调用留一行)不是同一张
|
|
21
|
+
* 表:那张管「一轮只留几条调用」,这张管「参数发不发」,只是成员恰好一样。新增轮尾卡时
|
|
22
|
+
* 两边都要看一眼。
|
|
23
|
+
*/
|
|
24
|
+
const ARGUMENT_KEEP_TOOLS = new Set(['exit_plan_mode', 'extension_present', 'schedule_create']);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 结果正文唯一保留的一项:定时任务卡解析它上面的 JSON,解不出来就不出卡(见 App 的
|
|
28
|
+
* `turn-tail-cards`),所以这份正文一个字都不能少、也不能截断。
|
|
29
|
+
*/
|
|
30
|
+
const OUTPUT_KEEP_TOOLS = new Set(['schedule_create']);
|
|
31
|
+
|
|
32
|
+
/** 这一块内容是不是图片(附件引用、内联字节、还原失败三种形状都算)。 */
|
|
33
|
+
function isImagePart(part) {
|
|
34
|
+
return Boolean(part) && typeof part === 'object' && part.type === 'image';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 工具结果的内容块:只留图片块,正文(text 与其它一切)整条不要。 */
|
|
38
|
+
function cutToolResultParts(parts) {
|
|
39
|
+
if (!Array.isArray(parts)) return parts;
|
|
40
|
+
const kept = parts.filter(isImagePart);
|
|
41
|
+
return kept.length === parts.length ? parts : kept;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 工具结果的内容:两种形状都认(当前是块数组、老日志是 `{ isError, content }` 信封),
|
|
46
|
+
* 信封壳必须原样留着 —— 结果级失败态挂在它上面(与 `history-derive.js` 的 `toolResultParts`
|
|
47
|
+
* 同一口径),剥掉壳子失败的工具就会显示成成功。
|
|
48
|
+
*/
|
|
49
|
+
function cutToolResultContent(content) {
|
|
50
|
+
if (!Array.isArray(content)) return content;
|
|
51
|
+
const first = content[0];
|
|
52
|
+
const envelope = content.length === 1 && first && typeof first === 'object' && typeof first.type !== 'string' ? first : null;
|
|
53
|
+
if (!envelope) return cutToolResultParts(content);
|
|
54
|
+
const inner = Array.isArray(envelope.content) ? envelope.content : [];
|
|
55
|
+
const kept = cutToolResultParts(inner);
|
|
56
|
+
return kept === inner ? content : [{ ...envelope, content: kept }];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 一条 `tool/result` 事件:只留 callId、状态与图片(`state` 由 `message.isError` 与
|
|
61
|
+
* `data.error.code` 派生,两者都要留着),正文只给 `OUTPUT_KEEP_TOOLS` 里的工具留着,
|
|
62
|
+
* `data.meta`(write/edit 的已应用 diff)整条丢掉。
|
|
63
|
+
*/
|
|
64
|
+
function cutToolResultEvent(event, toolName) {
|
|
65
|
+
const data = event && event.data;
|
|
66
|
+
if (!data || typeof data !== 'object') return event;
|
|
67
|
+
const message = data.message;
|
|
68
|
+
if (!message || typeof message !== 'object') return event;
|
|
69
|
+
const content = OUTPUT_KEEP_TOOLS.has(toolName) ? message.content : cutToolResultContent(message.content);
|
|
70
|
+
if (content === message.content && data.meta === undefined) return event;
|
|
71
|
+
const { meta: _droppedDiffs, ...rest } = data;
|
|
72
|
+
return { ...event, data: { ...rest, message: content === message.content ? message : { ...message, content } } };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 一块 assistant 内容:推理整块不要,tool-call 收成骨架 `{ type, id, name }`。
|
|
77
|
+
* 返回 `null` 表示这一块不下发;返回原对象表示原样下发。
|
|
78
|
+
*/
|
|
79
|
+
function cutAssistantBlock(block) {
|
|
80
|
+
if (!block || typeof block !== 'object') return block;
|
|
81
|
+
if (block.type === 'reasoning') return null;
|
|
82
|
+
if (block.type !== 'tool-call') return block;
|
|
83
|
+
if (ARGUMENT_KEEP_TOOLS.has(block.name)) return block;
|
|
84
|
+
const skeleton = { type: 'tool-call' };
|
|
85
|
+
if (block.id !== undefined) skeleton.id = block.id;
|
|
86
|
+
if (block.name !== undefined) skeleton.name = block.name;
|
|
87
|
+
return skeleton;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** assistant/message 的内容块:逐块过上面那一条规则(没改动的帧原样返回,省一次复制)。 */
|
|
91
|
+
function cutAssistantContent(content) {
|
|
92
|
+
if (!Array.isArray(content)) return content;
|
|
93
|
+
let changed = false;
|
|
94
|
+
const out = [];
|
|
95
|
+
for (const block of content) {
|
|
96
|
+
const kept = cutAssistantBlock(block);
|
|
97
|
+
if (kept !== block) changed = true;
|
|
98
|
+
if (kept !== null) out.push(kept);
|
|
99
|
+
}
|
|
100
|
+
return changed ? out : content;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 一条 `assistant/message` 事件:content 过上面那一条规则。 */
|
|
104
|
+
function cutAssistantEvent(event) {
|
|
105
|
+
const data = event && event.data;
|
|
106
|
+
const message = data && data.message;
|
|
107
|
+
if (!message || typeof message !== 'object' || !Array.isArray(message.content)) return event;
|
|
108
|
+
const content = cutAssistantContent(message.content);
|
|
109
|
+
if (content === message.content) return event;
|
|
110
|
+
return { ...event, data: { ...data, message: { ...message, content } } };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 一条 `tool/call` 事件:与 tool-call 块同一条规则 —— 只为三张轮尾卡的工具留 `arguments`
|
|
115
|
+
* (其余一律只留 `callId` / `name`)。宿主把这条事件原样追进会话日志,手机今天收得到每一次
|
|
116
|
+
* 调用的完整参数(bash 命令行、文件路径都在里面),而 App 对这条事件没有任何消费者。
|
|
117
|
+
* 认不出工具名时按「不留」处理:宁可少一份参数,也不把别的调用原样发出去。
|
|
118
|
+
*/
|
|
119
|
+
function cutToolCallEvent(event) {
|
|
120
|
+
const data = event && event.data;
|
|
121
|
+
if (!data || typeof data !== 'object') return event;
|
|
122
|
+
if (typeof data.name === 'string' && ARGUMENT_KEEP_TOOLS.has(data.name)) return event;
|
|
123
|
+
if (data.arguments === undefined) return event;
|
|
124
|
+
const { arguments: _droppedArguments, ...rest } = data;
|
|
125
|
+
return { ...event, data: rest };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 一块流式增量:`tool-call-delta` 的参数正文不下发,帧本身照留(App 靠它知道有工具开始了,
|
|
130
|
+
* `index` / `id` / `name` 也就是配对要用的那几个字段)。这条是整个减法里最贵的一笔 ——
|
|
131
|
+
* `write` / `edit` 的参数就是整份文件内容,按 token 一帧地推给手机。
|
|
132
|
+
*/
|
|
133
|
+
function cutAssistantChunkEvent(event) {
|
|
134
|
+
const data = event && event.data;
|
|
135
|
+
const chunk = data && data.chunk;
|
|
136
|
+
if (!chunk || typeof chunk !== 'object' || chunk.type !== 'tool-call-delta') return event;
|
|
137
|
+
if (chunk.argumentsDelta === undefined) return event;
|
|
138
|
+
const { argumentsDelta: _droppedArguments, ...rest } = chunk;
|
|
139
|
+
return { ...event, data: { ...data, chunk: rest } };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 事件类型 → 这条事件该怎么减:能减的类型走对应的一条规则,其余原样透传。
|
|
144
|
+
* 直播帧循环与回放都调它,一次判完,避免两处各判一半。
|
|
145
|
+
*/
|
|
146
|
+
function cutEvent(event, names) {
|
|
147
|
+
if (!event || typeof event !== 'object') return event;
|
|
148
|
+
if (event.type === 'assistant/message') return cutAssistantEvent(event);
|
|
149
|
+
if (event.type === 'assistant/chunk') return cutAssistantChunkEvent(event);
|
|
150
|
+
if (event.type === 'tool/result') {
|
|
151
|
+
const source = event.data && event.data.message && event.data.message.source;
|
|
152
|
+
return cutToolResultEvent(event, names && source ? names.nameOf(source.callId) : undefined);
|
|
153
|
+
}
|
|
154
|
+
if (event.type === 'tool/call') return cutToolCallEvent(event);
|
|
155
|
+
return event;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* callId → 工具名。
|
|
160
|
+
*
|
|
161
|
+
* `tool/result` 自己不带动工具名(只有 `source.callId`),而「这条结果的正文要不要留」
|
|
162
|
+
* 取决于工具名,所以要在事件流里把配对攒起来。三个来源:`tool/call` 事件、assistant 消息
|
|
163
|
+
* 里的 tool-call 块、流式 tool-call 增量。攒不到名字就按「不留正文」处理 —— 宁可少一张
|
|
164
|
+
* 定时任务卡(权威历史那条路会补上),也不能把别的工具输出当成卡的料发出去。
|
|
165
|
+
*/
|
|
166
|
+
function createToolNameIndex() {
|
|
167
|
+
const byCallId = new Map();
|
|
168
|
+
const remember = (rawId, rawName) => {
|
|
169
|
+
if (rawId === undefined || rawId === null) return;
|
|
170
|
+
const callId = typeof rawId === 'string' ? rawId : String(rawId);
|
|
171
|
+
if (!callId || typeof rawName !== 'string' || rawName === '') return;
|
|
172
|
+
byCallId.set(callId, rawName);
|
|
173
|
+
};
|
|
174
|
+
const absorb = (event) => {
|
|
175
|
+
const data = event && event.data;
|
|
176
|
+
if (!data || typeof data !== 'object') return;
|
|
177
|
+
if (event.type === 'tool/call') {
|
|
178
|
+
remember(data.callId, data.name);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (event.type === 'assistant/chunk') {
|
|
182
|
+
const chunk = data.chunk;
|
|
183
|
+
if (chunk && typeof chunk === 'object') remember(chunk.id, chunk.name);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (event.type !== 'assistant/message') return;
|
|
187
|
+
const content = data.message && Array.isArray(data.message.content) ? data.message.content : null;
|
|
188
|
+
if (!content) return;
|
|
189
|
+
for (const block of content) {
|
|
190
|
+
if (block && block.type === 'tool-call') remember(block.id, block.name);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
return {
|
|
194
|
+
absorb,
|
|
195
|
+
/** 一次把一批事件(回放取回的历史窗口)吸进来。 */
|
|
196
|
+
absorbAll: (events) => { for (const entry of events || []) absorb(entry && entry.event ? entry.event : entry); },
|
|
197
|
+
nameOf: (callId) => (callId === undefined || callId === null ? undefined : byCallId.get(typeof callId === 'string' ? callId : String(callId))),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = {
|
|
202
|
+
ARGUMENT_KEEP_TOOLS,
|
|
203
|
+
OUTPUT_KEEP_TOOLS,
|
|
204
|
+
cutEvent,
|
|
205
|
+
cutAssistantBlock,
|
|
206
|
+
cutAssistantContent,
|
|
207
|
+
cutAssistantEvent,
|
|
208
|
+
cutToolCallEvent,
|
|
209
|
+
cutToolResultContent,
|
|
210
|
+
cutToolResultEvent,
|
|
211
|
+
createToolNameIndex,
|
|
212
|
+
};
|