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
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 按路径读文件(POST /api/mobile/v1/file/read):右栏文件浏览器「点开一个文件」。
|
|
5
|
+
*
|
|
6
|
+
* 与 `/session/file` 的分工:那条服务的是**交付**(卡片、正文里的文件链接),放行面是
|
|
7
|
+
* 「本会话工作区 ∪ 本轮 present 声明过的路径」;右栏能一路翻到别的已登记工作区,手里的
|
|
8
|
+
* 就是一串路径,所以这条按 `allowed-zone.js` 判:宿主已登记的工作区 ∪ 当前会话的项目目录。
|
|
9
|
+
*
|
|
10
|
+
* 三条边界:
|
|
11
|
+
* 1. `path` 必须是绝对路径 —— 这条路由没有可信的解析基准,相对路径直接 400,不去猜谁当基准。
|
|
12
|
+
* 判定用的是 resolve 之后的落点,`..` 爬出允许区一律不认(403 `outside-allowed`)。
|
|
13
|
+
* 2. 认出允许区之后的失败分开报:盘上没有 404 `missing`、不是普通文件 400 `not-a-file`、
|
|
14
|
+
* 超过 512MB 413 `too-large`。码与文案跟 `/session/file` 同一套,App 那边一套分支就够。
|
|
15
|
+
* 3. 字节流式回写,不在插件进程里缓冲。协议 v2 的分块信封**直接调 `/session/file` 那个写入
|
|
16
|
+
* 函数**:两边抄一份形状必然漂,而漂了的症状是「手机上永远下载中」,没有报错。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const { stat } = require('node:fs/promises');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
const { pipeline } = require('node:stream/promises');
|
|
23
|
+
|
|
24
|
+
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
25
|
+
const { allowedRoots, inAllowedZone } = require('./allowed-zone.js');
|
|
26
|
+
const { writeEncryptedFile, SESSION_FILE_MAX_BYTES } = require('./session-file.js');
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 扩展名 → 内容类型。与 `session-file.js` 那张表逐条相同:两条路给同一个文件必须给出同一个
|
|
30
|
+
* `content-type`,否则 App 那边「按类型决定怎么预览」的分支会看端点选,等于多一种行为。
|
|
31
|
+
*/
|
|
32
|
+
const MEDIA_TYPES = Object.freeze({
|
|
33
|
+
mp4: 'video/mp4', m4v: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm',
|
|
34
|
+
mp3: 'audio/mpeg', m4a: 'audio/mp4', wav: 'audio/wav', ogg: 'audio/ogg', flac: 'audio/flac',
|
|
35
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
|
|
36
|
+
webp: 'image/webp', heic: 'image/heic', bmp: 'image/bmp', svg: 'image/svg+xml',
|
|
37
|
+
pdf: 'application/pdf',
|
|
38
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
39
|
+
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
40
|
+
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
41
|
+
zip: 'application/zip', gz: 'application/gzip', tar: 'application/x-tar',
|
|
42
|
+
json: 'application/json', csv: 'text/csv', html: 'text/html', htm: 'text/html',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const TEXT_EXTENSIONS = new Set([
|
|
46
|
+
'txt', 'md', 'markdown', 'log', 'json', 'csv', 'tsv', 'html', 'htm', 'css',
|
|
47
|
+
'js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs', 'py', 'rb', 'go', 'rs', 'java', 'kt',
|
|
48
|
+
'swift', 'c', 'h', 'cpp', 'sh', 'bash', 'zsh', 'yml', 'yaml', 'toml', 'ini',
|
|
49
|
+
'conf', 'sql', 'xml', 'svg', 'patch', 'diff',
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
/** 取扩展名(小写、不含点);没有扩展名回空串。 */
|
|
53
|
+
function extensionOf(filePath) {
|
|
54
|
+
const dot = filePath.lastIndexOf('.');
|
|
55
|
+
if (dot <= 0 || dot === filePath.length - 1) return '';
|
|
56
|
+
return filePath.slice(dot + 1).toLowerCase();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 回给响应的内容类型:认得出的按表,认不出的按扩展名落文本或 octet-stream。 */
|
|
60
|
+
function mediaTypeOf(filePath) {
|
|
61
|
+
const ext = extensionOf(filePath);
|
|
62
|
+
if (MEDIA_TYPES[ext]) return MEDIA_TYPES[ext];
|
|
63
|
+
if (TEXT_EXTENSIONS.has(ext)) return 'text/plain; charset=utf-8';
|
|
64
|
+
return 'application/octet-stream';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* `content-disposition` 的文件名:头里只能放 ASCII,中文名按 RFC 5987 另给一份
|
|
69
|
+
* `filename*`。App 不读它(文件名它自己有),留着是给别人抓包时认人用的。
|
|
70
|
+
*/
|
|
71
|
+
function dispositionOf(name) {
|
|
72
|
+
const ascii = name.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
|
|
73
|
+
return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* POST { path(绝对), sessionId? } → 该文件的原始字节。
|
|
78
|
+
*
|
|
79
|
+
* @param {object} ctx 插件上下文
|
|
80
|
+
* @param {object} req node:http IncomingMessage
|
|
81
|
+
* @param {object} res node:http ServerResponse
|
|
82
|
+
*/
|
|
83
|
+
async function handleFileRead(ctx, req, res) {
|
|
84
|
+
const body = await readJsonBody(req);
|
|
85
|
+
const declared = body && typeof body.path === 'string' ? body.path.trim() : '';
|
|
86
|
+
const sessionId = body && typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
|
|
87
|
+
if (!declared) {
|
|
88
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { path }(绝对路径)' } });
|
|
89
|
+
}
|
|
90
|
+
if (!path.isAbsolute(declared)) {
|
|
91
|
+
return sendJson(res, 400, {
|
|
92
|
+
ok: false,
|
|
93
|
+
error: { code: 'bad-request', message: 'path 必须是绝对路径' },
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
const target = path.resolve(declared);
|
|
97
|
+
|
|
98
|
+
let roots;
|
|
99
|
+
try {
|
|
100
|
+
roots = await allowedRoots(ctx, sessionId);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
// 宿主那句原话回给手机:这条路上「没取到」与「你没有这个目录」必须分得开。
|
|
103
|
+
return sendJson(res, 502, {
|
|
104
|
+
ok: false,
|
|
105
|
+
error: {
|
|
106
|
+
code: (error && error.code) || 'workspace-list-failed',
|
|
107
|
+
message: String((error && error.message) || error),
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (!inAllowedZone(roots, target)) {
|
|
112
|
+
return sendJson(res, 403, {
|
|
113
|
+
ok: false,
|
|
114
|
+
error: { code: 'outside-allowed', message: '这个文件不在能看的范围里(只放行工作区目录和这条会话的项目目录)' },
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let info;
|
|
119
|
+
try {
|
|
120
|
+
info = await stat(target);
|
|
121
|
+
} catch {
|
|
122
|
+
return sendJson(res, 404, { ok: false, error: { code: 'missing', message: '文件已经不在这台电脑上了' } });
|
|
123
|
+
}
|
|
124
|
+
if (!info.isFile()) {
|
|
125
|
+
return sendJson(res, 400, { ok: false, error: { code: 'not-a-file', message: '这条路径不是一个文件' } });
|
|
126
|
+
}
|
|
127
|
+
if (info.size > SESSION_FILE_MAX_BYTES) {
|
|
128
|
+
return sendJson(res, 413, {
|
|
129
|
+
ok: false,
|
|
130
|
+
error: { code: 'too-large', message: '这个文件太大,手机这边不支持整份取回' },
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const header = {
|
|
135
|
+
'content-type': mediaTypeOf(target),
|
|
136
|
+
'content-disposition': dispositionOf(path.basename(target)),
|
|
137
|
+
'cache-control': 'no-store',
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// 协议 v2:与 `/session/file` 逐字节同形(`x-dsh-content-type` 带明文类型、chunked,
|
|
141
|
+
// 真实类型单独给一份让 App 解密后决定怎么预览)。
|
|
142
|
+
const opened = req && req.__dshE2ee;
|
|
143
|
+
if (opened && opened.keys && opened.req) {
|
|
144
|
+
res.writeHead(200, {
|
|
145
|
+
...header,
|
|
146
|
+
'content-type': 'application/x-dsh-e2ee-chunks',
|
|
147
|
+
'transfer-encoding': 'chunked',
|
|
148
|
+
'x-dsh-proto': '2',
|
|
149
|
+
'x-dsh-content-type': header['content-type'],
|
|
150
|
+
});
|
|
151
|
+
await writeEncryptedFile(req, res, target, opened);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
res.writeHead(200, { ...header, 'content-length': String(info.size) });
|
|
156
|
+
if (info.size === 0) return res.end();
|
|
157
|
+
|
|
158
|
+
const stream = fs.createReadStream(target);
|
|
159
|
+
res.on('close', () => stream.destroy());
|
|
160
|
+
try {
|
|
161
|
+
await pipeline(stream, res);
|
|
162
|
+
} catch {
|
|
163
|
+
// 头已经发出去了,改不了状态码,只能断开让对端按截断处理。
|
|
164
|
+
try { res.destroy(); } catch { /* 连接已关闭 */ }
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = { handleFileRead, mediaTypeOf };
|
|
@@ -6,8 +6,13 @@
|
|
|
6
6
|
* 本模块只做「事件 → 展示结构」的纯计算,不碰 ctx、不发 RPC,因此可以单独测;
|
|
7
7
|
* 原先这些函数与路由 handler 混在 lib/mobile/sessions.js 里(该文件 1964 行),
|
|
8
8
|
* 拆出来后 handler 层只负责 HTTP 与宿主交互。
|
|
9
|
+
*
|
|
10
|
+
* 「什么不下发手机」(推理 / 工具参数 / 工具结果正文与 diff)的唯一规则在 `wire-cut.js`,
|
|
11
|
+
* 这里只负责把留下的事件折成消息结构 —— 直播间那条路调的是同一份。
|
|
9
12
|
*/
|
|
10
13
|
|
|
14
|
+
const { cutAssistantBlock, cutToolResultEvent, createToolNameIndex } = require('./wire-cut.js');
|
|
15
|
+
|
|
11
16
|
/**
|
|
12
17
|
* 从 session.list 摘要行取会话标题(与桌面端侧栏同源,不再从 history 窗口猜):
|
|
13
18
|
* - 优先读 title 投影(dsh-session-title 在「完整日志」上折叠的最新 session/title
|
|
@@ -27,55 +32,6 @@ function listTitle(s) {
|
|
|
27
32
|
return s.sessionId || '';
|
|
28
33
|
}
|
|
29
34
|
|
|
30
|
-
/** 单文件一行文本的传输上限:整文件 create/overwrite 的 newText 可能极大,超出即截断并标记。 */
|
|
31
|
-
const DIFF_TEXT_MAX_LINES = 400;
|
|
32
|
-
/** 一次工具调用最多回传的 diff 文件数(对齐桌面「一行一张卡」的粒度,避免畸形 meta 撑爆载荷)。 */
|
|
33
|
-
const DIFF_MAX_FILES = 20;
|
|
34
|
-
|
|
35
|
-
/** 按行数截断一段 diff 文本;未超限原样返回,超限保留前 MAX 行并标记 truncated。 */
|
|
36
|
-
function capDiffText(text) {
|
|
37
|
-
if (typeof text !== 'string') return undefined;
|
|
38
|
-
let lines = 0;
|
|
39
|
-
for (let i = 0; i < text.length; i += 1) {
|
|
40
|
-
if (text.charCodeAt(i) === 10) lines += 1;
|
|
41
|
-
if (lines >= DIFF_TEXT_MAX_LINES) {
|
|
42
|
-
return { text: text.slice(0, i), truncated: true };
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
return { text, truncated: false };
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* 收窄 tool/result 的 `data.meta`(write/edit 的 FsDiffMeta)为可回传的 diff 列表。
|
|
50
|
-
*
|
|
51
|
-
* 桌面 diff 卡的数据源头就是这里:fs 工具把「已应用的分块上下文 diff」挂在 `result.meta`
|
|
52
|
-
* 上,随 tool/result 事件持久化。投影只读 message.content 的话 meta 会整个丢掉,App 里
|
|
53
|
-
* write/edit 只剩一坨 JSON 参数。
|
|
54
|
-
*
|
|
55
|
-
* 只接受形状正确的条目(path 非空字符串、newText 字符串、oldText 字符串或 null),畸形数据
|
|
56
|
-
* 返回 undefined 让调用方退回通用卡片,绝不抛错。
|
|
57
|
-
*/
|
|
58
|
-
function normalizeResultDiffs(meta) {
|
|
59
|
-
if (!meta || typeof meta !== 'object' || !Array.isArray(meta.diffs)) return undefined;
|
|
60
|
-
const out = [];
|
|
61
|
-
for (const raw of meta.diffs) {
|
|
62
|
-
if (!raw || typeof raw !== 'object') continue;
|
|
63
|
-
const path = typeof raw.path === 'string' && raw.path !== '' ? raw.path : undefined;
|
|
64
|
-
const newText = capDiffText(raw.newText);
|
|
65
|
-
if (path === undefined || newText === undefined) continue;
|
|
66
|
-
const oldText = typeof raw.oldText === 'string' ? capDiffText(raw.oldText) : undefined;
|
|
67
|
-
if (raw.oldText !== null && raw.oldText !== undefined && typeof raw.oldText !== 'string') continue;
|
|
68
|
-
out.push({
|
|
69
|
-
path,
|
|
70
|
-
oldText: oldText === undefined ? null : oldText.text,
|
|
71
|
-
newText: newText.text,
|
|
72
|
-
...newText.truncated || (oldText && oldText.truncated) ? { truncated: true } : {},
|
|
73
|
-
});
|
|
74
|
-
if (out.length >= DIFF_MAX_FILES) break;
|
|
75
|
-
}
|
|
76
|
-
return out.length > 0 ? out : undefined;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
35
|
/**
|
|
80
36
|
* tool/result 的可见内容:两种形状都认。
|
|
81
37
|
*
|
|
@@ -136,25 +92,32 @@ function toolResultImages(parts) {
|
|
|
136
92
|
return out;
|
|
137
93
|
}
|
|
138
94
|
|
|
139
|
-
/**
|
|
95
|
+
/**
|
|
96
|
+
* 预扫描 tool/result 事件,构建 callId → 状态/图片 映射(对接桌面 toolRowModel 状态派生)。
|
|
97
|
+
*
|
|
98
|
+
* 每条结果先过 `wire-cut.js`:正文与结果上的 `meta`(已应用 diff)都不下发,只留状态与图片,
|
|
99
|
+
* 正文只给定时任务卡要解析的 `schedule_create` 留着。工具名从同一批事件里攒
|
|
100
|
+
* (`tool/call`、assistant 的 tool-call 块),攒不到就按「不留正文」处理。
|
|
101
|
+
*/
|
|
140
102
|
function buildToolResultMap(events) {
|
|
141
103
|
const map = new Map();
|
|
104
|
+
const toolNames = createToolNameIndex();
|
|
105
|
+
toolNames.absorbAll(events);
|
|
142
106
|
for (const entry of events) {
|
|
143
107
|
const e = entry.event || entry;
|
|
144
108
|
if (e.type !== 'tool/result' || !e.data || !e.data.message) continue;
|
|
145
109
|
const msg = e.data.message;
|
|
146
110
|
const src = msg.source;
|
|
147
111
|
if (!src || typeof src.callId !== 'string') continue;
|
|
148
|
-
const
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
map.set(src.callId, { isError, code, ...(output ? { output } : {}), ...(images.length ? { images } : {}), ...(diffs ? { diffs } : {}) });
|
|
112
|
+
const kept = cutToolResultEvent(e, toolNames.nameOf(src.callId));
|
|
113
|
+
const { parts, isError } = toolResultParts(kept.data.message);
|
|
114
|
+
const code = kept.data.error && typeof kept.data.error.code === 'string' ? kept.data.error.code : undefined;
|
|
115
|
+
const output = parts.map((part) => {
|
|
116
|
+
if (part && typeof part.text === 'string') return part.text;
|
|
117
|
+
return '';
|
|
118
|
+
}).filter(Boolean).join('');
|
|
119
|
+
const images = toolResultImages(parts);
|
|
120
|
+
map.set(src.callId, { isError, code, ...(output ? { output } : {}), ...(images.length ? { images } : {}) });
|
|
158
121
|
}
|
|
159
122
|
return map;
|
|
160
123
|
}
|
|
@@ -162,16 +125,14 @@ function buildToolResultMap(events) {
|
|
|
162
125
|
/**
|
|
163
126
|
* 把整窗工具结果折成客户端补丁(`/session/messages` 顶层的 `toolResults`)。
|
|
164
127
|
*
|
|
165
|
-
* 补丁本身必须取自整个窗口:调用方游标可能已经越过结果 seq
|
|
166
|
-
* 客户端按 callId
|
|
167
|
-
* `items` 里的 tool-call
|
|
168
|
-
*
|
|
128
|
+
* 补丁本身必须取自整个窗口:调用方游标可能已经越过结果 seq,而调用块还停在 running,
|
|
129
|
+
* 客户端按 callId 幂等打补丁。但**已经随消息块下发的**那份结果不再重复带料 ——
|
|
130
|
+
* `items` 里的 tool-call 块本来就是同一张表构出来的,图片与定时任务卡要的那份正文再传一遍
|
|
131
|
+
* 等于同一份内容在一条响应里走两趟。客户端打补丁时状态照写、料只在给了的时候才覆盖,所以
|
|
169
132
|
* 那几格省掉是安全的;消息块不在这份窗口里时才带全。
|
|
170
133
|
*
|
|
171
134
|
* `bodies: false`(增量拉取)连「消息块不在这份窗口里」的那些也只回状态。理由是同一个:
|
|
172
|
-
*
|
|
173
|
-
* 工具输出再灌一遍,等于每 15 秒把最近两百条消息里的工具输出重发一次(读文件、跑命令的
|
|
174
|
-
* 输出几十到几百 KB 很常见)。要全文的那条路是入口整份拉取(无游标)与展开时重新拉。
|
|
135
|
+
* 增量拉取的调用方手上已经有一份完整的会话,缺的只是「这几次调用结束了没有」。
|
|
175
136
|
*/
|
|
176
137
|
function toolResultPatchesFor(events, items, options = {}) {
|
|
177
138
|
const bodies = options.bodies !== false;
|
|
@@ -192,24 +153,20 @@ function toolResultPatchesFor(events, items, options = {}) {
|
|
|
192
153
|
state,
|
|
193
154
|
...(result.output ? { output: result.output } : {}),
|
|
194
155
|
...(result.images && result.images.length ? { images: result.images } : {}),
|
|
195
|
-
...(result.diffs && result.diffs.length ? { diffs: result.diffs } : {}),
|
|
196
156
|
};
|
|
197
157
|
});
|
|
198
158
|
}
|
|
199
159
|
|
|
200
|
-
/** 把 assistant message.content 解析为一个结构块数组(text/
|
|
160
|
+
/** 把 assistant message.content 解析为一个结构块数组(text/tool-call/image)。 */
|
|
201
161
|
function parseBlocks(blocks, toolResults) {
|
|
202
162
|
if (!Array.isArray(blocks)) return [];
|
|
203
163
|
const out = [];
|
|
204
|
-
for (const
|
|
164
|
+
for (const raw of blocks) {
|
|
165
|
+
// 推理不下发、tool-call 收成骨架(三张轮尾卡的工具留 arguments)—— 与直播间同一条规则。
|
|
166
|
+
const b = cutAssistantBlock(raw);
|
|
205
167
|
if (!b || typeof b.type !== 'string') continue;
|
|
206
168
|
if (b.type === 'text' && typeof b.text === 'string' && b.text) {
|
|
207
169
|
out.push({ type: 'text', text: b.text });
|
|
208
|
-
} else if (b.type === 'reasoning') {
|
|
209
|
-
// Reasoning blocks may be emitted as empty placeholders when the provider
|
|
210
|
-
// does not expose chain-of-thought. They are known blocks, not unsupported
|
|
211
|
-
// content; silently omit empty placeholders and avoid leaking their JSON.
|
|
212
|
-
if (typeof b.text === 'string' && b.text) out.push({ type: 'reasoning', text: b.text });
|
|
213
170
|
} else if (b.type === 'image' && b.dataUnavailable === true) {
|
|
214
171
|
// The image attachment resolver marked this block as unavailable. Keep it out of
|
|
215
172
|
// visible text; pushMessage projects its retry metadata onto unavailableImages.
|
|
@@ -220,21 +177,18 @@ function parseBlocks(blocks, toolResults) {
|
|
|
220
177
|
const entry = imageEntryOf(b);
|
|
221
178
|
if (entry) out.push({ type: 'image', ...entry });
|
|
222
179
|
} else if (b.type === 'tool-call') {
|
|
223
|
-
//
|
|
180
|
+
// 块已被上面那条规则收过:非白名单工具只剩 `{ id, name }`,所以 argsRaw 只有三张
|
|
181
|
+
// 轮尾卡的工具才有(App 从它读计划/扩展/定时任务的料)。
|
|
224
182
|
const callId = b.id != null ? String(b.id) : undefined;
|
|
225
183
|
let state;
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
let diffs;
|
|
184
|
+
let output;
|
|
185
|
+
let images;
|
|
229
186
|
if (callId && toolResults) {
|
|
230
187
|
const result = toolResults.get(callId);
|
|
231
188
|
if (result) {
|
|
232
189
|
output = result.output;
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
// 有它就替换掉仅凭 arguments 猜出来的调用态。
|
|
236
|
-
diffs = result.diffs;
|
|
237
|
-
// 对齐桌面 toolRowModel 状态派生:error.code === 'interrupted' → stopped, isError → error, ok
|
|
190
|
+
images = result.images;
|
|
191
|
+
// 对齐桌面 toolRowModel 状态派生:error.code === 'interrupted' → stopped, isError → error, ok
|
|
238
192
|
if (result.isError) {
|
|
239
193
|
state = result.code === 'interrupted' ? 'stopped' : 'error';
|
|
240
194
|
} else {
|
|
@@ -248,9 +202,8 @@ function parseBlocks(blocks, toolResults) {
|
|
|
248
202
|
name: typeof b.name === 'string' ? b.name : undefined,
|
|
249
203
|
argsRaw: typeof b.arguments === 'string' ? b.arguments : (b.arguments != null ? JSON.stringify(b.arguments) : undefined),
|
|
250
204
|
...state ? { state } : {},
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
...diffs && diffs.length ? { diffs } : {},
|
|
205
|
+
...typeof output === 'string' && output ? { output } : {},
|
|
206
|
+
...images && images.length ? { images } : {},
|
|
254
207
|
});
|
|
255
208
|
} else {
|
|
256
209
|
// 桌面端对未知 assistant block 使用 JsonBlock 兜底;移动端没有对应组件,
|
|
@@ -261,6 +214,31 @@ function parseBlocks(blocks, toolResults) {
|
|
|
261
214
|
return out;
|
|
262
215
|
}
|
|
263
216
|
|
|
217
|
+
/**
|
|
218
|
+
* 一条消息里 write/edit 成功写过的文件路径。
|
|
219
|
+
*
|
|
220
|
+
* 参数不下发手机(见 `wire-cut.js`),但「这一轮动过哪些文件」仍要在本机从**原始**块的
|
|
221
|
+
* `arguments` 里派生出来:App 拿这份清单解析收尾正文里的行内文件引用。
|
|
222
|
+
*/
|
|
223
|
+
function writtenPaths(content, toolResults) {
|
|
224
|
+
if (!Array.isArray(content)) return [];
|
|
225
|
+
const out = [];
|
|
226
|
+
for (const b of content) {
|
|
227
|
+
if (!b || typeof b !== 'object' || b.type !== 'tool-call') continue;
|
|
228
|
+
if (b.name !== 'write' && b.name !== 'edit') continue;
|
|
229
|
+
const callId = b.id != null ? String(b.id) : undefined;
|
|
230
|
+
const result = callId && toolResults ? toolResults.get(callId) : undefined;
|
|
231
|
+
if (!result || result.isError) continue;
|
|
232
|
+
const raw = typeof b.arguments === 'string' ? b.arguments : (b.arguments != null ? JSON.stringify(b.arguments) : '');
|
|
233
|
+
try {
|
|
234
|
+
const args = JSON.parse(raw || '{}');
|
|
235
|
+
const path = typeof args.path === 'string' ? args.path : typeof args.file_path === 'string' ? args.file_path : null;
|
|
236
|
+
if (typeof path === 'string' && path) out.push(path);
|
|
237
|
+
} catch { /* 参数不是 JSON:这条调用不算产出,与「没有参数」同处理 */ }
|
|
238
|
+
}
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
|
|
264
242
|
/** 从结构块里拼接纯文本(列表预览/标题回退用,仍只取 text 块)。 */
|
|
265
243
|
function blocksToText(blocks) {
|
|
266
244
|
return blocks.filter((b) => b.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('\n');
|
|
@@ -503,15 +481,8 @@ function deriveMessages(events) {
|
|
|
503
481
|
// 这一轮的轮号(老日志没有):产出清单与交付声明都按它归并。
|
|
504
482
|
const turn = e.data && typeof e.data.turn === 'number' ? e.data.turn : undefined;
|
|
505
483
|
// 本条消息自己写 / 改过的文件:并进**这一轮**的那一份,不单独挂在它自己头上。
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
.map((block) => {
|
|
509
|
-
try {
|
|
510
|
-
const args = JSON.parse(block.argsRaw || '{}');
|
|
511
|
-
return typeof args.path === 'string' ? args.path : typeof args.file_path === 'string' ? args.file_path : null;
|
|
512
|
-
} catch { return null; }
|
|
513
|
-
})
|
|
514
|
-
.filter((path) => typeof path === 'string');
|
|
484
|
+
// 从原始块的 arguments 算(那些参数不下发手机)。
|
|
485
|
+
const ownWrites = writtenPaths(msg.content, toolResults);
|
|
515
486
|
if (turn !== undefined && ownWrites.length > 0) {
|
|
516
487
|
const bucket = producedByTurn.get(turn) || new Set();
|
|
517
488
|
for (const path of ownWrites) bucket.add(path);
|
|
@@ -981,8 +952,6 @@ module.exports = {
|
|
|
981
952
|
errorDetails,
|
|
982
953
|
promptErrorObject,
|
|
983
954
|
MODEL_UNAVAILABLE_ERROR,
|
|
984
|
-
capDiffText,
|
|
985
|
-
normalizeResultDiffs,
|
|
986
955
|
buildToolResultMap,
|
|
987
956
|
toolResultPatchesFor,
|
|
988
957
|
parseBlocks,
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* 内联形状,App 不需要新增通道。
|
|
10
10
|
*
|
|
11
11
|
* 保守取舍——宁可保留字面 markdown,也不给半截东西:
|
|
12
|
-
* -
|
|
12
|
+
* - 本机路径认三种:绝对(`/x/y.png`、`file:///x/y.png`)、`~/y.png`、相对(`deliverables/a.png`,
|
|
13
|
+
* 按调用方给的工作区目录解析,与桌面端同口径;没给目录就当成不是本机图);http(s)、`data:` 不碰;
|
|
13
14
|
* - 只认 png/jpg/jpeg/webp/gif,并核对魔术字节,不按扩展名撒谎;
|
|
14
15
|
* - 三级体积预算(单图 / 单段 / 单次响应),超预算就保持字面;
|
|
15
16
|
* - 读不到、不是普通文件、读取失败一律原样保留;
|
|
@@ -63,10 +64,13 @@ function sniffMediaType(buf) {
|
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
/**
|
|
66
|
-
* `` 里的地址 →
|
|
67
|
+
* `` 里的地址 → 本机路径;不是本机图片(远程、data、非图片后缀)返回 null。
|
|
67
68
|
* 尖括号包裹的目的地(CommonMark 允许 ``)在这里一并剥掉。
|
|
69
|
+
*
|
|
70
|
+
* 相对地址(`deliverables/a.png`、`./a.png`)按 `cwd` 解析——那是会话的工作区目录,也是
|
|
71
|
+
* 桌面端画正文图的基准;没给 `cwd` 时相对地址照样返回 null(老口径:保持字面 markdown)。
|
|
68
72
|
*/
|
|
69
|
-
function localImagePathOf(url) {
|
|
73
|
+
function localImagePathOf(url, cwd) {
|
|
70
74
|
if (typeof url !== 'string') return null;
|
|
71
75
|
let value = url.trim();
|
|
72
76
|
if (!value) return null;
|
|
@@ -82,12 +86,39 @@ function localImagePathOf(url) {
|
|
|
82
86
|
} else if (value === '~' || value.startsWith('~/')) {
|
|
83
87
|
value = path.join(os.homedir(), value.slice(1));
|
|
84
88
|
}
|
|
85
|
-
if (!path.isAbsolute(value))
|
|
89
|
+
if (!path.isAbsolute(value)) {
|
|
90
|
+
if (!cwd) return null;
|
|
91
|
+
value = path.resolve(cwd, value);
|
|
92
|
+
}
|
|
86
93
|
if (!mediaTypeFor(value)) return null;
|
|
87
94
|
return path.normalize(value);
|
|
88
95
|
}
|
|
89
96
|
|
|
90
|
-
/**
|
|
97
|
+
/**
|
|
98
|
+
* 正文里有没有「相对地址」的本机图片语法。
|
|
99
|
+
*
|
|
100
|
+
* 只有这种文本才值得去取会话的工作区目录(`sessions.list` 要扫一遍全部会话),
|
|
101
|
+
* 其余情况一条会话列表都不查:绝对路径照常内联,相对地址原样留着。
|
|
102
|
+
*/
|
|
103
|
+
function hasRelativeImage(text) {
|
|
104
|
+
if (typeof text !== 'string' || text.indexOf('![') < 0) return false;
|
|
105
|
+
for (const m of text.matchAll(IMAGE_RE)) {
|
|
106
|
+
let dest = m[2].trim();
|
|
107
|
+
if (dest.startsWith('<') && dest.endsWith('>')) dest = dest.slice(1, -1).trim();
|
|
108
|
+
if (!dest || path.isAbsolute(dest)) continue;
|
|
109
|
+
if (dest === '~' || dest.startsWith('~/')) continue;
|
|
110
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(dest)) continue;
|
|
111
|
+
if (mediaTypeFor(dest)) return true;
|
|
112
|
+
}
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 一次响应共用的内联预算;不给就按单次调用算一份。
|
|
118
|
+
*
|
|
119
|
+
* `cwd` 由调用方按需补上(相对地址的解析基准,见 localImagePathOf):`undefined` 表示
|
|
120
|
+
* 「还没查过工作区目录」,空串表示「查过了,这条会话没有」。
|
|
121
|
+
*/
|
|
91
122
|
function createTextImageBudget(limit = MAX_RESPONSE_CHARS) {
|
|
92
123
|
return { remaining: limit };
|
|
93
124
|
}
|
|
@@ -215,7 +246,7 @@ async function inlineTextImages(text, budget) {
|
|
|
215
246
|
let cursor = 0;
|
|
216
247
|
for (const m of matches) {
|
|
217
248
|
if (textBudget <= 0 || pool.remaining <= 0) break;
|
|
218
|
-
const filePath = localImagePathOf(m[2]);
|
|
249
|
+
const filePath = localImagePathOf(m[2], pool.cwd);
|
|
219
250
|
if (!filePath) continue;
|
|
220
251
|
const uri = await dataUriFor(filePath);
|
|
221
252
|
if (!uri || uri.length > textBudget || uri.length > pool.remaining) continue;
|
|
@@ -227,4 +258,4 @@ async function inlineTextImages(text, budget) {
|
|
|
227
258
|
return cursor === 0 ? text : out + text.slice(cursor);
|
|
228
259
|
}
|
|
229
260
|
|
|
230
|
-
module.exports = { inlineTextImages, createTextImageBudget, localImagePathOf };
|
|
261
|
+
module.exports = { inlineTextImages, createTextImageBudget, localImagePathOf, hasRelativeImage };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 模型 → 厂商的识别,以及厂商图标(`vendor-logos.cjs` 那张表)的取用。
|
|
5
|
+
*
|
|
6
|
+
* 为什么需要猜:宿主只给「渠道 id + 模型 id」,没有厂商这一栏,而同一个模型在不同渠道下写法
|
|
7
|
+
* 各不相同 —— `deepseek-v4.1-flash`、`DeepSeek V4.1 flash`、`deepseek_v4_1_flash` 都是同一个
|
|
8
|
+
* 东西;有些渠道还把模型名整个改掉(`route-model-a`)。所以先规范化(小写、去掉所有分隔符),
|
|
9
|
+
* 再按关键词认;模型名认不出时再看渠道名(`anthropic`、`moonshotai` 这类渠道 id 往往就是厂商)。
|
|
10
|
+
*
|
|
11
|
+
* 认不出来的就**不猜**:宁可那一行退回首字母徽标,也不要给一个错的品牌 —— 厂商名是用户用来
|
|
12
|
+
* 认模型的,猜错比空着更糟。这条与「取不到就整块不出」是同一个口径。
|
|
13
|
+
*
|
|
14
|
+
* 纯函数、不碰网络也不碰文件,守卫直接跑真实现。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const { VENDOR_LOGOS } = require('./vendor-logos.cjs');
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 规范化:小写、只留字母与数字(汉字保留 —— 国产模型的别名经常是中文)。
|
|
21
|
+
*
|
|
22
|
+
* 去掉分隔符是关键:`v4.1` / `v4-1` / `v4_1` 都应该落到同一个串上,否则每种写法都要单独列一条。
|
|
23
|
+
*/
|
|
24
|
+
function foldText(value) {
|
|
25
|
+
return typeof value === 'string' ? value.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '') : '';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 按非字母数字切开的小写片段:给「o3」「phi」这种两三个字符的短词用(它们不能按子串认)。 */
|
|
29
|
+
function tokensOf(value) {
|
|
30
|
+
return typeof value === 'string'
|
|
31
|
+
? value.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean)
|
|
32
|
+
: [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 认法表。匹配顺序就是优先级,长关键词在前。
|
|
37
|
+
*
|
|
38
|
+
* `terms` 在规范化后的串上按子串认(`deepseekv41flash` 里含 `deepseek`);
|
|
39
|
+
* `tokens` 按切开后的整段认(`o3-mini` 的 `o3`、`phi-4` 的 `phi`)—— 短词按子串认会把
|
|
40
|
+
* 一堆无关模型认成同一家。
|
|
41
|
+
*/
|
|
42
|
+
const VENDOR_PATTERNS = [
|
|
43
|
+
['deepseek', { terms: ['deepseek', '深度求索', '深度探索'] }],
|
|
44
|
+
['anthropic', { terms: ['claude', 'anthropic', 'sonnet', 'opus', 'haiku'] }],
|
|
45
|
+
['openai', { terms: ['openai', 'chatgpt', 'gpt', 'codex', 'davinci', 'omni'], tokens: ['o1', 'o3', 'o4'] }],
|
|
46
|
+
['google', { terms: ['gemini', 'gemma', 'google', 'vertex', 'bard'] }],
|
|
47
|
+
['alibaba', { terms: ['qwen', 'qwq', 'tongyi', 'alibaba', '通义', '千问', '阿里'] }],
|
|
48
|
+
['moonshot', { terms: ['kimi', 'moonshot', '月之暗面'] }],
|
|
49
|
+
['zhipu', { terms: ['glm', 'chatglm', 'zhipu', '智谱', '清言'] }],
|
|
50
|
+
['meta', { terms: ['llama', 'meta'] }],
|
|
51
|
+
['mistral', { terms: ['mistral', 'mixtral', 'codestral', 'devstral', 'magistral'] }],
|
|
52
|
+
['xai', { terms: ['grok', 'xai'] }],
|
|
53
|
+
['cohere', { terms: ['cohere', 'commandr'] }],
|
|
54
|
+
['bytedance', { terms: ['doubao', 'bytedance', 'seed', '豆包', '火山'] }],
|
|
55
|
+
['baidu', { terms: ['ernie', 'wenxin', 'baidu', '文心', '百度'] }],
|
|
56
|
+
['tencent', { terms: ['hunyuan', 'tencent', '混元', '腾讯'] }],
|
|
57
|
+
['minimax', { terms: ['minimax', 'abab', '海螺'] }],
|
|
58
|
+
['nvidia', { terms: ['nemotron', 'nvidia', '英伟达'] }],
|
|
59
|
+
['microsoft', { terms: ['microsoft', 'azure'], tokens: ['phi', 'phi3', 'phi4', 'phi35'] }],
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
/** 一段文字认哪家;认不出返回空串。 */
|
|
63
|
+
function vendorOfText(value) {
|
|
64
|
+
const folded = foldText(value);
|
|
65
|
+
if (!folded) return '';
|
|
66
|
+
for (const [vendor, rule] of VENDOR_PATTERNS) {
|
|
67
|
+
if (rule.terms && rule.terms.some((term) => folded.includes(term))) return vendor;
|
|
68
|
+
}
|
|
69
|
+
const tokens = tokensOf(value);
|
|
70
|
+
if (tokens.length > 0) {
|
|
71
|
+
for (const [vendor, rule] of VENDOR_PATTERNS) {
|
|
72
|
+
if (rule.tokens && rule.tokens.some((token) => tokens.includes(token))) return vendor;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return '';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 一条模型用量认哪家:先看模型名 / 模型 id,再看渠道名 / 渠道 id。
|
|
80
|
+
*
|
|
81
|
+
* 顺序有意如此 —— 渠道可以挂着任何模型(自建渠道尤其如此),名字写对了就以名字为准;
|
|
82
|
+
* 只有名字认不出来时才用渠道兜底,这也是很多网关渠道的实际情况(渠道叫 `anthropic`、
|
|
83
|
+
* 模型名是它自己起的别名)。
|
|
84
|
+
*
|
|
85
|
+
* 返回的是**认出来的那一家**,跟它有没有图标无关:有一家(比如 GLM / Grok)暂时没有图标,
|
|
86
|
+
* 这一行就该退回首字母徽标,而不是接着往下试渠道名 —— 那样「模型名认出 A、渠道名像 B」时会
|
|
87
|
+
* 认成 B,摆上别家的 logo。认错厂商比不认更糟,这一条是硬规矩。
|
|
88
|
+
*/
|
|
89
|
+
function identifyVendor(entry) {
|
|
90
|
+
if (!entry || typeof entry !== 'object') return '';
|
|
91
|
+
const candidates = [entry.modelName, entry.model, entry.providerName, entry.provider];
|
|
92
|
+
for (const candidate of candidates) {
|
|
93
|
+
const vendor = vendorOfText(candidate);
|
|
94
|
+
if (vendor) return vendor;
|
|
95
|
+
}
|
|
96
|
+
return '';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 厂商的展示名与图标标记;不在表里返回 null。 */
|
|
100
|
+
function vendorLogoOf(vendor) {
|
|
101
|
+
const entry = VENDOR_LOGOS[vendor];
|
|
102
|
+
return entry ? { name: entry.name, icon: entry.icon } : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = { foldText, tokensOf, vendorOfText, identifyVendor, vendorLogoOf, VENDOR_PATTERNS };
|