dsh-remote-control 1.0.0 → 1.1.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.
Files changed (100) hide show
  1. package/cordis.patch.yml +6 -0
  2. package/dist/bundle/client.cjs +146 -0
  3. package/dist/bundle/index.js +667 -264
  4. package/package.json +13 -6
  5. package/dist/src/core/clock.d.ts +0 -45
  6. package/dist/src/core/clock.js +0 -83
  7. package/dist/src/core/clock.js.map +0 -1
  8. package/dist/src/core/keys.d.ts +0 -98
  9. package/dist/src/core/keys.js +0 -176
  10. package/dist/src/core/keys.js.map +0 -1
  11. package/dist/src/core/pairing-window.d.ts +0 -95
  12. package/dist/src/core/pairing-window.js +0 -92
  13. package/dist/src/core/pairing-window.js.map +0 -1
  14. package/dist/src/core/runtime.d.ts +0 -104
  15. package/dist/src/core/runtime.js +0 -491
  16. package/dist/src/core/runtime.js.map +0 -1
  17. package/dist/src/core/sleep-policy.d.ts +0 -81
  18. package/dist/src/core/sleep-policy.js +0 -134
  19. package/dist/src/core/sleep-policy.js.map +0 -1
  20. package/dist/src/core/window.d.ts +0 -88
  21. package/dist/src/core/window.js +0 -185
  22. package/dist/src/core/window.js.map +0 -1
  23. package/dist/src/index.d.ts +0 -70
  24. package/dist/src/index.js +0 -882
  25. package/dist/src/index.js.map +0 -1
  26. package/dist/src/platform/carrier-services.d.ts +0 -96
  27. package/dist/src/platform/carrier-services.js +0 -982
  28. package/dist/src/platform/carrier-services.js.map +0 -1
  29. package/dist/src/platform/guard.d.ts +0 -60
  30. package/dist/src/platform/guard.js +0 -124
  31. package/dist/src/platform/guard.js.map +0 -1
  32. package/dist/src/platform/mock-kernel.d.ts +0 -95
  33. package/dist/src/platform/mock-kernel.js +0 -180
  34. package/dist/src/platform/mock-kernel.js.map +0 -1
  35. package/dist/src/platform/qr.d.ts +0 -28
  36. package/dist/src/platform/qr.js +0 -80
  37. package/dist/src/platform/qr.js.map +0 -1
  38. package/dist/src/platform/sleep-posix.d.ts +0 -17
  39. package/dist/src/platform/sleep-posix.js +0 -91
  40. package/dist/src/platform/sleep-posix.js.map +0 -1
  41. package/dist/src/ports/index.d.ts +0 -246
  42. package/dist/src/ports/index.js +0 -2
  43. package/dist/src/ports/index.js.map +0 -1
  44. package/dist/src/shell/config.d.ts +0 -56
  45. package/dist/src/shell/config.js +0 -212
  46. package/dist/src/shell/config.js.map +0 -1
  47. package/dist/src/shell/pairing-text.d.ts +0 -38
  48. package/dist/src/shell/pairing-text.js +0 -22
  49. package/dist/src/shell/pairing-text.js.map +0 -1
  50. package/dist/src/shell/status.d.ts +0 -39
  51. package/dist/src/shell/status.js +0 -119
  52. package/dist/src/shell/status.js.map +0 -1
  53. package/dist/src/transport/relay.d.ts +0 -107
  54. package/dist/src/transport/relay.js +0 -359
  55. package/dist/src/transport/relay.js.map +0 -1
  56. package/dist/tests/carrier-events.test.d.ts +0 -1
  57. package/dist/tests/carrier-events.test.js +0 -88
  58. package/dist/tests/carrier-events.test.js.map +0 -1
  59. package/dist/tests/carrier-services.test.d.ts +0 -1
  60. package/dist/tests/carrier-services.test.js +0 -562
  61. package/dist/tests/carrier-services.test.js.map +0 -1
  62. package/dist/tests/carrier-vocabulary.test.d.ts +0 -1
  63. package/dist/tests/carrier-vocabulary.test.js +0 -149
  64. package/dist/tests/carrier-vocabulary.test.js.map +0 -1
  65. package/dist/tests/clock-probe-timers.test.d.ts +0 -1
  66. package/dist/tests/clock-probe-timers.test.js +0 -73
  67. package/dist/tests/clock-probe-timers.test.js.map +0 -1
  68. package/dist/tests/command-result.test.d.ts +0 -1
  69. package/dist/tests/command-result.test.js +0 -185
  70. package/dist/tests/command-result.test.js.map +0 -1
  71. package/dist/tests/config.test.d.ts +0 -1
  72. package/dist/tests/config.test.js +0 -223
  73. package/dist/tests/config.test.js.map +0 -1
  74. package/dist/tests/guard.test.d.ts +0 -1
  75. package/dist/tests/guard.test.js +0 -175
  76. package/dist/tests/guard.test.js.map +0 -1
  77. package/dist/tests/ids-and-keys.test.d.ts +0 -1
  78. package/dist/tests/ids-and-keys.test.js +0 -211
  79. package/dist/tests/ids-and-keys.test.js.map +0 -1
  80. package/dist/tests/pairing-text.test.d.ts +0 -1
  81. package/dist/tests/pairing-text.test.js +0 -62
  82. package/dist/tests/pairing-text.test.js.map +0 -1
  83. package/dist/tests/pairing-window.test.d.ts +0 -1
  84. package/dist/tests/pairing-window.test.js +0 -165
  85. package/dist/tests/pairing-window.test.js.map +0 -1
  86. package/dist/tests/relay-client.test.d.ts +0 -1
  87. package/dist/tests/relay-client.test.js +0 -557
  88. package/dist/tests/relay-client.test.js.map +0 -1
  89. package/dist/tests/runtime.test.d.ts +0 -1
  90. package/dist/tests/runtime.test.js +0 -963
  91. package/dist/tests/runtime.test.js.map +0 -1
  92. package/dist/tests/sleep.test.d.ts +0 -1
  93. package/dist/tests/sleep.test.js +0 -264
  94. package/dist/tests/sleep.test.js.map +0 -1
  95. package/dist/tests/status.test.d.ts +0 -1
  96. package/dist/tests/status.test.js +0 -238
  97. package/dist/tests/status.test.js.map +0 -1
  98. package/dist/tests/window.test.d.ts +0 -1
  99. package/dist/tests/window.test.js +0 -210
  100. package/dist/tests/window.test.js.map +0 -1
