dsh-mobile-plugin 0.1.100 → 0.2.1
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 +14 -5
- package/dsh-mobile-credentials/lib/credentials.js +405 -84
- package/lib/client.js +31 -8
- package/lib/http/routing.js +165 -9
- package/lib/impl.js +88 -9
- package/lib/index.js +3 -2
- package/lib/mirror/pusher.cjs +35 -4
- package/lib/mobile/approvals.js +62 -15
- package/lib/mobile/auth.js +46 -26
- package/lib/mobile/credential-upgrade.js +137 -0
- package/lib/mobile/entry-window.js +32 -0
- package/lib/mobile/events.js +14 -4
- package/lib/mobile/extensions.js +279 -0
- package/lib/mobile/history-derive.js +209 -11
- package/lib/mobile/host-bridge/event-synthesis.js +61 -10
- package/lib/mobile/pairing.js +150 -64
- package/lib/mobile/plugin-self.js +187 -0
- package/lib/mobile/push-notify-route.js +37 -0
- package/lib/mobile/push-notify.js +84 -0
- package/lib/mobile/questions.js +75 -10
- package/lib/mobile/relay-control.js +547 -0
- package/lib/mobile/session-cache.js +60 -2
- package/lib/mobile/session-file.js +133 -34
- package/lib/mobile/session-list-shortcut.js +38 -0
- package/lib/mobile/session-model-options.js +3 -1
- package/lib/mobile/session-models.js +15 -3
- package/lib/mobile/session-send.js +30 -27
- package/lib/mobile/session-snapshot.js +12 -12
- package/lib/mobile/sessions.js +20 -0
- package/lib/mobile/stats-collect.cjs +55 -12
- package/lib/mobile/stats-ledger.cjs +140 -21
- package/lib/mobile/stream-registry.js +73 -0
- package/lib/mobile/stream.js +382 -31
- package/lib/mobile/subagents.js +11 -2
- package/lib/mobile/uploads.js +91 -5
- package/lib/mobile/workspaces.js +39 -8
- package/lib/panel/index.html +11 -11
- package/lib/relay/core.js +28 -2
- package/lib/relay/lan-network.cjs +47 -0
- package/lib/relay/lan-proxy.js +283 -122
- package/lib/relay/protocol.js +4 -2
- package/lib/relay/tunnel.js +20 -14
- package/lib/routes/control-plane.js +139 -9
- package/lib/routes/mobile-api.js +92 -9
- package/lib/shared/e2ee-pair.js +91 -0
- package/lib/shared/e2ee-plain.js +79 -0
- package/lib/shared/e2ee-request.js +331 -0
- package/lib/shared/e2ee.js +173 -0
- package/lib/shared/http-util.js +27 -4
- package/lib/shared/list-signal.js +16 -0
- package/lib/shared/pair-throttle.js +150 -0
- package/lib/shared/sse-write.js +16 -5
- package/lib/shared/via-header.js +65 -0
- package/lib/shell/runtime.js +147 -42
- package/lib/shell/self-check-after-swap.js +93 -0
- package/lib/shell/updater.js +44 -3
- package/lib/sidecar-facade-worker.cjs +70 -6
- package/lib/update-install.cjs +13 -5
- package/package.json +3 -2
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 扩展卡的宿主侧:卡状态账本、扩展动作登记表,以及给模型用的 `extension_present` 工具。
|
|
5
|
+
*
|
|
6
|
+
* 为什么卡状态要落在宿主家里:一张卡「现在长什么样」不能只靠声明它的那一次工具调用。
|
|
7
|
+
* 扩展页回写标题与说明之后,手机上重进会话、刷新列表都会从历史重新拼卡 —— 没有这份账本,
|
|
8
|
+
* 卡会一直停在声明那一刻的样子(卡的状态得由宿主认账)。账本与会话
|
|
9
|
+
* 日志无关,清理会话不连带它,与统计账本同口径。
|
|
10
|
+
*
|
|
11
|
+
* 为什么动作名是一张硬编码白名单:扩展是网页,它能让宿主做的事必须由宿主预先枚举,而不是
|
|
12
|
+
* 由扩展在消息里现拼一个动作名交给宿主执行。这条边界和移动 API 的能力面是同一条 —— 移动
|
|
13
|
+
* API 不暴露 shell、系统设置、任意文件读写,这里也一个都不开。**登记表现在是空的**:宿主动作
|
|
14
|
+
* 这一档还没有对任何扩展放开,所以任何动作名都必须被拒。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
|
|
20
|
+
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
21
|
+
const { dshHome } = require('../shared/paths');
|
|
22
|
+
|
|
23
|
+
/** 账本文件名。跟会话日志分开,清理会话时不动它。 */
|
|
24
|
+
const CARDS_FILE = 'mobile-extension-cards.json';
|
|
25
|
+
/** 一条卡状态的标题 / 说明上限:App 那张卡只画一行,长正文只会把它撑坏。 */
|
|
26
|
+
const MAX_TEXT = 200;
|
|
27
|
+
/** 整张表的上限:会话越积越多,不封顶的话这份 JSON 会一直长下去。 */
|
|
28
|
+
const MAX_CARDS = 500;
|
|
29
|
+
|
|
30
|
+
function cardsFile() {
|
|
31
|
+
return path.join(dshHome(), CARDS_FILE);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 读整张表。文件不在、读坏、形状不对都当空表 —— 账本是锦上添花,读不出来不该让请求失败
|
|
36
|
+
* (卡按声明的样子画,下一次回写会把它补回来)。
|
|
37
|
+
*/
|
|
38
|
+
function readLedger() {
|
|
39
|
+
let text;
|
|
40
|
+
try { text = fs.readFileSync(cardsFile(), 'utf8'); } catch { return {}; }
|
|
41
|
+
let parsed;
|
|
42
|
+
try { parsed = JSON.parse(text); } catch { return {}; }
|
|
43
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
|
44
|
+
return parsed;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 整份覆写。**写失败不抛**:回写卡状态是扩展页的一次旁路动作,写不进去最多是下次进会话
|
|
49
|
+
* 看到旧状态,不该把这次请求变成失败。仍然是先写临时文件再改名,宿主随时可能被杀。
|
|
50
|
+
*/
|
|
51
|
+
function writeLedger(table) {
|
|
52
|
+
const file = cardsFile();
|
|
53
|
+
try {
|
|
54
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
55
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
56
|
+
fs.writeFileSync(temp, `${JSON.stringify(table, null, 2)}\n`);
|
|
57
|
+
fs.renameSync(temp, file);
|
|
58
|
+
} catch { /* 旁路:写不进去就算了 */ }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function cardKey(sessionId, cardId) {
|
|
62
|
+
return `${sessionId}:${cardId}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function textOf(value) {
|
|
66
|
+
return typeof value === 'string' ? value.slice(0, MAX_TEXT) : '';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 一条记录至少要有身份:会话、卡、扩展三者缺一就没有可认的卡。 */
|
|
70
|
+
function validCard(input) {
|
|
71
|
+
if (!input || typeof input !== 'object') return null;
|
|
72
|
+
const sessionId = typeof input.sessionId === 'string' ? input.sessionId.trim() : '';
|
|
73
|
+
const cardId = typeof input.cardId === 'string' ? input.cardId.trim() : '';
|
|
74
|
+
const extension = typeof input.extension === 'string' ? input.extension.trim() : '';
|
|
75
|
+
const title = textOf(input.title);
|
|
76
|
+
const detail = textOf(input.detail);
|
|
77
|
+
// title 与 detail 都空等于「这张卡什么都没变」,收下来只会把声明时那句话擦掉。
|
|
78
|
+
if (!sessionId || !cardId || !extension) return null;
|
|
79
|
+
if (!title && !detail) return null;
|
|
80
|
+
return { sessionId, cardId, extension, title, detail };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 丢掉最旧的那几条,直到表回到上限以内。 */
|
|
84
|
+
function trimLedger(table) {
|
|
85
|
+
const entries = Object.entries(table);
|
|
86
|
+
if (entries.length <= MAX_CARDS) return;
|
|
87
|
+
entries.sort((left, right) => (Number(left[1].updatedAt) || 0) - (Number(right[1].updatedAt) || 0));
|
|
88
|
+
for (const [key] of entries.slice(0, entries.length - MAX_CARDS)) delete table[key];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 写一条卡状态。参数不合法回 null(调用方据此回 400),合法则回完整记录。
|
|
93
|
+
*
|
|
94
|
+
* 同一张卡重复回写是覆盖,不追加:`sessionId:cardId` 就是这张卡的身份,与 App 侧
|
|
95
|
+
* 卡上的 id(那次工具调用的 id)是同一个东西。
|
|
96
|
+
*/
|
|
97
|
+
function writeCard(input) {
|
|
98
|
+
const card = validCard(input);
|
|
99
|
+
if (!card) return null;
|
|
100
|
+
const table = readLedger();
|
|
101
|
+
const record = { ...card, updatedAt: Date.now() };
|
|
102
|
+
table[cardKey(card.sessionId, card.cardId)] = record;
|
|
103
|
+
trimLedger(table);
|
|
104
|
+
writeLedger(table);
|
|
105
|
+
return record;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 这条会话里被回写过的卡状态。坏行丢掉,别的会话的一律不带上。 */
|
|
109
|
+
function readCards(sessionId) {
|
|
110
|
+
if (typeof sessionId !== 'string' || !sessionId) return [];
|
|
111
|
+
const table = readLedger();
|
|
112
|
+
const cards = [];
|
|
113
|
+
for (const value of Object.values(table)) {
|
|
114
|
+
if (!value || typeof value !== 'object') continue;
|
|
115
|
+
if (value.sessionId !== sessionId) continue;
|
|
116
|
+
if (typeof value.cardId !== 'string' || !value.cardId) continue;
|
|
117
|
+
if (typeof value.extension !== 'string' || !value.extension) continue;
|
|
118
|
+
cards.push({
|
|
119
|
+
cardId: value.cardId,
|
|
120
|
+
extension: value.extension,
|
|
121
|
+
title: typeof value.title === 'string' ? value.title : '',
|
|
122
|
+
detail: typeof value.detail === 'string' ? value.detail : '',
|
|
123
|
+
updatedAt: typeof value.updatedAt === 'number' ? value.updatedAt : 0,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return cards;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 已登记的宿主动作:扩展 slug → { 动作名: 处理函数 }。
|
|
131
|
+
*
|
|
132
|
+
* **现在是空的**:宿主动作这一档还没有对任何扩展放开,所以 `actionsFor` 恒回空数组、
|
|
133
|
+
* `runAction` 恒回 `extension-action-unknown`。要放开第三方来源时,在这里逐个扩展、
|
|
134
|
+
* 逐个动作名登记 —— 只认这张表里的字面名字,不接受通配,也不接受调用方现拼的动作名。
|
|
135
|
+
* 处理函数拿到的是扩展带来的 params,返回值放进 `result` 原样交回。
|
|
136
|
+
*/
|
|
137
|
+
const HOST_ACTIONS = Object.freeze({
|
|
138
|
+
// '某个扩展的 slug': {
|
|
139
|
+
// '某个动作名': async (params) => ({ /* 交回给扩展的结果 */ }),
|
|
140
|
+
// },
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
/** 这个扩展被宿主预先枚举过的动作名;没登记过就是空数组。 */
|
|
144
|
+
function actionsFor(extensionSlug) {
|
|
145
|
+
if (typeof extensionSlug !== 'string' || !extensionSlug) return [];
|
|
146
|
+
const table = HOST_ACTIONS[extensionSlug];
|
|
147
|
+
return table ? Object.keys(table) : [];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 让宿主替扩展办一件事。
|
|
152
|
+
*
|
|
153
|
+
* 动作名不在 `actionsFor` 里一律拒 —— 包括 `*` 这类看起来像通配的写法。有登记时按登记表
|
|
154
|
+
* 里的处理函数执行;登记表现在是空的,所以这条分支只剩形状(放开时在下面接处理函数)。
|
|
155
|
+
*
|
|
156
|
+
* **不抛异常**:被拒是预期结果,不是故障,调用方要拿它回 403 而不是 500。
|
|
157
|
+
*/
|
|
158
|
+
async function runAction(input) {
|
|
159
|
+
const extension = input && typeof input.extension === 'string' ? input.extension : '';
|
|
160
|
+
const action = input && typeof input.action === 'string' ? input.action : '';
|
|
161
|
+
if (!extension || !action || !actionsFor(extension).includes(action)) {
|
|
162
|
+
return { ok: false, code: 'extension-action-unknown', message: '该扩展没有登记这个动作' };
|
|
163
|
+
}
|
|
164
|
+
const handler = HOST_ACTIONS[extension][action];
|
|
165
|
+
return { ok: true, result: await handler(input.params) };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 模型声明一张扩展卡的工具。
|
|
170
|
+
*
|
|
171
|
+
* 卡本身不是这里存的:工具只把「哪个扩展、哪张卡、写了什么」说清楚,App 从历史里那次调用
|
|
172
|
+
* 的参数画出卡,之后的状态回写落在上面那张账本里。所以这里不写账本 —— 声明与回写是两件事
|
|
173
|
+
* (卡可以声明之后再也没人动过)。
|
|
174
|
+
*/
|
|
175
|
+
function registerExtensionTool(ctx) {
|
|
176
|
+
const { defineTool } = require('@deepseek-ai/dsh-tools');
|
|
177
|
+
ctx.tools.register(defineTool({
|
|
178
|
+
name: 'extension_present',
|
|
179
|
+
description: 'Declare a card that an installed extension takes over (for example a draft or a form flow). The card is drawn from this call; the extension page can later write back its title and detail.',
|
|
180
|
+
parameters: {
|
|
181
|
+
extension: { type: 'string', required: true, description: 'Slug of the installed extension that takes over this content.' },
|
|
182
|
+
title: { type: 'string', required: true, description: 'One-line title shown on the card.' },
|
|
183
|
+
detail: { type: 'string', description: 'Optional secondary line; keep it short, the card shows one line.' },
|
|
184
|
+
},
|
|
185
|
+
output: {
|
|
186
|
+
schema: { type: 'object', additionalProperties: false, properties: {
|
|
187
|
+
extension: { type: 'string' }, title: { type: 'string' }, detail: { type: 'string' },
|
|
188
|
+
} },
|
|
189
|
+
render: (_args, value) => [{ type: 'text', text: `已为扩展 ${value.extension} 声明卡片:${value.title}` }],
|
|
190
|
+
},
|
|
191
|
+
execute: (args) => ({
|
|
192
|
+
extension: args.extension,
|
|
193
|
+
title: args.title,
|
|
194
|
+
detail: typeof args.detail === 'string' ? args.detail : '',
|
|
195
|
+
}),
|
|
196
|
+
}));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 装配层的入口:挂 `extension_present` 工具。
|
|
201
|
+
*
|
|
202
|
+
* 账本与三条路由不依赖这个工具,所以没有 tools 注入的 profile 照样能回写卡状态;缺注入
|
|
203
|
+
* 时只静默跳过(cordis 抛的是 "cannot get property … without inject",与 session-cleanup
|
|
204
|
+
* 同一口径)。其余错误照抛 —— 不能把真的注册失败也当成「这个 profile 没注入」。
|
|
205
|
+
*/
|
|
206
|
+
function installExtensions(ctx) {
|
|
207
|
+
try {
|
|
208
|
+
registerExtensionTool(ctx);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
const message = error && error.message ? error.message : String(error);
|
|
211
|
+
if (!message.includes('without inject')) throw error;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* GET /api/mobile/v1/extension/cards?sessionId=…(token)
|
|
217
|
+
*
|
|
218
|
+
* 这条会话里被回写过的卡状态;没有会话 id 就回 400,不拿空数组冒充「没有卡」。
|
|
219
|
+
*/
|
|
220
|
+
async function handleExtensionCards(_ctx, req, res) {
|
|
221
|
+
const url = new URL(req.url || '/', 'http://x');
|
|
222
|
+
const sessionId = (url.searchParams.get('sessionId') || '').trim();
|
|
223
|
+
if (!sessionId) {
|
|
224
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 sessionId' } });
|
|
225
|
+
}
|
|
226
|
+
sendJson(res, 200, { ok: true, cards: readCards(sessionId) });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* POST /api/mobile/v1/extension/card-state(token)
|
|
231
|
+
* body: { sessionId, cardId, extension, title?, detail? }
|
|
232
|
+
*
|
|
233
|
+
* `sessionId` / `cardId` / `extension` 由 App 按卡本身填,不由扩展页提供 —— 页面能指定
|
|
234
|
+
* 别人的卡就等于能回写别人的卡。写失败不回错误,理由在 writeLedger 上。
|
|
235
|
+
*/
|
|
236
|
+
async function handleExtensionCardState(_ctx, req, res) {
|
|
237
|
+
const body = await readJsonBody(req, 64 * 1024);
|
|
238
|
+
const card = body ? writeCard(body) : null;
|
|
239
|
+
if (!card) {
|
|
240
|
+
return sendJson(res, 400, {
|
|
241
|
+
ok: false,
|
|
242
|
+
error: { code: 'bad-request', message: '需要 { sessionId, cardId, extension },且 title 与 detail 不能都为空' },
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
sendJson(res, 200, { ok: true, card });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* POST /api/mobile/v1/extension/action(token)
|
|
250
|
+
* body: { extension, action, params? }
|
|
251
|
+
*
|
|
252
|
+
* 没登记的动作回 **403**:这不是请求坏了,是预期内的拒绝,App 侧据此让扩展页自己解释
|
|
253
|
+
* 「这个动作宿主没开放」,而不是当成请求失败。`code` / `message` 放在顶层,与 App 那边
|
|
254
|
+
* 读 `body.code` 的口径一致(见 `app/src/api/domains/extensions.ts`)。
|
|
255
|
+
*/
|
|
256
|
+
async function handleExtensionAction(_ctx, req, res) {
|
|
257
|
+
const body = await readJsonBody(req, 64 * 1024);
|
|
258
|
+
if (!body || typeof body.extension !== 'string' || !body.extension
|
|
259
|
+
|| typeof body.action !== 'string' || !body.action) {
|
|
260
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { extension, action }' } });
|
|
261
|
+
}
|
|
262
|
+
const outcome = await runAction({ extension: body.extension, action: body.action, params: body.params });
|
|
263
|
+
if (!outcome.ok) return sendJson(res, 403, { ok: false, code: outcome.code, message: outcome.message });
|
|
264
|
+
sendJson(res, 200, { ok: true, result: outcome.result });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
module.exports = {
|
|
268
|
+
actionsFor,
|
|
269
|
+
runAction,
|
|
270
|
+
readCards,
|
|
271
|
+
writeCard,
|
|
272
|
+
registerExtensionTool,
|
|
273
|
+
installExtensions,
|
|
274
|
+
handleExtensionCards,
|
|
275
|
+
handleExtensionCardState,
|
|
276
|
+
handleExtensionAction,
|
|
277
|
+
MAX_TEXT,
|
|
278
|
+
MAX_CARDS,
|
|
279
|
+
};
|
|
@@ -162,6 +162,44 @@ function buildToolResultMap(events) {
|
|
|
162
162
|
return map;
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
/**
|
|
166
|
+
* 把整窗工具结果折成客户端补丁(`/session/messages` 顶层的 `toolResults`)。
|
|
167
|
+
*
|
|
168
|
+
* 补丁本身必须取自整个窗口:调用方游标可能已经越过结果 seq,而工具卡还停在 running,
|
|
169
|
+
* 客户端按 callId 幂等打补丁。但**已经随消息块下发的**那份结果不再重复带正文 ——
|
|
170
|
+
* `items` 里的 tool-call 块本来就是同一张表构出来的,输出/图片/diff 再传一遍等于同一份
|
|
171
|
+
* 工具输出在一条响应里走两趟。客户端打补丁时状态照写、正文只在给了的时候才覆盖,所以
|
|
172
|
+
* 那几格省掉是安全的;消息块不在这份窗口里时才带全。
|
|
173
|
+
*
|
|
174
|
+
* `bodies: false`(增量拉取)连「消息块不在这份窗口里」的那些也只回状态。理由是同一个:
|
|
175
|
+
* 增量拉取的调用方手上已经有一份完整的会话,缺的只是「这几次调用结束了没有」;把整窗的
|
|
176
|
+
* 工具输出再灌一遍,等于每 15 秒把最近两百条消息里的工具输出重发一次(读文件、跑命令的
|
|
177
|
+
* 输出几十到几百 KB 很常见)。要全文的那条路是入口整份拉取(无游标)与展开时重新拉。
|
|
178
|
+
*/
|
|
179
|
+
function toolResultPatchesFor(events, items, options = {}) {
|
|
180
|
+
const bodies = options.bodies !== false;
|
|
181
|
+
const delivered = new Set();
|
|
182
|
+
for (const item of items || []) {
|
|
183
|
+
if (!item || !Array.isArray(item.blocks)) continue;
|
|
184
|
+
for (const block of item.blocks) {
|
|
185
|
+
if (block && block.type === 'tool-call' && typeof block.callId === 'string' && block.callId) {
|
|
186
|
+
delivered.add(block.callId);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return [...buildToolResultMap(events).entries()].map(([callId, result]) => {
|
|
191
|
+
const state = result.isError ? (result.code === 'interrupted' ? 'stopped' : 'error') : 'ok';
|
|
192
|
+
if (!bodies || delivered.has(callId)) return { callId, state };
|
|
193
|
+
return {
|
|
194
|
+
callId,
|
|
195
|
+
state,
|
|
196
|
+
...(result.output ? { output: result.output } : {}),
|
|
197
|
+
...(result.images && result.images.length ? { images: result.images } : {}),
|
|
198
|
+
...(result.diffs && result.diffs.length ? { diffs: result.diffs } : {}),
|
|
199
|
+
};
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
165
203
|
/** 把 assistant message.content 解析为一个结构块数组(text/reasoning/tool-call/image)。 */
|
|
166
204
|
function parseBlocks(blocks, toolResults) {
|
|
167
205
|
if (!Array.isArray(blocks)) return [];
|
|
@@ -341,6 +379,19 @@ function deriveMessages(events) {
|
|
|
341
379
|
if (bucket.length > 0) presentedByTurn.set(data.turn, bucket);
|
|
342
380
|
}
|
|
343
381
|
|
|
382
|
+
/**
|
|
383
|
+
* 这一轮**被 write / edit 动过**的文件,按轮攒。
|
|
384
|
+
*
|
|
385
|
+
* 为什么按轮而不是按单条消息:一份文件是一轮里某一步写的,而收尾答复那条消息自己一个
|
|
386
|
+
* 写操作都没有 —— 按消息算的话,正文里提到刚写好的文件时那份清单永远是空的(2026-10-25
|
|
387
|
+
* 现场:手机上那个文件名一直是灰的,数一遍 54 条消息只有 4 条带得动这份清单)。
|
|
388
|
+
* 桌面 `dsh-client-ui-deliverables` 的 `selectProducedFiles(owner)` 也是按**整轮**取。
|
|
389
|
+
*
|
|
390
|
+
* 攒完在循环之后回填:同一轮里先发的消息也要能看到这一轮后几步才写出来的文件。
|
|
391
|
+
*/
|
|
392
|
+
const producedByTurn = new Map();
|
|
393
|
+
const producedItems = [];
|
|
394
|
+
|
|
344
395
|
// 单步计时预扫描:为每条 assistant 消息派生「本轮」的 ttft / decode / 输出 token
|
|
345
396
|
// (对齐桌面 turn-metrics/assistant-timing:ttftMs = 首个非空 delta − step/start;
|
|
346
397
|
// decodeMs = 落定 − 首个 delta;outputTokens = assistant/message.usage.outputTokens)。
|
|
@@ -458,17 +509,27 @@ function deriveMessages(events) {
|
|
|
458
509
|
seq: e.seq,
|
|
459
510
|
time: e.time,
|
|
460
511
|
};
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
512
|
+
// 这一轮的轮号(老日志没有):产出清单与交付声明都按它归并。
|
|
513
|
+
const turn = e.data && typeof e.data.turn === 'number' ? e.data.turn : undefined;
|
|
514
|
+
// 本条消息自己写 / 改过的文件:并进**这一轮**的那一份,不单独挂在它自己头上。
|
|
515
|
+
const ownWrites = blocks
|
|
516
|
+
.filter((block) => block.type === 'tool-call' && (block.name === 'write' || block.name === 'edit') && block.state === 'ok')
|
|
517
|
+
.map((block) => {
|
|
518
|
+
try {
|
|
519
|
+
const args = JSON.parse(block.argsRaw || '{}');
|
|
520
|
+
return typeof args.path === 'string' ? args.path : typeof args.file_path === 'string' ? args.file_path : null;
|
|
521
|
+
} catch { return null; }
|
|
522
|
+
})
|
|
523
|
+
.filter((path) => typeof path === 'string');
|
|
524
|
+
if (turn !== undefined && ownWrites.length > 0) {
|
|
525
|
+
const bucket = producedByTurn.get(turn) || new Set();
|
|
526
|
+
for (const path of ownWrites) bucket.add(path);
|
|
527
|
+
producedByTurn.set(turn, bucket);
|
|
528
|
+
}
|
|
529
|
+
// 回填要等循环跑完(这一轮后面几步写的也要算进来),先把这条记下来。
|
|
530
|
+
if (turn !== undefined) producedItems.push({ item, turn });
|
|
531
|
+
// 这一轮模型显式声明的交付文件:轮尾那张交付卡的唯一依据(与产出的清单分开)。
|
|
532
|
+
if (turn !== undefined) {
|
|
472
533
|
const presented = presentedByTurn.get(turn);
|
|
473
534
|
if (presented && presented.length > 0) item.presentedFiles = presented.map((file) => ({ ...file }));
|
|
474
535
|
}
|
|
@@ -724,11 +785,146 @@ function deriveMessages(events) {
|
|
|
724
785
|
}
|
|
725
786
|
// tool/result 等其余类型照旧忽略(工具结果在预扫描里配对回填;compaction 行由 start/end 发出)。
|
|
726
787
|
}
|
|
788
|
+
// 产出清单按轮回填:这一轮所有 write / edit 的并集,挂到这一轮每条 assistant 消息上。
|
|
789
|
+
// 挂「整轮」而不是「从写那一刻起」,与桌面 `selectProducedFiles(owner)` 同口径;正文里
|
|
790
|
+
// 提到这一轮写的文件就能解析成可点(App 侧按文件名或整条路径唯一命中才画,见
|
|
791
|
+
// `viewmodels/file-mentions`)。
|
|
792
|
+
if (producedItems.length > 0) {
|
|
793
|
+
for (const { item, turn } of producedItems) {
|
|
794
|
+
const bucket = producedByTurn.get(turn);
|
|
795
|
+
if (bucket && bucket.size > 0) item.producedFiles = [...bucket];
|
|
796
|
+
}
|
|
797
|
+
}
|
|
727
798
|
// 严格按事件 seq 升序(与消息混排;compaction 行升级/替换后位置以最终事件为准)。
|
|
728
799
|
out.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
|
|
729
800
|
return out;
|
|
730
801
|
}
|
|
731
802
|
|
|
803
|
+
/**
|
|
804
|
+
* 入口窗口的「内容化」裁剪(`GET /session/messages?thin=1`,只对不带 beforeSeq / afterSeq 的
|
|
805
|
+
* 一次性入口窗口生效;往上翻页与增量补拉保持完整)。
|
|
806
|
+
*
|
|
807
|
+
* 为什么:入口原来按「消息条数」截尾巴,而宿主每跑一个 agent 步就落一条 assistant 消息
|
|
808
|
+
* (推理块 + 工具调用块)。一次长回合几十上百步,最后 40 条可能全是这些过程消息 ——
|
|
809
|
+
* 用户自己那句话被挤出窗口,进对话只剩半轮,看起来像「我说的话凭空消失」。
|
|
810
|
+
*
|
|
811
|
+
* 现在入口按「轮」分组、只留用户看得见的内容:
|
|
812
|
+
* - 用户消息 / 定时任务唤醒行(原样);
|
|
813
|
+
* - 助手正文(中途汇报与最终答复都算)与带图的消息;
|
|
814
|
+
* - 结构化行(压缩、重试、本轮失败等,体量小,且带着这一轮的状态)。
|
|
815
|
+
* 过程明细(推理块、工具调用块、只有过程没有文字的消息)不下发,开启某一轮时再逐轮补取。
|
|
816
|
+
* 每轮被裁掉多少项、范围到哪,挂在**这一轮开头那条用户消息**的 `turnProcess` 上:App 用它
|
|
817
|
+
* 渲染「还有 N 项处理过程」,点开时按 startSeq / endSeq 回头要那一轮的事件。
|
|
818
|
+
*
|
|
819
|
+
* 窗口最老那一组没有起点(宿主按 maxMessages 截断时正好切在一轮中间)时整组丢掉:
|
|
820
|
+
* 半轮的消息没有用户消息打头,画出来就是「凭空冒出的 AI 发言」;它会被「加载更早」整轮补回。
|
|
821
|
+
*
|
|
822
|
+
* 三种「出卡」工具的调用块要留一条(定时任务 / 计划 / 扩展卡):轮尾那几张卡是从工具块
|
|
823
|
+
* 现算的(`services/viewmodels/turn-tail-cards` 的 scheduleTailCard / planTailCard /
|
|
824
|
+
* extensionTailCard),整块丢掉会让卡在收口时凭空不见。它们体量很小。
|
|
825
|
+
* **新增一种轮尾卡时要同步这里**,否则那张卡只在「点开过程之后」才出现。
|
|
826
|
+
*/
|
|
827
|
+
const ENTRY_OUTCOME_TOOLS = new Set(['schedule_create', 'exit_plan_mode', 'extension_present']);
|
|
828
|
+
|
|
829
|
+
/** 一轮的起点:用户自己发的消息,或定时任务唤醒的那一行。 */
|
|
830
|
+
function isEntryTurnOpener(item) {
|
|
831
|
+
return Boolean(item) && typeof item === 'object'
|
|
832
|
+
&& (item.role === 'user' || item.kind === 'wake');
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function thinEntryMessages(items, options = {}) {
|
|
836
|
+
if (!Array.isArray(items) || items.length === 0) return items;
|
|
837
|
+
const hasMore = options.hasMore === true;
|
|
838
|
+
const groups = [];
|
|
839
|
+
let group = null;
|
|
840
|
+
for (const item of items) {
|
|
841
|
+
if (!item || typeof item !== 'object') continue;
|
|
842
|
+
if (isEntryTurnOpener(item) || group === null) {
|
|
843
|
+
group = { opener: isEntryTurnOpener(item) ? item : null, items: [] };
|
|
844
|
+
groups.push(group);
|
|
845
|
+
}
|
|
846
|
+
group.items.push(item);
|
|
847
|
+
}
|
|
848
|
+
const out = [];
|
|
849
|
+
for (let index = 0; index < groups.length; index += 1) {
|
|
850
|
+
const { opener, items: groupItems } = groups[index];
|
|
851
|
+
// 最老那半轮:没有用户消息当起点,整组丢掉(交给「加载更早」)。整页只有这一组时不丢 ——
|
|
852
|
+
// 那是「单独一轮就超过入口上限」的极端形状,丢了等于入口全空,宁可先摊开这一轮的文字。
|
|
853
|
+
if (opener === null && hasMore && index === 0 && groups.length > 1) continue;
|
|
854
|
+
let processCount = 0;
|
|
855
|
+
let hasKeptAssistant = false;
|
|
856
|
+
const kept = [];
|
|
857
|
+
for (const item of groupItems) {
|
|
858
|
+
// 结构化行与唤醒行原样保留:体量小,而且「已停止 / 处理失败 / 已压缩」是这一轮的状态。
|
|
859
|
+
if (isEntryTurnOpener(item) || item.kind !== undefined) {
|
|
860
|
+
kept.push(item);
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
if (item.role === 'user') { kept.push(item); continue; }
|
|
864
|
+
if (item.role !== 'assistant') { kept.push(item); continue; }
|
|
865
|
+
const blocks = Array.isArray(item.blocks) ? item.blocks : [];
|
|
866
|
+
const keptBlocks = [];
|
|
867
|
+
let stripped = 0;
|
|
868
|
+
for (const block of blocks) {
|
|
869
|
+
if (!block || typeof block !== 'object') continue;
|
|
870
|
+
// **每一个过程块都记进 processCount**,包括因为要出轮尾卡而留下的那几个 ——
|
|
871
|
+
// 这个数是给 App 的「这一轮一共多少项过程」用的,它按自己派生出的过程项递减;
|
|
872
|
+
// 只数「被摘掉的」会让保留的那几块扣不掉,App 那边提前减到 0、加载入口消失,
|
|
873
|
+
// 真正没载入的过程反倒没处点了。
|
|
874
|
+
// 空推理块在 deriveMessages 里就被丢掉了(`parseBlocks` 只留有文字的),这里也
|
|
875
|
+
// 不能数 —— 数了 App 就少扣一项,那一轮的加载入口永远差着数。
|
|
876
|
+
if (block.type === 'reasoning') {
|
|
877
|
+
if (typeof block.text === 'string' && block.text) { processCount += 1; stripped += 1; }
|
|
878
|
+
continue;
|
|
879
|
+
}
|
|
880
|
+
if (block.type === 'tool-call') {
|
|
881
|
+
processCount += 1;
|
|
882
|
+
if (typeof block.name === 'string' && ENTRY_OUTCOME_TOOLS.has(block.name)) keptBlocks.push(block);
|
|
883
|
+
else stripped += 1;
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
keptBlocks.push(block);
|
|
887
|
+
}
|
|
888
|
+
const hasText = typeof item.text === 'string' && item.text !== '';
|
|
889
|
+
const hasContent = hasText
|
|
890
|
+
|| keptBlocks.some((block) => block.type === 'image' || block.type === 'tool-call')
|
|
891
|
+
|| (Array.isArray(item.images) && item.images.length > 0)
|
|
892
|
+
|| (Array.isArray(item.unavailableImages) && item.unavailableImages.length > 0);
|
|
893
|
+
if (!hasContent) continue;
|
|
894
|
+
kept.push(stripped > 0 ? { ...item, blocks: keptBlocks } : item);
|
|
895
|
+
hasKeptAssistant = true;
|
|
896
|
+
}
|
|
897
|
+
// 一轮里一条助手消息都没留下(整轮只在跑工具、被停止):交付声明照样得留个落脚处。
|
|
898
|
+
if (opener !== null && !hasKeptAssistant) {
|
|
899
|
+
const presented = groupItems
|
|
900
|
+
.filter((item) => Array.isArray(item.presentedFiles) && item.presentedFiles.length > 0)
|
|
901
|
+
.flatMap((item) => item.presentedFiles);
|
|
902
|
+
if (presented.length > 0) {
|
|
903
|
+
const at = kept.indexOf(opener);
|
|
904
|
+
if (at >= 0) kept[at] = { ...opener, presentedFiles: presented };
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
if (opener !== null && processCount > 0 && Number.isSafeInteger(opener.seq)) {
|
|
908
|
+
const last = groupItems[groupItems.length - 1];
|
|
909
|
+
const endSeq = Number.isSafeInteger(last.seq) ? last.seq : opener.seq;
|
|
910
|
+
const at = kept.indexOf(opener);
|
|
911
|
+
if (at >= 0) {
|
|
912
|
+
kept[at] = {
|
|
913
|
+
...kept[at],
|
|
914
|
+
turnProcess: {
|
|
915
|
+
startSeq: opener.seq,
|
|
916
|
+
endSeq,
|
|
917
|
+
count: processCount,
|
|
918
|
+
...(typeof last.time === 'number' ? { endTime: last.time } : {}),
|
|
919
|
+
},
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
for (const item of kept) out.push(item);
|
|
924
|
+
}
|
|
925
|
+
return out;
|
|
926
|
+
}
|
|
927
|
+
|
|
732
928
|
// 信封拆解的唯一实现在 lib/mobile/envelope.js。这里只是为了让既有
|
|
733
929
|
// `require('./history-derive.js').unwrap` 继续可用(对外导出面不变)。
|
|
734
930
|
const { unwrap } = require('./envelope.js');
|
|
@@ -798,6 +994,7 @@ module.exports = {
|
|
|
798
994
|
listTitle,
|
|
799
995
|
presentedPathsOf,
|
|
800
996
|
deriveMessages,
|
|
997
|
+
thinEntryMessages,
|
|
801
998
|
unwrap,
|
|
802
999
|
errorDetails,
|
|
803
1000
|
promptErrorObject,
|
|
@@ -805,6 +1002,7 @@ module.exports = {
|
|
|
805
1002
|
capDiffText,
|
|
806
1003
|
normalizeResultDiffs,
|
|
807
1004
|
buildToolResultMap,
|
|
1005
|
+
toolResultPatchesFor,
|
|
808
1006
|
parseBlocks,
|
|
809
1007
|
blocksToText,
|
|
810
1008
|
lastMessageOf,
|
|
@@ -10,7 +10,35 @@
|
|
|
10
10
|
* 按 frame.payload 里的 type 分发,形状对上了它们一行都不用改。
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
const { openStream, warn } = require('./transport.js');
|
|
13
|
+
const { gatewayOf, openStream, warn } = require('./transport.js');
|
|
14
|
+
|
|
15
|
+
const warnedUnavailableContexts = new WeakSet();
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Keep the compatibility SSE alive when a host has no streaming gateway. The
|
|
19
|
+
* device stream also carries plugin-owned signals and heartbeats, so ending it
|
|
20
|
+
* here would cause reconnect churn and disable that independent data path.
|
|
21
|
+
*/
|
|
22
|
+
async function* unavailableHostStream(ctx, signal) {
|
|
23
|
+
const gateway = gatewayOf(ctx);
|
|
24
|
+
if (gateway && typeof gateway.stream === 'function') return false;
|
|
25
|
+
|
|
26
|
+
if (ctx && typeof ctx === 'object' && !warnedUnavailableContexts.has(ctx)) {
|
|
27
|
+
warnedUnavailableContexts.add(ctx);
|
|
28
|
+
warn(ctx, '宿主未提供 typertGateway.stream,跳过宿主事件并保持设备流空闲');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!signal || signal.aborted) return true;
|
|
32
|
+
await new Promise((resolve) => {
|
|
33
|
+
const finish = () => {
|
|
34
|
+
signal.removeEventListener('abort', finish);
|
|
35
|
+
resolve();
|
|
36
|
+
};
|
|
37
|
+
signal.addEventListener('abort', finish, { once: true });
|
|
38
|
+
if (signal.aborted) finish();
|
|
39
|
+
});
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
14
42
|
|
|
15
43
|
// ── 事件合成 ──────────────────────────────────────────────────────────────
|
|
16
44
|
|
|
@@ -276,19 +304,31 @@ function projectionState() {
|
|
|
276
304
|
* `Array.isArray(items)` 判定失败、整帧被静默丢弃 —— 排队条一次都不显示。
|
|
277
305
|
*
|
|
278
306
|
* `next-turn`(等下一轮才开始的消息)→ `placement: 'queued'`,QueueDock 只认这一个值。
|
|
279
|
-
* `next-step
|
|
280
|
-
*
|
|
281
|
-
*
|
|
307
|
+
* `next-step`(已收下、等本步末尾注入的插话)→ `placement: 'steering'`:排队条与正文列表都
|
|
308
|
+
* 只认 `queued`,所以它不会多画一行;它的用处是「宿主已经收下了」这条回执 —— 插话从发出去
|
|
309
|
+
* 到真被插进去中间可能隔着好几个工具往返,此前手机上只挂着一颗「发送中…」。页脚那排插话
|
|
310
|
+
* 气泡会把本机自己发的那几条排掉(`steeringForFooter`),不会与待发气泡双画。
|
|
311
|
+
* `message.id` 用宿主行自己的 id,App 靠它与正文去重(chatRenderRows)。
|
|
282
312
|
*/
|
|
283
313
|
function queueItems(inbox, ctx) {
|
|
284
|
-
const rows = inbox && Array.isArray(inbox['next-turn']) ? inbox['next-turn'] : [];
|
|
285
314
|
const items = [];
|
|
286
315
|
let dropped = 0;
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
316
|
+
/**
|
|
317
|
+
* 摊平一组待发行。`placement` 只标语义:`queued` 进排队条,`steering` 只是回执。
|
|
318
|
+
* 没有 id 的行丢弃并计数(理由见下面的注释)。
|
|
319
|
+
*/
|
|
320
|
+
const absorb = (rows, placement) => {
|
|
321
|
+
if (!Array.isArray(rows)) return;
|
|
322
|
+
for (const message of rows) {
|
|
323
|
+
if (!message || typeof message !== 'object') continue;
|
|
324
|
+
const id = typeof message.id === 'string' && message.id ? message.id : '';
|
|
325
|
+
if (!id) { dropped += 1; continue; } // 没有 id 的行没法被 session.updateQueue 定位,别发出去
|
|
326
|
+
items.push({ id, placement, message: { id, content: message.content } });
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
if (inbox && typeof inbox === 'object') {
|
|
330
|
+
absorb(inbox['next-turn'], 'queued');
|
|
331
|
+
absorb(inbox['next-step'], 'steering');
|
|
292
332
|
}
|
|
293
333
|
// 丢弃必须留痕:旧实现是裸 continue,手机上少一条待发消息而宿主侧一行日志都没有,
|
|
294
334
|
// 只能靠人对着桌面端数气泡。凭空造 id 更糟——App 的 session.updateQueue 按 id 定位,
|
|
@@ -339,8 +379,19 @@ function muxFrames(ctx, request, signal) {
|
|
|
339
379
|
const sessionId = sessionHint(request);
|
|
340
380
|
const payload = request && request.payload;
|
|
341
381
|
return (async function* run() {
|
|
382
|
+
if (yield* unavailableHostStream(ctx, signal)) return;
|
|
342
383
|
if (!sessionId) {
|
|
343
384
|
yield* deviceFrames(ctx, signal);
|
|
385
|
+
// **设备级这一支不能跟着宿主那几条流一起结束**:这条 SSE 还捎带着插件自己产的
|
|
386
|
+
// 清单变更、正文水位与 15s 心跳(由 `handleDeviceStream` 直接写这条连接)。
|
|
387
|
+
// 迭代器一返回,SSE 立刻收尾,手机端只会看到「连几秒就断」:信号永远不「活着」,
|
|
388
|
+
// 兜底轮询降不下去,每次重连还要重放一遍水位(2026-10-24 现场:17 帧后 loopEnded)。
|
|
389
|
+
// 单会话那一支不适用 —— 会话结束了就该收尾。
|
|
390
|
+
if (!signal || signal.aborted) return;
|
|
391
|
+
await new Promise((resolve) => {
|
|
392
|
+
signal.addEventListener('abort', resolve, { once: true });
|
|
393
|
+
if (signal.aborted) resolve();
|
|
394
|
+
});
|
|
344
395
|
return;
|
|
345
396
|
}
|
|
346
397
|
const address = sessionAddress(payload);
|