dsh-mobile-plugin 0.1.99 → 0.2.0
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 +79 -5
- package/lib/index.js +1 -1
- 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 +129 -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 +159 -0
- package/lib/mobile/host-bridge/event-synthesis.js +40 -1
- 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 +534 -0
- package/lib/mobile/session-cache.js +19 -1
- package/lib/mobile/session-file.js +78 -4
- 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 +21 -3
- package/lib/mobile/session-snapshot.js +12 -11
- 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 +103 -18
- package/lib/mobile/uploads.js +79 -3
- package/lib/mobile/workspaces.js +39 -8
- package/lib/panel/index.html +11 -11
- package/lib/relay/core.js +25 -2
- 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 +135 -9
- package/lib/routes/mobile-api.js +87 -7
- 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 +36 -2
- package/lib/sidecar-facade-worker.cjs +70 -6
- 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,38 @@ 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
|
+
function toolResultPatchesFor(events, items) {
|
|
175
|
+
const delivered = new Set();
|
|
176
|
+
for (const item of items || []) {
|
|
177
|
+
if (!item || !Array.isArray(item.blocks)) continue;
|
|
178
|
+
for (const block of item.blocks) {
|
|
179
|
+
if (block && block.type === 'tool-call' && typeof block.callId === 'string' && block.callId) {
|
|
180
|
+
delivered.add(block.callId);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return [...buildToolResultMap(events).entries()].map(([callId, result]) => {
|
|
185
|
+
const state = result.isError ? (result.code === 'interrupted' ? 'stopped' : 'error') : 'ok';
|
|
186
|
+
if (delivered.has(callId)) return { callId, state };
|
|
187
|
+
return {
|
|
188
|
+
callId,
|
|
189
|
+
state,
|
|
190
|
+
...(result.output ? { output: result.output } : {}),
|
|
191
|
+
...(result.images && result.images.length ? { images: result.images } : {}),
|
|
192
|
+
...(result.diffs && result.diffs.length ? { diffs: result.diffs } : {}),
|
|
193
|
+
};
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
165
197
|
/** 把 assistant message.content 解析为一个结构块数组(text/reasoning/tool-call/image)。 */
|
|
166
198
|
function parseBlocks(blocks, toolResults) {
|
|
167
199
|
if (!Array.isArray(blocks)) return [];
|
|
@@ -729,6 +761,131 @@ function deriveMessages(events) {
|
|
|
729
761
|
return out;
|
|
730
762
|
}
|
|
731
763
|
|
|
764
|
+
/**
|
|
765
|
+
* 入口窗口的「内容化」裁剪(`GET /session/messages?thin=1`,只对不带 beforeSeq / afterSeq 的
|
|
766
|
+
* 一次性入口窗口生效;往上翻页与增量补拉保持完整)。
|
|
767
|
+
*
|
|
768
|
+
* 为什么:入口原来按「消息条数」截尾巴,而宿主每跑一个 agent 步就落一条 assistant 消息
|
|
769
|
+
* (推理块 + 工具调用块)。一次长回合几十上百步,最后 40 条可能全是这些过程消息 ——
|
|
770
|
+
* 用户自己那句话被挤出窗口,进对话只剩半轮,看起来像「我说的话凭空消失」。
|
|
771
|
+
*
|
|
772
|
+
* 现在入口按「轮」分组、只留用户看得见的内容:
|
|
773
|
+
* - 用户消息 / 定时任务唤醒行(原样);
|
|
774
|
+
* - 助手正文(中途汇报与最终答复都算)与带图的消息;
|
|
775
|
+
* - 结构化行(压缩、重试、本轮失败等,体量小,且带着这一轮的状态)。
|
|
776
|
+
* 过程明细(推理块、工具调用块、只有过程没有文字的消息)不下发,开启某一轮时再逐轮补取。
|
|
777
|
+
* 每轮被裁掉多少项、范围到哪,挂在**这一轮开头那条用户消息**的 `turnProcess` 上:App 用它
|
|
778
|
+
* 渲染「还有 N 项处理过程」,点开时按 startSeq / endSeq 回头要那一轮的事件。
|
|
779
|
+
*
|
|
780
|
+
* 窗口最老那一组没有起点(宿主按 maxMessages 截断时正好切在一轮中间)时整组丢掉:
|
|
781
|
+
* 半轮的消息没有用户消息打头,画出来就是「凭空冒出的 AI 发言」;它会被「加载更早」整轮补回。
|
|
782
|
+
*
|
|
783
|
+
* 三种「出卡」工具的调用块要留一条(定时任务 / 计划 / 扩展卡):轮尾那几张卡是从工具块
|
|
784
|
+
* 现算的(`services/viewmodels/turn-tail-cards` 的 scheduleTailCard / planTailCard /
|
|
785
|
+
* extensionTailCard),整块丢掉会让卡在收口时凭空不见。它们体量很小。
|
|
786
|
+
* **新增一种轮尾卡时要同步这里**,否则那张卡只在「点开过程之后」才出现。
|
|
787
|
+
*/
|
|
788
|
+
const ENTRY_OUTCOME_TOOLS = new Set(['schedule_create', 'exit_plan_mode', 'extension_present']);
|
|
789
|
+
|
|
790
|
+
/** 一轮的起点:用户自己发的消息,或定时任务唤醒的那一行。 */
|
|
791
|
+
function isEntryTurnOpener(item) {
|
|
792
|
+
return Boolean(item) && typeof item === 'object'
|
|
793
|
+
&& (item.role === 'user' || item.kind === 'wake');
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function thinEntryMessages(items, options = {}) {
|
|
797
|
+
if (!Array.isArray(items) || items.length === 0) return items;
|
|
798
|
+
const hasMore = options.hasMore === true;
|
|
799
|
+
const groups = [];
|
|
800
|
+
let group = null;
|
|
801
|
+
for (const item of items) {
|
|
802
|
+
if (!item || typeof item !== 'object') continue;
|
|
803
|
+
if (isEntryTurnOpener(item) || group === null) {
|
|
804
|
+
group = { opener: isEntryTurnOpener(item) ? item : null, items: [] };
|
|
805
|
+
groups.push(group);
|
|
806
|
+
}
|
|
807
|
+
group.items.push(item);
|
|
808
|
+
}
|
|
809
|
+
const out = [];
|
|
810
|
+
for (let index = 0; index < groups.length; index += 1) {
|
|
811
|
+
const { opener, items: groupItems } = groups[index];
|
|
812
|
+
// 最老那半轮:没有用户消息当起点,整组丢掉(交给「加载更早」)。整页只有这一组时不丢 ——
|
|
813
|
+
// 那是「单独一轮就超过入口上限」的极端形状,丢了等于入口全空,宁可先摊开这一轮的文字。
|
|
814
|
+
if (opener === null && hasMore && index === 0 && groups.length > 1) continue;
|
|
815
|
+
let processCount = 0;
|
|
816
|
+
let hasKeptAssistant = false;
|
|
817
|
+
const kept = [];
|
|
818
|
+
for (const item of groupItems) {
|
|
819
|
+
// 结构化行与唤醒行原样保留:体量小,而且「已停止 / 处理失败 / 已压缩」是这一轮的状态。
|
|
820
|
+
if (isEntryTurnOpener(item) || item.kind !== undefined) {
|
|
821
|
+
kept.push(item);
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
if (item.role === 'user') { kept.push(item); continue; }
|
|
825
|
+
if (item.role !== 'assistant') { kept.push(item); continue; }
|
|
826
|
+
const blocks = Array.isArray(item.blocks) ? item.blocks : [];
|
|
827
|
+
const keptBlocks = [];
|
|
828
|
+
let stripped = 0;
|
|
829
|
+
for (const block of blocks) {
|
|
830
|
+
if (!block || typeof block !== 'object') continue;
|
|
831
|
+
// **每一个过程块都记进 processCount**,包括因为要出轮尾卡而留下的那几个 ——
|
|
832
|
+
// 这个数是给 App 的「这一轮一共多少项过程」用的,它按自己派生出的过程项递减;
|
|
833
|
+
// 只数「被摘掉的」会让保留的那几块扣不掉,App 那边提前减到 0、加载入口消失,
|
|
834
|
+
// 真正没载入的过程反倒没处点了。
|
|
835
|
+
// 空推理块在 deriveMessages 里就被丢掉了(`parseBlocks` 只留有文字的),这里也
|
|
836
|
+
// 不能数 —— 数了 App 就少扣一项,那一轮的加载入口永远差着数。
|
|
837
|
+
if (block.type === 'reasoning') {
|
|
838
|
+
if (typeof block.text === 'string' && block.text) { processCount += 1; stripped += 1; }
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
if (block.type === 'tool-call') {
|
|
842
|
+
processCount += 1;
|
|
843
|
+
if (typeof block.name === 'string' && ENTRY_OUTCOME_TOOLS.has(block.name)) keptBlocks.push(block);
|
|
844
|
+
else stripped += 1;
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
keptBlocks.push(block);
|
|
848
|
+
}
|
|
849
|
+
const hasText = typeof item.text === 'string' && item.text !== '';
|
|
850
|
+
const hasContent = hasText
|
|
851
|
+
|| keptBlocks.some((block) => block.type === 'image' || block.type === 'tool-call')
|
|
852
|
+
|| (Array.isArray(item.images) && item.images.length > 0)
|
|
853
|
+
|| (Array.isArray(item.unavailableImages) && item.unavailableImages.length > 0);
|
|
854
|
+
if (!hasContent) continue;
|
|
855
|
+
kept.push(stripped > 0 ? { ...item, blocks: keptBlocks } : item);
|
|
856
|
+
hasKeptAssistant = true;
|
|
857
|
+
}
|
|
858
|
+
// 一轮里一条助手消息都没留下(整轮只在跑工具、被停止):交付声明照样得留个落脚处。
|
|
859
|
+
if (opener !== null && !hasKeptAssistant) {
|
|
860
|
+
const presented = groupItems
|
|
861
|
+
.filter((item) => Array.isArray(item.presentedFiles) && item.presentedFiles.length > 0)
|
|
862
|
+
.flatMap((item) => item.presentedFiles);
|
|
863
|
+
if (presented.length > 0) {
|
|
864
|
+
const at = kept.indexOf(opener);
|
|
865
|
+
if (at >= 0) kept[at] = { ...opener, presentedFiles: presented };
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
if (opener !== null && processCount > 0 && Number.isSafeInteger(opener.seq)) {
|
|
869
|
+
const last = groupItems[groupItems.length - 1];
|
|
870
|
+
const endSeq = Number.isSafeInteger(last.seq) ? last.seq : opener.seq;
|
|
871
|
+
const at = kept.indexOf(opener);
|
|
872
|
+
if (at >= 0) {
|
|
873
|
+
kept[at] = {
|
|
874
|
+
...kept[at],
|
|
875
|
+
turnProcess: {
|
|
876
|
+
startSeq: opener.seq,
|
|
877
|
+
endSeq,
|
|
878
|
+
count: processCount,
|
|
879
|
+
...(typeof last.time === 'number' ? { endTime: last.time } : {}),
|
|
880
|
+
},
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
for (const item of kept) out.push(item);
|
|
885
|
+
}
|
|
886
|
+
return out;
|
|
887
|
+
}
|
|
888
|
+
|
|
732
889
|
// 信封拆解的唯一实现在 lib/mobile/envelope.js。这里只是为了让既有
|
|
733
890
|
// `require('./history-derive.js').unwrap` 继续可用(对外导出面不变)。
|
|
734
891
|
const { unwrap } = require('./envelope.js');
|
|
@@ -798,6 +955,7 @@ module.exports = {
|
|
|
798
955
|
listTitle,
|
|
799
956
|
presentedPathsOf,
|
|
800
957
|
deriveMessages,
|
|
958
|
+
thinEntryMessages,
|
|
801
959
|
unwrap,
|
|
802
960
|
errorDetails,
|
|
803
961
|
promptErrorObject,
|
|
@@ -805,6 +963,7 @@ module.exports = {
|
|
|
805
963
|
capDiffText,
|
|
806
964
|
normalizeResultDiffs,
|
|
807
965
|
buildToolResultMap,
|
|
966
|
+
toolResultPatchesFor,
|
|
808
967
|
parseBlocks,
|
|
809
968
|
blocksToText,
|
|
810
969
|
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
|
|
|
@@ -339,8 +367,19 @@ function muxFrames(ctx, request, signal) {
|
|
|
339
367
|
const sessionId = sessionHint(request);
|
|
340
368
|
const payload = request && request.payload;
|
|
341
369
|
return (async function* run() {
|
|
370
|
+
if (yield* unavailableHostStream(ctx, signal)) return;
|
|
342
371
|
if (!sessionId) {
|
|
343
372
|
yield* deviceFrames(ctx, signal);
|
|
373
|
+
// **设备级这一支不能跟着宿主那几条流一起结束**:这条 SSE 还捎带着插件自己产的
|
|
374
|
+
// 清单变更、正文水位与 15s 心跳(由 `handleDeviceStream` 直接写这条连接)。
|
|
375
|
+
// 迭代器一返回,SSE 立刻收尾,手机端只会看到「连几秒就断」:信号永远不「活着」,
|
|
376
|
+
// 兜底轮询降不下去,每次重连还要重放一遍水位(2026-10-24 现场:17 帧后 loopEnded)。
|
|
377
|
+
// 单会话那一支不适用 —— 会话结束了就该收尾。
|
|
378
|
+
if (!signal || signal.aborted) return;
|
|
379
|
+
await new Promise((resolve) => {
|
|
380
|
+
signal.addEventListener('abort', resolve, { once: true });
|
|
381
|
+
if (signal.aborted) resolve();
|
|
382
|
+
});
|
|
344
383
|
return;
|
|
345
384
|
}
|
|
346
385
|
const address = sessionAddress(payload);
|