@@ -1,982 +0,0 @@
1
- import { canParticipate, guardedSubscribe } from './guard.js';
2
- const MAX_TITLE_BATCH_DEFAULT = 25;
3
- const TITLE_TIMEOUT_DEFAULT = 4000;
4
- const HISTORY_TIMEOUT_DEFAULT = 15_000;
5
- /** 新建会话的超时。创建要分配 id、可能还要挂 workspace,比读标题慢,但也不该让手机干等。 */
6
- const CREATE_TIMEOUT_DEFAULT = 8000;
7
- const LIST_LIMIT_DEFAULT = 100;
8
- export function createServicesKernel(services, options) {
9
- const log = (message, fields = {}) => options.log?.(message, fields);
10
- const titleCache = new Map();
11
- /**
12
- * 本进程刚造出来、**持久化那面还没追上**的会话(见 `listSessions` 末尾的合并)。
13
- * 不合并的话,用户新建完回到列表会发现那条会话不在里面。
14
- */
15
- const freshSessions = new Map();
16
- /** 取证用:内核实际发过来的会话事件类型,以及我们**没有映射**的那些(进 status.json)。 */
17
- const seenEventTypes = new Set();
18
- const unmappedEventTypes = new Set();
19
- /** 事件回调里抛过的错(按类型计数)。没有这一条,"回合跑到一半没声音"就只能靠猜。 */
20
- const listenerErrors = new Map();
21
- /**
22
- * 人工交互两面的登记结果,进 status.json。
23
- * 真机上"审批卡为什么没弹"有三种原因(宿主策略根本没问 / 我们没登记上 /
24
- * 登记了但此刻没有已配对的手机),没有这两个字段就只能猜。
25
- */
26
- let approvalFace = 'not-attached';
27
- let questionsFace = 'pending';
28
- /** 软探测一个成员是不是函数。返回类型用 unknown 参数表,调用点各自窄化。 */
29
- const fn = (owner, name) => {
30
- if (!owner)
31
- return undefined;
32
- const value = owner[name];
33
- return typeof value === 'function' ? value : undefined;
34
- };
35
- /** 把"缺哪个成员 + 该对象实际有什么"说清楚:静默降级是排查的最大敌人。 */
36
- const shapeOf = (owner) => {
37
- if (!owner || typeof owner !== 'object')
38
- return String(owner);
39
- return Object.keys(owner)
40
- .slice(0, 24)
41
- .join(',');
42
- };
43
- function archivedIds() {
44
- const registry = services.workspaceRegistry;
45
- const ids = registry?.archivedSessionIds;
46
- return Array.isArray(ids) ? new Set(ids.map(String)) : new Set();
47
- }
48
- function liveAgent(sessionId) {
49
- const get = fn(services.agents, 'get');
50
- if (!get)
51
- return undefined;
52
- try {
53
- return get.call(services.agents, sessionId);
54
- }
55
- catch {
56
- return undefined;
57
- }
58
- }
59
- function agentStatus(sessionId) {
60
- const agent = liveAgent(sessionId);
61
- const status = agent?.status;
62
- return typeof status === 'string' ? status : 'idle';
63
- }
64
- /**
65
- * 一条会话的工作区目录(会话头里的 `cwd`)。二维码 PNG 按它落 `<workspace>/.dsh/`。
66
- *
67
- * **只查活会话**:`services.sessions.get(id)` 拿到的是内核的 `Session` 对象,
68
- * `header.cwd` 由内核自己校验成绝对路径(dsh-session:`cwd must be an absolute path`)。
69
- * 不走持久化语料那条路(`sessionQuery.listSessions` 要读整份语料、还顺带拉标题),
70
- * 因为用到它的两个时机——用户敲 `/drc pair`、右栏轮询——都发生在**用户正在那条会话里**。
71
- * 查不到就返回 undefined,调用方退回 `~/.dsh/` 并把那个路径如实写进卡片。
72
- */
73
- function sessionWorkspace(sessionId) {
74
- const get = fn(services.sessions, 'get');
75
- if (!get || sessionId === '')
76
- return undefined;
77
- try {
78
- const session = get.call(services.sessions, sessionId);
79
- // 两个读法都留着:真身是 `session.header.cwd`,而某些代际把会话折叠成扁平记录
80
- // (`{id, cwd}`,见 listSessions 里 `sessions.list` 那个分支的读法)。
81
- const cwd = session?.header?.cwd ?? session?.cwd;
82
- return typeof cwd === 'string' && cwd !== '' ? cwd : undefined;
83
- }
84
- catch {
85
- return undefined;
86
- }
87
- }
88
- function toSummary(record, archived) {
89
- const cached = titleCache.get(record.id);
90
- const running = agentStatus(record.id) === 'running';
91
- const state = archived.has(record.id) ? 'archived' : running ? 'running' : 'idle';
92
- const updatedAt = cached ? iso(record.headerTime) : undefined;
93
- return {
94
- id: record.id,
95
- state,
96
- running,
97
- ...(cached?.title ? { title: cached.title } : {}),
98
- ...(record.cwd ? { workspace: record.cwd } : {}),
99
- ...(updatedAt ? { updatedAt } : {}),
100
- ...(record.origin ? { origin: record.origin } : {}),
101
- };
102
- }
103
- function iso(value) {
104
- if (typeof value === 'number')
105
- return new Date(value).toISOString();
106
- if (typeof value === 'string' && !Number.isNaN(Date.parse(value)))
107
- return value;
108
- return undefined;
109
- }
110
- async function loadTitles(ids) {
111
- const read = fn(services.sessionQuery, 'readTitleSnapshots');
112
- if (!read || ids.length === 0)
113
- return;
114
- const batch = options.titleBatch ?? MAX_TITLE_BATCH_DEFAULT;
115
- for (let at = 0; at < ids.length; at += batch) {
116
- const slice = ids.slice(at, at + batch);
117
- try {
118
- const rows = (await withTimeout(Promise.resolve(read.call(services.sessionQuery, slice)), options.titleTimeoutMs ?? TITLE_TIMEOUT_DEFAULT));
119
- for (const row of rows ?? []) {
120
- const title = row?.value?.title?.title;
121
- if (row?.status === 'fulfilled' && typeof title === 'string' && title) {
122
- titleCache.set(String(row.sessionId), { title, at: options.clock.now() });
123
- }
124
- }
125
- }
126
- catch (error) {
127
- // 标题读失败不影响列表:宁可无标题,也不要一整屏空白。
128
- log('title lookup failed', { message: messageOf(error) });
129
- }
130
- }
131
- }
132
- async function listSessions(limit) {
133
- const cap = limit > 0 ? limit : LIST_LIMIT_DEFAULT;
134
- const archived = archivedIds();
135
- let records = [];
136
- let source = 'sessionQuery';
137
- const listPersistent = fn(services.sessionQuery, 'listSessions');
138
- if (listPersistent) {
139
- try {
140
- const rows = (await listPersistent.call(services.sessionQuery));
141
- records = (rows ?? [])
142
- .filter((row) => typeof row?.header?.id === 'string')
143
- .map((row) => ({
144
- id: String(row.header?.id),
145
- cwd: row.header?.cwd,
146
- createdAt: row.header?.createdAt,
147
- origin: row.header?.origin,
148
- headerTime: row.header?.createdAt,
149
- }));
150
- }
151
- catch (error) {
152
- log('sessionQuery.listSessions failed, falling back to sessions.list', { message: messageOf(error) });
153
- records = [];
154
- }
155
- }
156
- if (records.length === 0) {
157
- const listLive = fn(services.sessions, 'list');
158
- if (!listLive) {
159
- log('no session listing face', {
160
- sessionQuery: shapeOf(services.sessionQuery),
161
- sessions: shapeOf(services.sessions),
162
- });
163
- return [];
164
- }
165
- source = 'sessions';
166
- const live = (listLive.call(services.sessions) ?? []);
167
- records = (live ?? [])
168
- .filter((row) => typeof row?.id === 'string')
169
- .map((row) => ({
170
- id: String(row.id),
171
- cwd: row.cwd,
172
- createdAt: row.createdAt,
173
- origin: row.origin,
174
- headerTime: row.createdAt,
175
- }));
176
- }
177
- // 委派子会话整行过滤:手机上点进一条无法回答审批的子会话,比看不见更容易误导。
178
- records = records.filter((row) => row.origin !== 'subagent');
179
- // 刚建的会话要**补**进来:`sessionQuery.listSessions` 扫的是持久化日志,
180
- // 而一条还没有任何消息的新会话根本没有日志。不补的话,用户在手机上新建完
181
- // 回到列表会发现那条不在里面 —— 「新建了但列表没变」比一条报错更难懂。
182
- // 一旦持久化那面也能报出它,本地这条临时记录就退休,此后一切以它为准。
183
- for (const [id, row] of [...freshSessions]) {
184
- if (records.some((item) => item.id === id))
185
- freshSessions.delete(id);
186
- else
187
- records.push(row);
188
- }
189
- records.sort((a, b) => createdAtOf(b) - createdAtOf(a));
190
- const page = records.slice(0, cap);
191
- await loadTitles(page.map((row) => row.id).filter((id) => !titleCache.has(id)));
192
- return page.map((row) => ({
193
- summary: toSummary(row, archived),
194
- live: agentStatus(row.id) === 'running',
195
- listingSource: source,
196
- }));
197
- }
198
- function createdAtOf(row) {
199
- if (typeof row.createdAt === 'number')
200
- return row.createdAt;
201
- if (typeof row.createdAt === 'string') {
202
- const parsed = Date.parse(row.createdAt);
203
- return Number.isNaN(parsed) ? 0 : parsed;
204
- }
205
- return 0;
206
- }
207
- /**
208
- * 新建一条会话。
209
- *
210
- * 三层软探测(服务 → `commands` → `create`)而不是 `?.` 一把梭:**缺的是哪一层要能报出来**。
211
- * 真机上"新建没反应"的病根可能在任意一层,而报错信息里带上"这一层有什么成员",
212
- * 排查就不用再重启一次 Harness 去取证。
213
- */
214
- async function newSession() {
215
- const controller = services.sessionController;
216
- if (!controller)
217
- return { ok: false, message: '主机这一代没有 sessionController 服务' };
218
- const commands = controller.commands;
219
- const create = fn(commands, 'create');
220
- if (!create) {
221
- return { ok: false, message: `sessionController.commands 上没有 create()(成员:${shapeOf(commands)})` };
222
- }
223
- try {
224
- // 只传空对象:不指定 sessionId(交给内核分配)、不指定 cwd / workspaceId(用默认项目目录)。
225
- // 两者同时给会被内核当场拒(`gateway/bad-request`),所以这里一个都不给。
226
- const made = (await withTimeout(Promise.resolve(create.call(commands, {})), options.createTimeoutMs ?? CREATE_TIMEOUT_DEFAULT));
227
- const id = typeof made?.sessionId === 'string' ? made.sessionId : '';
228
- if (!id)
229
- return { ok: false, message: `新建会话没有返回 sessionId(拿到 ${shapeOf(made)})` };
230
- // 先记进 freshSessions:下一条列表推送就能带上它(见 listSessions 里的合并)。
231
- freshSessions.set(id, { id, createdAt: options.clock.now() });
232
- return { ok: true, sessionId: id };
233
- }
234
- catch (error) {
235
- return { ok: false, message: messageOf(error) };
236
- }
237
- }
238
- /** 用户消息形状:优先用平台工厂(它会补 id 与规范化),拿不到退回最小可用对象。 */
239
- async function buildUserMessage(text) {
240
- try {
241
- // 用变量而不是字面量:这个包不是本插件的依赖,由宿主在运行时提供,
242
- // 写死路径会让类型检查去找一个根本不存在的声明(旧实现同样用变量绕开)。
243
- const llmModule = '@deepseek-ai/dsh-llm';
244
- const mod = (await import(/* @vite-ignore */ llmModule));
245
- if (typeof mod.createUserMessage === 'function') {
246
- return mod.createUserMessage({
247
- content: [{ type: 'text', text }],
248
- source: { kind: 'user' },
249
- });
250
- }
251
- }
252
- catch {
253
- /* 这一代没有该包或名字不同:走兜底形状 */
254
- }
255
- return {
256
- id: `user_${Date.now().toString(36)}`,
257
- content: [{ type: 'text', text }],
258
- source: { kind: 'user' },
259
- role: 'user',
260
- };
261
- }
262
- /**
263
- * 部署默认模型(`agentDefaultModel.currentSelection()`)。
264
- * 取不到就必须**在任何内核调用之前**拒绝:宿主 loop 缺 model 会在 `prepareRequest`
265
- * 里抛 `reading 'provider'`,那条崩溃与远程控制毫无关系,排查方向会被彻底带偏
266
- * (取证 `docs/legacy-spec/host-plugin-cordis.md` §3 表第 11 行)。
267
- */
268
- function currentSelection() {
269
- const owner = services.agentDefaultModel;
270
- const read = fn(owner, 'currentSelection');
271
- if (!read || !owner)
272
- return undefined;
273
- try {
274
- const value = read.call(owner);
275
- if (!value || typeof value.provider !== 'string' || typeof value.model !== 'string')
276
- return undefined;
277
- return { provider: value.provider, model: value.model };
278
- }
279
- catch {
280
- return undefined;
281
- }
282
- }
283
- async function sendPrompt(sessionId, text) {
284
- const agent = liveAgent(sessionId);
285
- if (!agent)
286
- return { ok: false, message: `会话没有活的 agent(agents.get(${sessionId}) 为空,请先恢复会话)` };
287
- if (!currentSelection()) {
288
- // 没有可用模型时必须主动拒绝,理由见 currentSelection() 的注释。
289
- return { ok: false, message: '拿不到当前模型选择(agentDefaultModel.currentSelection 缺失)' };
290
- }
291
- const message = await buildUserMessage(text);
292
- const followup = fn(agent, 'followup');
293
- if (followup) {
294
- followup.call(agent, message);
295
- return { ok: true };
296
- }
297
- const steer = fn(agent, 'steer');
298
- if (steer) {
299
- steer.call(agent, message);
300
- return { ok: true };
301
- }
302
- return { ok: false, message: `agent 既没有 followup 也没有 steer(keys=[${shapeOf(agent)}])` };
303
- }
304
- async function interrupt(sessionId) {
305
- const agent = liveAgent(sessionId);
306
- if (!agent)
307
- return { ok: false, message: `会话没有活的 agent:${sessionId}` };
308
- const cancel = fn(agent, 'cancel');
309
- if (!cancel)
310
- return { ok: false, message: `agent 没有 cancel(keys=[${shapeOf(agent)}])` };
311
- // 正解见文件头第 1 条:`{kind:'user'}` + `keepInbox`,保住已排队的用户消息。
312
- cancel.call(agent, { kind: 'user' }, { keepInbox: true });
313
- return { ok: true };
314
- }
315
- /**
316
- * 冷会话续跑(归档或未加载的会话发指令前必须走这一步)。
317
- *
318
- * **`agentOptions` 不能省**:真机取证抓到的就是这个——宿主每轮结束都会 `session/end-seed`
319
- * 把 agent 拆掉,于是手机的第二条指令一定走 resume;而 `agents.resume({resumeSessionId})`
320
- * 不带模型时建出来的 Agent 没有 call configuration,回合在 prompt 装配阶段就死掉:
321
- * `prompt variable "{{model}}" has no value for this assembly (section "deployment:persona-prefix")`,
322
- * 更早一代甚至直接抛 `Cannot read properties of undefined (reading 'provider')`。
323
- * 手机上看到的是"发出去、转两下、一个字都没有"——因为回合是以 error 收的尾,
324
- * 而第一版连 `turn/end` 的 reason 都丢掉了(见 `turnEndKernelEvents`)。
325
- * 取证:`docs/legacy-spec/host-plugin-cordis.md` §3 表第 5/11 行。
326
- */
327
- async function ensureRunnable(sessionId) {
328
- if (liveAgent(sessionId))
329
- return { ok: true };
330
- const registry = services.workspaceRegistry;
331
- const unarchive = fn(registry, 'unarchiveSession');
332
- if (unarchive) {
333
- try {
334
- await unarchive.call(registry, sessionId);
335
- }
336
- catch (error) {
337
- // 恢复失败要如实上报:归档会话会被宿主的 archived-session-gate 在
338
- // `agent/pre-step` 直接拒掉(turn 以 blocked 收场,发生在模型调用之前)。
339
- return { ok: false, message: `恢复归档会话失败:${messageOf(error)}` };
340
- }
341
- }
342
- const resume = fn(services.agents, 'resume');
343
- if (!resume)
344
- return { ok: false, message: `这一代宿主没有 agents.resume(keys=[${shapeOf(services.agents)}])` };
345
- const selection = currentSelection();
346
- if (!selection) {
347
- // 没有模型就不要去续跑:续出来的 Agent 一定在装配阶段炸,而且炸点看起来
348
- // 与远程控制毫无关系。主动拒绝才有可读的原因。
349
- return { ok: false, message: '拿不到当前模型选择(agentDefaultModel.currentSelection 缺失),无法续跑会话' };
350
- }
351
- try {
352
- await resume.call(services.agents, {
353
- resumeSessionId: sessionId,
354
- agentOptions: { provider: selection.provider, model: selection.model },
355
- });
356
- return { ok: true };
357
- }
358
- catch (error) {
359
- return { ok: false, message: `续跑会话失败:${messageOf(error)}` };
360
- }
361
- }
362
- /**
363
- * 读一条会话已有的历史(手机打开会话时用)。
364
- *
365
- * 走 `sessionQuery.readSession(id)`:它返回**整份**会话日志(不是窗口),
366
- * 与实时 `session/event` 同一套 `{type, seq, data}` 形状 —— 所以翻译直接复用
367
- * `sessionEventKernelEvents`,分页由 `historyPageFromLog` 在插件侧切。
368
- *
369
- * 为什么不逐页读:`readEvent({seq, before, after})` 那边有 50 条的窗口上限,
370
- * 按它翻页要先知道"哪些 seq 是有内容的",多一层猜测;而整份日志本来就要被
371
- * `_corpus.load()` 完整读出来(含 replay 校验),**分页切在内存里做不会多读一个字节**。
372
- * 真机实测:4 轮对话的日志 713 行,读一次是毫秒级。
373
- *
374
- * 能力缺失时**抛**而不是回空页:空页在手机上是"这个会话没内容",
375
- * 那是一个会让人查错方向的假事实。
376
- */
377
- async function readHistory(sessionId, page) {
378
- const read = fn(services.sessionQuery, 'readSession');
379
- if (!read) {
380
- throw new Error(`这一代宿主不能读会话历史(sessionQuery 没有 readSession,keys=[${shapeOf(services.sessionQuery)}])`);
381
- }
382
- const loaded = (await withTimeout(Promise.resolve(read.call(services.sessionQuery, sessionId)), options.historyTimeoutMs ?? HISTORY_TIMEOUT_DEFAULT));
383
- const events = Array.isArray(loaded?.events) ? loaded.events : [];
384
- return historyPageFromLog({
385
- sessionId,
386
- events,
387
- ...(page.beforeSeq === undefined ? {} : { beforeSeq: page.beforeSeq }),
388
- limit: page.limit,
389
- });
390
- }
391
- function subscribe(onEvent) {
392
- const on = services.on;
393
- if (typeof on !== 'function') {
394
- log('kernel has no on(); streaming disabled', { keys: shapeOf(services) });
395
- return () => { };
396
- }
397
- const disposers = [];
398
- /**
399
- * 注册一个**永远不会往外抛**的监听器。
400
- *
401
- * 这条不是防御性冗余:`guardedSubscribe` 只保护"注册"这一步,回调本体是裸交给
402
- * cordis 的 emit 的。一次抛出(例如某代事件的字段形状没料到)会打断这一条事件的
403
- * 派发链,严重时整条订阅被拆掉——真机上表现成"回合跑到一半就再也没有事件",
404
- * 而且 status.json 里一片祥和。插件的红线是"绝不带崩宿主",事件回调是第一现场。
405
- */
406
- const bind = (name, handler) => {
407
- const guardedHandler = (...args) => {
408
- try {
409
- handler(...args);
410
- }
411
- catch (error) {
412
- const key = `${name}: ${messageOf(error)}`.slice(0, 120);
413
- const count = (listenerErrors.get(key) ?? 0) + 1;
414
- listenerErrors.set(key, count);
415
- if (count === 1)
416
- log('event listener threw', { name, message: messageOf(error) });
417
- }
418
- };
419
- disposers.push(guardedSubscribe(on, name, guardedHandler, (message, fields) => log(message, fields)));
420
- };
421
- bind('session/event', (...args) => translateSessionEvent(onEvent, args));
422
- bind('session/created', () => onEvent({ kind: 'sessions-changed', reason: 'session/created' }));
423
- bind('agent/status', (...args) => translateAgentStatus(onEvent, args));
424
- bind('agent/error', (...args) => {
425
- const sessionId = sessionIdOf(args);
426
- if (sessionId)
427
- onEvent({ kind: 'run-state', sessionId, state: 'idle', detail: 'agent-error' });
428
- });
429
- return () => {
430
- for (const dispose of disposers) {
431
- try {
432
- dispose();
433
- }
434
- catch {
435
- /* 退订失败无所谓 */
436
- }
437
- }
438
- };
439
- }
440
- /**
441
- * 平台日志里的一条会话事件 → 插件词汇表。
442
- *
443
- * 未识别的类型**不猜、不挪用**,但必须留痕:`seenEventTypes` / `unmappedEventTypes`
444
- * 直接进 status.json 的 `kernel` 里。真机取证第一次用到它就抓出了"手机上看不到工具行"
445
- * 这类问题的分叉点——到底是内核没发、还是我们没认。
446
- */
447
- function translateSessionEvent(onEvent, args) {
448
- const session = args.find((arg) => arg && typeof arg === 'object' && typeof arg.id === 'string');
449
- const event = args.find((arg) => arg && typeof arg === 'object' && typeof arg.type === 'string');
450
- const sessionId = String(session?.id ?? '');
451
- const type = event?.type;
452
- if (!sessionId || !type)
453
- return;
454
- seenEventTypes.add(type);
455
- const data = (event?.data ?? {});
456
- for (const mapped of sessionEventKernelEvents({ sessionId, type, seq: event?.seq, data })) {
457
- // 标题缓存留在闭包这一侧:纯函数只负责"这条事件翻成什么",不负责记账。
458
- if (mapped.kind === 'title')
459
- titleCache.set(sessionId, { title: mapped.title, at: options.clock.now() });
460
- onEvent(mapped);
461
- }
462
- if (!MAPPED_SESSION_EVENTS.has(type)) {
463
- // 不映射、不猜语义(F1:挪用既有名字的语义才是危险的),但**必须留痕**:
464
- // "手机上看不见工具行"这种问题,第一步就是要分清是内核没发还是我们没认。
465
- if (!unmappedEventTypes.has(type)) {
466
- unmappedEventTypes.add(type);
467
- log('unmapped session event type', { type, keys: shapeOf(data) });
468
- }
469
- }
470
- }
471
- function translateAgentStatus(onEvent, args) {
472
- const payload = args.find((arg) => arg && typeof arg === 'object');
473
- const sessionId = payload?.sessionId ?? String(payload?.agent?.session?.id ?? '');
474
- if (!sessionId)
475
- return;
476
- onEvent({ kind: 'run-state', sessionId, state: payload?.status === 'running' ? 'running' : 'idle' });
477
- }
478
- let sink;
479
- function attachInteractionSink(next) {
480
- sink = next;
481
- const disposers = [];
482
- const on = services.on;
483
- if (typeof on === 'function') {
484
- const name = 'approval/request';
485
- if (canParticipate(name)) {
486
- // **参与**这条 waterfall:必须返回 outcome 或调用 next(),返回 undefined 会冲掉整条链。
487
- const listener = (...args) => participate(args);
488
- try {
489
- const disposer = on.call(services, name, listener);
490
- // 这条登记要进 status.json:真机上审批卡"为什么没弹"分三种原因
491
- // (策略根本没问 / 没登记上 / 登记了但没配对的手机),没有这个字段就只能猜。
492
- approvalFace = 'registered';
493
- disposers.push(() => {
494
- if (typeof disposer === 'function')
495
- disposer();
496
- else if (typeof services.off === 'function')
497
- services.off(name, listener);
498
- });
499
- }
500
- catch (error) {
501
- approvalFace = `failed: ${messageOf(error)}`.slice(0, 80);
502
- log('approval/request participation failed', { message: messageOf(error) });
503
- }
504
- }
505
- else {
506
- approvalFace = 'refused by guard';
507
- }
508
- }
509
- else {
510
- approvalFace = 'no on()';
511
- }
512
- const questions = services.userQuestions;
513
- if (!options.takeOverQuestions) {
514
- // 默认关:`ctx.userQuestions` 只允许一个活跃提供者,接管就意味着桌面 UI 不再问。
515
- questionsFace = 'not-taken-over';
516
- }
517
- else if (!questions || typeof questions.registerProvider !== 'function') {
518
- // 把服务实际暴露的成员一起报出来:真机上这一代宿主没有 `registerProvider`,
519
- // 只写"没有提供者"就分不清是"服务不在"还是"这一代的 API 换了名字"。
520
- questionsFace = `no registerProvider (keys=${questions ? shapeOf(questions) : 'service absent'})`.slice(0, 120);
521
- }
522
- if (options.takeOverQuestions && questions && typeof questions.registerProvider === 'function') {
523
- const provider = {
524
- ask: async (request) => {
525
- const agent = request.agent;
526
- const sessionId = String(agent?.session?.id ?? request.sessionId ?? '');
527
- const items = Array.isArray(request.questions) ? request.questions : [];
528
- const mapped = items.map((item, index) => mapQuestion(item, index));
529
- const answer = await sink?.question({
530
- sessionId,
531
- questions: mapped,
532
- ...(request.signal ? { signal: request.signal } : {}),
533
- });
534
- if (!answer) {
535
- // 提问服务没有 next() 可交还(单提供者),所以这里必须明确失败,
536
- // 绝不能静默挂着——那会把整个 agent 卡死。
537
- throw new Error('手机端未应答这次提问(未配对、不在线或已超时)');
538
- }
539
- return { answers: answer.answers };
540
- },
541
- };
542
- try {
543
- const dispose = questions.registerProvider.call(questions, provider);
544
- if (typeof dispose === 'function')
545
- disposers.push(dispose);
546
- questionsFace = 'registered';
547
- log('question provider taken over from the desktop UI');
548
- }
549
- catch (error) {
550
- questionsFace = `failed: ${messageOf(error)}`.slice(0, 80);
551
- log('question provider registration failed', { message: messageOf(error) });
552
- }
553
- }
554
- return () => {
555
- sink = undefined;
556
- for (const dispose of disposers) {
557
- try {
558
- dispose();
559
- }
560
- catch {
561
- /* 尽力退订 */
562
- }
563
- }
564
- };
565
- }
566
- /** `approval/request(this, req, next)`:req 是只读审批问题,next 交还给其他应答者。 */
567
- async function participate(args) {
568
- const request = args.find((arg) => arg && typeof arg === 'object' && typeof arg.toolName === 'string');
569
- const next = args.find((arg) => typeof arg === 'function');
570
- const sessionId = String(request?.agent?.session?.id ?? '');
571
- if (!sink || !sessionId || !servicesPeers()) {
572
- // 没有手机端在线 → 交还桌面 UI。这一步是"插件不抢答"的关键。
573
- return String((await next?.()) ?? 'unavailable');
574
- }
575
- const decision = await sink.approval({
576
- sessionId,
577
- action: String(request?.toolName ?? '工具调用'),
578
- ...(request?.reason ? { reason: String(request.reason) } : {}),
579
- ...(request?.signal ? { signal: request.signal } : {}),
580
- });
581
- if (decision === 'decline')
582
- return String((await next?.()) ?? 'unavailable');
583
- return decision;
584
- }
585
- /** 有没有能收消息的对端由 runtime 判断;这里只做一个粗筛(有 sink 即可能有对端)。 */
586
- function servicesPeers() {
587
- return sink !== undefined;
588
- }
589
- function mapQuestion(item, index) {
590
- const options = Array.isArray(item.options) ? item.options : [];
591
- return {
592
- id: String(item.id ?? `q${index + 1}`),
593
- question: String(item.question ?? ''),
594
- ...(item.multiSelect === true ? { multi: true } : {}),
595
- ...(options.length > 0
596
- ? {
597
- options: options.map((option, optionIndex) => ({
598
- // 平台选项只有 label(没有 id),所以 id 由这里稳定生成:
599
- // 手机上回传 id,我们再映射回 label 交给平台(见 core/runtime.ts)。
600
- id: `o${optionIndex + 1}`,
601
- label: String(option.label ?? ''),
602
- })),
603
- }
604
- : {}),
605
- };
606
- }
607
- return {
608
- carrier: 'services',
609
- listSessions,
610
- async runState(sessionId) {
611
- const running = agentStatus(sessionId) === 'running';
612
- const archived = archivedIds().has(sessionId);
613
- return { running, state: archived ? 'archived' : running ? 'running' : 'idle' };
614
- },
615
- sendPrompt,
616
- interrupt,
617
- readHistory,
618
- newSession,
619
- subscribe,
620
- attachInteractionSink,
621
- ensureRunnable,
622
- sessionWorkspace,
623
- // 与发指令/续跑走同一条读取路径:这里原来还有一份实现,而且是 `current()` 裸调用
624
- // (不带 receiver;真要用 this 的服务方法会当场抛),两份迟早分叉。
625
- modelSelection: currentSelection,
626
- describe() {
627
- return {
628
- carrier: 'services',
629
- sessions: typeof services.sessions === 'object',
630
- sessionQuery: typeof services.sessionQuery === 'object',
631
- // 读历史只依赖这一条。真机上"点进会话是空的"第一步就要分清:
632
- // 是宿主没这个能力、还是我们没读出来。没有这个字段就只能猜。
633
- historyFace: typeof fn(services.sessionQuery, 'readSession') === 'function'
634
- ? 'readSession'
635
- : `absent (keys=${shapeOf(services.sessionQuery)})`.slice(0, 120),
636
- // 新建会话只依赖这一条。和 historyFace 同理:真机上"新建没反应"第一件要分清的事
637
- // 是宿主没有这个能力,还是我们没调对。
638
- createFace: typeof fn(services.sessionController?.commands, 'create') === 'function'
639
- ? 'commands.create'
640
- : `absent (keys=${shapeOf(services.sessionController)})`.slice(0, 120),
641
- // 二维码落点依赖这一条。它缺席时 PNG 会退回 ~/.dsh/,卡片上的路径会跟着变——
642
- // 用户看到"图不在工作区"时,第一件事就是看这个字段是不是 absent。
643
- workspaceFace: typeof fn(services.sessions, 'get') === 'function'
644
- ? 'sessions.get'
645
- : `absent (keys=${shapeOf(services.sessions)})`.slice(0, 120),
646
- agents: typeof services.agents === 'object',
647
- agentDefaultModel: typeof services.agentDefaultModel === 'object',
648
- workspaceRegistry: typeof services.workspaceRegistry === 'object',
649
- userQuestions: typeof services.userQuestions === 'object',
650
- takeOverQuestions: options.takeOverQuestions === true,
651
- archivedSessions: archivedIds().size,
652
- hasOn: typeof services.on === 'function',
653
- // 真机排错的第一现场:内核到底发了哪些事件类型、其中哪些我们没认。
654
- eventTypes: [...seenEventTypes].slice(-24).join('|'),
655
- unmappedEventTypes: [...unmappedEventTypes].join('|'),
656
- approvalFace,
657
- questionsFace,
658
- listenerErrors: [...listenerErrors]
659
- .slice(0, 4)
660
- .map(([key, count]) => `${count}×${key}`)
661
- .join(' | ') || 'none',
662
- };
663
- },
664
- };
665
- }
666
- /**
667
- * 会话事件类型 → 插件事件序列。**纯函数**,抽出来的理由与 `turnEndKernelEvents`
668
- * 一样:这一段每一条都对应一个真机形状,必须能在 CI 里逐条测,而不是等下一次重启宿主。
669
- * 未知类型返回空数组(不映射、不猜),由调用方留痕。
670
- */
671
- export const MAPPED_SESSION_EVENTS = new Set([
672
- 'turn/start',
673
- 'assistant/message',
674
- 'user/message',
675
- 'tool/call',
676
- 'tool/result',
677
- 'turn/end',
678
- 'session/title',
679
- 'approval/asked',
680
- 'approval/decided',
681
- ]);
682
- export function sessionEventKernelEvents(input) {
683
- const { sessionId, type, data } = input;
684
- switch (type) {
685
- case 'turn/start':
686
- return [{ kind: 'run-state', sessionId, state: 'running' }];
687
- case 'assistant/message':
688
- case 'user/message':
689
- return [
690
- {
691
- kind: 'delta',
692
- sessionId,
693
- messageId: messageIdOf(data, input.seq),
694
- text: textOf(data),
695
- role: type === 'assistant/message' ? 'assistant' : 'user',
696
- done: true,
697
- },
698
- ];
699
- case 'tool/call': {
700
- // 字段名要按真机形状读:内核给的是 `arguments`(一个 JSON 串),不是 `args`;
701
- // 读错了这一帧永远停在 'started',手机上看不到参数、也看不出工具在跑什么。
702
- const argsRaw = data.arguments ?? data.args;
703
- return [
704
- {
705
- kind: 'tool',
706
- sessionId,
707
- callId: String(data.callId ?? data.id ?? `tool_${input.seq ?? 0}`),
708
- phase: argsRaw === undefined || argsRaw === null ? 'started' : 'args',
709
- tool: typeof data.name === 'string' ? data.name : undefined,
710
- title: typeof data.title === 'string' ? data.title : previewOf(argsRaw),
711
- ...(argsRaw === undefined ? {} : { argsPreview: previewOf(argsRaw) }),
712
- },
713
- ];
714
- }
715
- case 'tool/result': {
716
- const message = data.message;
717
- const callId = data.callId ?? message?.toolCallId ?? message?.source?.callId ?? '';
718
- // 正文同样在两处:顶层 `result`(一代)与 `data.message.content`(真机这一代)。
719
- const body = data.result ?? data.error ?? eventContent(data);
720
- return [
721
- {
722
- kind: 'tool',
723
- sessionId,
724
- callId: String(callId),
725
- phase: data.error ? 'failed' : 'completed',
726
- resultPreview: previewOf(body),
727
- },
728
- ];
729
- }
730
- case 'turn/end':
731
- return turnEndKernelEvents({ sessionId, seq: input.seq, data });
732
- case 'session/title':
733
- return typeof data.title === 'string' ? [{ kind: 'title', sessionId, title: data.title }] : [];
734
- case 'approval/asked':
735
- case 'approval/decided':
736
- // 审批审计事件(emit-mode,经 session/event 到达)。审批卡片本身由
737
- // `approval/request` 的参与者路径产生,见 attachInteractionSink。
738
- return [{ kind: 'sessions-changed', reason: type }];
739
- default:
740
- return [];
741
- }
742
- }
743
- /**
744
- * 回合失败时给手机看的那句话。
745
- *
746
- * 中文打底 + 原始原因照抄:只说"失败了"用户无从下手,只贴英文栈又看不懂。
747
- * 长度夹住(内核的 message 可能带着整段 prompt 或栈),并剥掉可能的路径与凭据形态。
748
- */
749
- function turnErrorText(reason) {
750
- const raw = String(reason?.error?.message ?? reason?.kind ?? '未知错误')
751
- .replace(/\s+/g, ' ')
752
- .trim();
753
- const code = reason?.error?.code ? `(${String(reason.error.code)})` : '';
754
- return `这一轮没有产出回复,内核报错${code}:${raw.slice(0, 240)}`;
755
- }
756
- /**
757
- * 内核 `turn/end` → 插件事件序列。单独抽出来是为了**能在 CI 里测**:
758
- * 这一条是真机取证抓出来的(宿主在 prompt 装配阶段抛
759
- * `prompt variable "{{model}}" has no value for this assembly`,而第一版把
760
- * `data.reason` 整个丢掉,于是手机上"一个字都没有的失败回合"与"成功的回合"
761
- * 长得一模一样,live-e2e 十五项全绿而实际没有任何回复)。
762
- * 顺序是有约定的:先补 done,再报 idle,否则手机的转圈停不下来(F9)。
763
- */
764
- export function turnEndKernelEvents(input) {
765
- const reason = input.data.reason;
766
- const failed = reason?.kind === 'error';
767
- const messageId = `turn_${input.seq ?? 0}`;
768
- const events = [];
769
- if (failed)
770
- events.push({
771
- kind: 'delta',
772
- sessionId: input.sessionId,
773
- messageId,
774
- text: turnErrorText(reason),
775
- role: 'system',
776
- done: false,
777
- });
778
- events.push({ kind: 'delta', sessionId: input.sessionId, messageId, text: '', done: true });
779
- events.push({
780
- kind: 'run-state',
781
- sessionId: input.sessionId,
782
- state: 'idle',
783
- ...(failed ? { detail: 'turn-error' } : {}),
784
- });
785
- return events;
786
- }
787
- /**
788
- * 一条会话事件的正文在哪里。
789
- *
790
- * **两代形状都要认**(真机取证):`user/message` 把 `content` 放在事件 data 顶层,
791
- * 而 `assistant/message` 放在 `data.message.content`(同一层还有 `turn/step/usage/stream`)。
792
- * 第一版只读顶层,于是助手的回复折成空串——手机上"流式输出到达"的断言**仍然通过**
793
- * (done 帧照发、帧数照够),但屏幕上一个字都没有。这是这条链路上最要命的一类缺陷:
794
- * 判据数的是帧,不是内容。
795
- */
796
- function eventContent(data) {
797
- if (data?.content !== undefined && data?.content !== null)
798
- return data.content;
799
- const message = data?.message;
800
- return message?.content;
801
- }
802
- // ── 历史:内核日志 → 一页线格式条目 ───────────────────────────────────
803
- /** 一页历史默认给多少条线格式条目(够看几轮,又不会顶满中继的帧预算)。 */
804
- export const HISTORY_LIMIT_DEFAULT = 40;
805
- /** 单张页面的正文总预算(字符)。超了就少给几条,剩下的由游标继续翻。 */
806
- const HISTORY_CHAR_BUDGET = 100_000;
807
- /** 单条正文的硬上限。小程序侧的正文块上限是 20000,这里留出余量。 */
808
- const HISTORY_TEXT_MAX = 12_000;
809
- /**
810
- * 内核日志 → 一页历史。**纯函数**,理由与 `sessionEventKernelEvents` 一样:
811
- * 这一段每一条都对应一个真机形状,必须能在 CI 里逐条测。
812
- *
813
- * 三件在真机日志上验证过的事(取证:`~/.dsh/sessions/**\/session.v4.jsonl.zstd`):
814
- *
815
- * 1. **日志事件与实时 `session/event` 是同一个形状**(`{type, seq, data}`),
816
- * 所以这里直接复用 `sessionEventKernelEvents`。顺带得到一个关键性质:
817
- * 同一条消息在两条路径上算出的 `messageId` **逐字相同**(都走 `messageIdOf`),
818
- * 于是"实时已经渲染过、历史里又来一遍"这种重叠靠 id 就能去重。
819
- * 2. **一条原始事件可能折成 0 条**:绝大多数 `assistant/message` 带的是
820
- * `reasoning` + `tool-call`(正文是**空的**),按 M28 只有 `type==='text'` 的分片出站。
821
- * 不过滤的话,历史里会插进一堆空白正文块。
822
- * 3. **`turn/end` 会产生终结帧**(空文本 + done、以及 run-state)。历史不需要终结帧
823
- * ——没有东西需要被终止——所以只留 delta/tool 两种,运行态一律丢掉:
824
- * 回放历史不该去改顶栏的"运行中"。
825
- */
826
- export function historyPageFromLog(input) {
827
- const { sessionId } = input;
828
- const groups = [];
829
- for (const raw of input.events ?? []) {
830
- const type = typeof raw?.type === 'string' ? raw.type : '';
831
- if (!type)
832
- continue;
833
- const seq = typeof raw?.seq === 'number' && Number.isFinite(raw.seq) ? raw.seq : 0;
834
- const data = (raw?.data ?? {});
835
- const mapped = sessionEventKernelEvents({ sessionId, type, seq, data });
836
- const kept = mapped.filter(isHistoryWorthy);
837
- if (kept.length > 0)
838
- groups.push({ seq, events: kept });
839
- }
840
- // 一次调用里的所有 tool/call:它的结果在不在**整份日志**里。读历史时整份日志都到手了,
841
- // 所以这里能区分"这一步确实干完了"与"这一步没有下文"。
842
- const terminal = new Set();
843
- for (const group of groups) {
844
- for (const event of group.events) {
845
- if (event.kind === 'tool' && (event.phase === 'completed' || event.phase === 'failed')) {
846
- terminal.add(event.callId);
847
- }
848
- }
849
- }
850
- const ranged = input.beforeSeq === undefined ? groups : groups.filter((group) => group.seq < input.beforeSeq);
851
- const limit = Math.max(1, input.limit);
852
- const budget = input.charBudget ?? HISTORY_CHAR_BUDGET;
853
- // 从**后往前**取,保证拿到的是"最近的一页",而不是会话开头。
854
- const page = [];
855
- let items = 0;
856
- let chars = 0;
857
- for (let i = ranged.length - 1; i >= 0; i--) {
858
- const group = ranged[i];
859
- // 已经够一页了:**但至少给一组**,否则 limit 很小时会产出一张空页而游标还在动。
860
- if (page.length > 0 && (items >= limit || chars >= budget))
861
- break;
862
- const normalized = group.events.map((event) => normalizeHistoryEvent(event, terminal));
863
- page.unshift({ seq: group.seq, events: normalized });
864
- items += normalized.length;
865
- chars += normalized.reduce((sum, event) => sum + charCountOf(event), 0);
866
- }
867
- const flat = [];
868
- for (const group of page)
869
- flat.push(...group.events);
870
- // 游标只在**确实还有更早、且还有内容**的时候给:给出一个翻不出东西的游标,
871
- // 手机上会长出一个点了没反应的「加载更早」。
872
- const earliest = page.length > 0 ? page[0].seq : undefined;
873
- const hasEarlier = earliest !== undefined && ranged.some((group) => group.seq < earliest);
874
- return {
875
- events: flat,
876
- ...(hasEarlier ? { nextBeforeSeq: earliest } : {}),
877
- };
878
- }
879
- /** 历史只留"有内容"的两种:有正文的 delta、有 callId 的工具事件。 */
880
- function isHistoryWorthy(event) {
881
- if (event.kind === 'delta')
882
- return event.text.length > 0;
883
- if (event.kind === 'tool')
884
- return event.callId.length > 0;
885
- return false;
886
- }
887
- /**
888
- * 一条没有下文(整份日志里都没有结果)的工具调用,在历史里要有个说法。
889
- *
890
- * 它只会出现在"这一轮被中断或出错"的情形。原样留给手机的话,步骤组会显示
891
- * 「正在执行 1 个步骤」并一直闪 —— 而那条会话其实早就停了,看起来就像页面坏了。
892
- * 所以这里落成一个明确的、不撒谎的形态:状态按"已结束"渲染,结果位写明原因。
893
- *
894
- * 如果这条会话此刻还在跑,真正的那条结果稍后会从实时流到达,手机按 callId
895
- * 在原位替换掉这句占位文案(`chat.js` 的 `_onTool` 就是按 callId 找的)。
896
- */
897
- function normalizeHistoryEvent(event, terminal) {
898
- if (event.kind === 'delta') {
899
- if (event.text.length <= HISTORY_TEXT_MAX)
900
- return event;
901
- return { ...event, text: `${event.text.slice(0, HISTORY_TEXT_MAX)}…(这条回复过长,已截断)` };
902
- }
903
- if (event.kind === 'tool' && !terminal.has(event.callId) && event.phase !== 'completed' && event.phase !== 'failed') {
904
- return { ...event, phase: 'completed', resultPreview: '(这一轮被中断,这个工具没有返回结果)' };
905
- }
906
- return event;
907
- }
908
- function charCountOf(event) {
909
- if (event.kind === 'delta')
910
- return event.text.length;
911
- if (event.kind === 'tool')
912
- return (event.resultPreview ?? '').length + (event.argsPreview ?? '').length;
913
- return 0;
914
- }
915
- function textOf(data) {
916
- const content = eventContent(data);
917
- if (typeof content === 'string')
918
- return content;
919
- if (Array.isArray(content)) {
920
- return content
921
- .map((part) => part && typeof part === 'object' && part.type === 'text'
922
- ? String(part.text ?? '')
923
- : '')
924
- .join('');
925
- }
926
- return '';
927
- }
928
- function messageIdOf(data, seq) {
929
- const message = data?.message;
930
- const id = data?.id ?? data?.messageId ?? message?.id;
931
- if (typeof id === 'string' && id)
932
- return id;
933
- return `msg_${String(seq ?? Date.now())}`;
934
- }
935
- function previewOf(value) {
936
- if (value === undefined || value === null)
937
- return undefined;
938
- const text = typeof value === 'string' ? value : safeJson(value);
939
- if (!text)
940
- return undefined;
941
- return text.length > 200 ? `${text.slice(0, 200)}…` : text;
942
- }
943
- function safeJson(value) {
944
- try {
945
- return JSON.stringify(value) ?? String(value);
946
- }
947
- catch {
948
- return String(value);
949
- }
950
- }
951
- async function withTimeout(promise, ms) {
952
- let timer;
953
- try {
954
- return await Promise.race([
955
- promise,
956
- new Promise((_, reject) => {
957
- timer = setTimeout(() => reject(new Error(`超时 ${ms}ms`)), ms);
958
- }),
959
- ]);
960
- }
961
- finally {
962
- if (timer)
963
- clearTimeout(timer);
964
- }
965
- }
966
- function messageOf(error) {
967
- return String(error?.message ?? error);
968
- }
969
- function sessionIdOf(args) {
970
- for (const arg of args) {
971
- if (!arg || typeof arg !== 'object')
972
- continue;
973
- const direct = arg.sessionId;
974
- if (typeof direct === 'string')
975
- return direct;
976
- const nested = arg.session;
977
- if (nested && typeof nested.id === 'string')
978
- return nested.id;
979
- }
980
- return undefined;
981
- }
982
- //# sourceMappingURL=carrier-services.js.map