virlen-remote 0.5.0 → 0.7.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/CHANGELOG.md +373 -169
- package/README.md +389 -201
- package/dist/index.d.ts +10 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -1
- package/dist/protocol/agents.d.ts +14 -0
- package/dist/protocol/agents.d.ts.map +1 -0
- package/dist/protocol/agents.js +14 -0
- package/dist/protocol/agents.js.map +1 -0
- package/dist/protocol/api.d.ts +370 -1
- package/dist/protocol/api.d.ts.map +1 -1
- package/dist/protocol/api.js.map +1 -1
- package/dist/protocol/compress.d.ts +55 -0
- package/dist/protocol/compress.d.ts.map +1 -0
- package/dist/protocol/compress.js +60 -0
- package/dist/protocol/compress.js.map +1 -0
- package/dist/protocol/files.d.ts +261 -0
- package/dist/protocol/files.d.ts.map +1 -0
- package/dist/protocol/files.js +506 -0
- package/dist/protocol/files.js.map +1 -0
- package/dist/protocol/host.d.ts +24 -1
- package/dist/protocol/host.d.ts.map +1 -1
- package/dist/protocol/host.js +9 -1
- package/dist/protocol/host.js.map +1 -1
- package/dist/protocol/message-files.d.ts +79 -0
- package/dist/protocol/message-files.d.ts.map +1 -0
- package/dist/protocol/message-files.js +118 -0
- package/dist/protocol/message-files.js.map +1 -0
- package/dist/protocol/tool-args.d.ts +95 -0
- package/dist/protocol/tool-args.d.ts.map +1 -0
- package/dist/protocol/tool-args.js +439 -0
- package/dist/protocol/tool-args.js.map +1 -0
- package/dist/testing/mock-host.d.ts +44 -1
- package/dist/testing/mock-host.d.ts.map +1 -1
- package/dist/testing/mock-host.js +439 -10
- package/dist/testing/mock-host.js.map +1 -1
- package/dist/transport/rtc.d.ts +17 -0
- package/dist/transport/rtc.d.ts.map +1 -1
- package/dist/transport/rtc.js +49 -12
- package/dist/transport/rtc.js.map +1 -1
- package/dist/transport/signaling.d.ts +12 -1
- package/dist/transport/signaling.d.ts.map +1 -1
- package/dist/transport/signaling.js +17 -3
- package/dist/transport/signaling.js.map +1 -1
- package/package.json +69 -69
- package/src/index.ts +270 -184
- package/src/protocol/agents.ts +13 -0
- package/src/protocol/api.ts +974 -615
- package/src/protocol/compress.ts +65 -0
- package/src/protocol/files.ts +544 -0
- package/src/protocol/host.ts +205 -165
- package/src/protocol/message-actions.ts +60 -60
- package/src/protocol/message-files.ts +161 -0
- package/src/protocol/pairing.ts +254 -254
- package/src/protocol/tool-args.ts +478 -0
- package/src/testing/mock-host.ts +1285 -740
- package/src/transport/link-kind.ts +222 -222
- package/src/transport/rtc.ts +381 -344
- package/src/transport/signaling.ts +331 -317
- package/src/transport/transfer-tier.ts +64 -64
package/src/protocol/api.ts
CHANGED
|
@@ -1,615 +1,974 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 类型化 API 定义 + 两端共用的推导辅助(见 docs/phone-control-bridge.md §2.2/§2.3)。
|
|
3
|
-
*
|
|
4
|
-
* **命名法:按「谁实现」命名,而不是「谁调用」。**
|
|
5
|
-
* - `host.*` = 电脑实现、手机调用
|
|
6
|
-
* - `mobile.*` = 手机实现、电脑调用
|
|
7
|
-
* 这样同一份定义能被两端共用:手机侧推导出类型安全客户端,电脑侧推导出必须实现的 handler 签名。
|
|
8
|
-
* **改一处,两端同时编译报错。**
|
|
9
|
-
*
|
|
10
|
-
* ⚠️ 本文件当前承载「协议表」;DTO 为白名单投影(§7-⑥:绝不 `{...session}`),
|
|
11
|
-
* 具体从电脑侧 store 投影到 DTO 的适配在 M2 的 `dto.ts` 落地。
|
|
12
|
-
*/
|
|
13
|
-
import type { CallContext, Endpoint } from './endpoint'
|
|
14
|
-
import type {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*/
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*/
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*/
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
*
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
*
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
/**
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
/**
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
export
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
/**
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
/**
|
|
448
|
-
|
|
449
|
-
/**
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
*
|
|
467
|
-
*
|
|
468
|
-
* ⚠️
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
'
|
|
484
|
-
'
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
'
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
/**
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
/**
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
1
|
+
/**
|
|
2
|
+
* 类型化 API 定义 + 两端共用的推导辅助(见 docs/phone-control-bridge.md §2.2/§2.3)。
|
|
3
|
+
*
|
|
4
|
+
* **命名法:按「谁实现」命名,而不是「谁调用」。**
|
|
5
|
+
* - `host.*` = 电脑实现、手机调用
|
|
6
|
+
* - `mobile.*` = 手机实现、电脑调用
|
|
7
|
+
* 这样同一份定义能被两端共用:手机侧推导出类型安全客户端,电脑侧推导出必须实现的 handler 签名。
|
|
8
|
+
* **改一处,两端同时编译报错。**
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ 本文件当前承载「协议表」;DTO 为白名单投影(§7-⑥:绝不 `{...session}`),
|
|
11
|
+
* 具体从电脑侧 store 投影到 DTO 的适配在 M2 的 `dto.ts` 落地。
|
|
12
|
+
*/
|
|
13
|
+
import type { CallContext, Endpoint } from './endpoint'
|
|
14
|
+
import type { CompressMode } from './compress'
|
|
15
|
+
import type { FilePreviewKind } from './files'
|
|
16
|
+
import type { MessageFileRef } from './message-files'
|
|
17
|
+
import type { MessageQuote } from './message-actions'
|
|
18
|
+
|
|
19
|
+
// ───────────────────────────── DTO(白名单投影) ─────────────────────────────
|
|
20
|
+
|
|
21
|
+
export interface SessionSummaryDTO {
|
|
22
|
+
id: string
|
|
23
|
+
title: string
|
|
24
|
+
updatedAt: number
|
|
25
|
+
/** 该会话是否正在跑 run(手机据此显示「工作中」)。 */
|
|
26
|
+
working?: boolean
|
|
27
|
+
/** 是否置顶(M4:手机可置顶/取消置顶,需要知道当前态——目标态语义,不能靠切换)。 */
|
|
28
|
+
pinned?: boolean
|
|
29
|
+
// ── 会话列表分组 / 信息面板(§22)──
|
|
30
|
+
/** 归属 Agent id(抽屉「按 Agent 分组」的依据)。 */
|
|
31
|
+
agentId?: string
|
|
32
|
+
/** 归属 Agent 显示名(电脑侧解析,手机不查表)。 */
|
|
33
|
+
agentName?: string
|
|
34
|
+
/**
|
|
35
|
+
* 工作目录(**归一化绝对路径**,`/` 分隔)。
|
|
36
|
+
*
|
|
37
|
+
* ⚠️ 这是 §22 对 §7-⑥ 的**有意放宽**:手机端要按工作目录分组、并在**新建会话时**选目录,
|
|
38
|
+
* 必须能看到它。越权防线不在「手机看不到路径」,而在**电脑侧的候选集校验**
|
|
39
|
+
* (`host.session.create` 只接受 `host.workspace.list` 给过的目录,见 §22.3)。
|
|
40
|
+
* 仍然不外发 `systemPrompt` / `params` / `allowedTools`(那些才是真正的泄露面)。
|
|
41
|
+
*/
|
|
42
|
+
workspace?: string
|
|
43
|
+
// ── 当前模型(信息面板展示;切换见 `host.session.setModel`)──
|
|
44
|
+
providerConfigId?: string
|
|
45
|
+
providerName?: string
|
|
46
|
+
modelId?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface MessageDTO {
|
|
50
|
+
id: string
|
|
51
|
+
role: 'user' | 'assistant' | 'tool' | 'system'
|
|
52
|
+
/**
|
|
53
|
+
* 纯文本投影;图片等富内容一期剥离为占位符(§7-⑦)。空串 = 无正文(手机端不渲染空气泡)。
|
|
54
|
+
*
|
|
55
|
+
* ⚠️ 空串有**两种**来源,靠 `detail` 区分:本来就没有正文,与**正文被传输档位省略**
|
|
56
|
+
* (中继 / 类型未知时工具输出不下发,见共享包的 `TransferTier`)。把后者当成前者,
|
|
57
|
+
* 就是给用户写一句不成立的结论。
|
|
58
|
+
*
|
|
59
|
+
* ⚠️ **引用块不在本字段里**(见 `quotes`):引用是结构化下行的,若同时把它展平进
|
|
60
|
+
* `text`,消费方就会把同一段引文显示两遍(引用条 + 正文里的 `[引用] …`)。
|
|
61
|
+
*
|
|
62
|
+
* ⚠️ **工具输出(`role:'tool'`)有长度上限**:超过 `TOOL_DETAIL_MAX`(5000)字符时
|
|
63
|
+
* 电脑侧会做**中间省略**(`elideMiddle`),并把 `…(中间省略 N 字符)…` 写在正文里 ——
|
|
64
|
+
* 头尾都是真的,只少了中间那段。所以它**不是**「正文被篡改」,消费方不得据此
|
|
65
|
+
* 提示「内容不可信」;要判「有没有被砍过」就看那一行标记(长度看不出来)。
|
|
66
|
+
*/
|
|
67
|
+
text: string
|
|
68
|
+
createdAt: number
|
|
69
|
+
/**
|
|
70
|
+
* 正文完整性标记(**可选**,只在正文被有意省略时出现)。
|
|
71
|
+
*
|
|
72
|
+
* `'omitted'`:`text` 为空是**档位决定**,不是「这次调用没有输出」—— 工具输出确实存在,
|
|
73
|
+
* 只是这条链路(TURN 中继 / 类型未知)按精简档不下发(§33)。手机端据此显示「输出已省略」。
|
|
74
|
+
*
|
|
75
|
+
* ⚠️ 消费方**不要穷举取值**:判「有没有这个字段」,不认识的值一律按「正文不完整」处理 ——
|
|
76
|
+
* 将来增加别的完整性档(如截断)时,旧消费方仍应给出诚实的提示而不是当成完整正文。
|
|
77
|
+
*/
|
|
78
|
+
detail?: 'omitted'
|
|
79
|
+
/**
|
|
80
|
+
* 工具名(仅 `role:'tool'`,如 `read_file`)。
|
|
81
|
+
*
|
|
82
|
+
* 来源:电脑侧用 `toolCallId` 反查发起该调用的 assistant 消息的 `toolCalls[].name`
|
|
83
|
+
* (与桌面 `message-list/helpers.ts::resolveJumpAnchorId` 同一套匹配规则)。
|
|
84
|
+
* 手机端**不猜**:拿不到就只显示「工具」。
|
|
85
|
+
*/
|
|
86
|
+
toolName?: string
|
|
87
|
+
/**
|
|
88
|
+
* 工具**入参摘要**(仅 `role:'tool'`;一行纯文本,如 `src/store/chat.ts`、
|
|
89
|
+
* `npm run build`、`在 src 中搜索 sessionError`)。
|
|
90
|
+
*
|
|
91
|
+
* 为什么需要一个字段:工具气泡只说「调了 read_file」,用户并不知道**看的是哪个文件**;
|
|
92
|
+
* 桌面端这条信息来自 `toolCalls[].input`(28 个 `getShortText()` 组件各挑各的主参数)。
|
|
93
|
+
* 手机是第二个屏幕,同样需要「这一步在干什么」。
|
|
94
|
+
*
|
|
95
|
+
* ⚠️ **不是原始入参**:`write_file.content` / `edit_file.edits[].old_string` 可能是整篇文章,
|
|
96
|
+
* 原样下行就是流量事故(§7-⑦)。电脑侧只挑关键入参、格式化并截断
|
|
97
|
+
* (`summarizeToolArgs`,与演示宿主共用同一份实现)。
|
|
98
|
+
*
|
|
99
|
+
* 消费方注意:拿不到(旧电脑端 / 跨页工具调用)时字段缺席 ——
|
|
100
|
+
* 此时**只显示工具名**,不要猜、也不要用正文反推。
|
|
101
|
+
*/
|
|
102
|
+
toolArgs?: string
|
|
103
|
+
/**
|
|
104
|
+
* 工具**入参详情**(仅 `role:'tool'`;多行,展开工具卡片后才渲染)。
|
|
105
|
+
*
|
|
106
|
+
* 为什么与 `toolArgs` 并存:那一行摘要只挑**主参数**(且硬上限 160 字符),真机反馈是
|
|
107
|
+
* 「入参显示不完整」—— 用户点开卡片想知道「刚才那行没显示完的是什么」。这里是入参本身
|
|
108
|
+
* (两空格缩进的 JSON,与桌面导出的同一种形态),路径与 `toolArgs` 走**同一个**缩短回调。
|
|
109
|
+
*
|
|
110
|
+
* ⚠️ **它比 `toolArgs` 重得多**(`write_file.content` 会原样出现,只是被 `TOOL_DETAIL_MAX`
|
|
111
|
+
* 兜住),所以纪律也反了一面:`toolArgs` 是「只给摘要、不给原文」,这里是「用户主动点开
|
|
112
|
+
* 才渲染的现场」。消费方**不得**把它放进折叠态(那会把流量事故从电脑搬到手机屏幕上)。
|
|
113
|
+
*
|
|
114
|
+
* 超长时同样是**中间省略**(标记行写在正文里)。字段缺席 = 没有可显示的入参
|
|
115
|
+
* (旧电脑端 / 跨页工具调用 / 入参为空)→ 展开区不渲染这一块,不要凭空造。
|
|
116
|
+
*/
|
|
117
|
+
toolArgsFull?: string
|
|
118
|
+
/**
|
|
119
|
+
* 本步是否**失败**(仅 `role:'tool'` 有意义;可选)。
|
|
120
|
+
*
|
|
121
|
+
* 语义(电脑侧权威,与桌面工具卡片 `result.isError` 同一判据):引擎把工具执行结果落成
|
|
122
|
+
* `role:'tool'` 消息时带的错误标记 —— Rust 侧「成功」与「退出码 >= 2 等工具级失败」都回
|
|
123
|
+
* Ok,靠这个布尔区分(见 `virlen-app` 的 `tools/execute/common.ts`、`message-repair.ts`)。
|
|
124
|
+
*
|
|
125
|
+
* ⚠️ **不能用正文反推**:失败结果常常也是一段正常文本(报错回显),成功输出里也可能包含
|
|
126
|
+
* 形似错误的字样。这个字段才是权威判据。字段缺席 = 没有失败标记(成功 / 旧电脑端未下发)——
|
|
127
|
+
* 消费方据此显示 ✓;只有 `true` 才显示 ✗。**不得**把缺席当成「结果未知」而什么都不显示。
|
|
128
|
+
*
|
|
129
|
+
* 消费方注意:手机端据此在工具卡片 / 工具组头部打 ✓/✗ 标志(`MessageList`)。
|
|
130
|
+
*/
|
|
131
|
+
isError?: boolean
|
|
132
|
+
/**
|
|
133
|
+
* 本条消息引用了哪些历史消息(快照,**可选**;只有带引用的用户消息才有)。
|
|
134
|
+
*
|
|
135
|
+
* 为什么结构化下行而不展平进 `text`:桌面端把它渲染成气泡上方的引用条(`QuoteChip`),
|
|
136
|
+
* 并可点击跳回原消息 —— 展平成一串文本就再也拆不回来了(`[引用] …` 只是个前缀,
|
|
137
|
+
* 正文里本来就可能有同样的字样)。
|
|
138
|
+
*
|
|
139
|
+
* 消费方注意:`quotes[].text` 是**快照**,可能很长(引用一整段 AI 回答)—— 引用条应**截断显示**,
|
|
140
|
+
* 发给模型的是完整快照(与桌面一致,不要在协议层擅自裁剪用户要引用的内容)。
|
|
141
|
+
*/
|
|
142
|
+
quotes?: MessageQuote[]
|
|
143
|
+
/**
|
|
144
|
+
* 本条消息引用了电脑上的哪些文件(**可选**;只有带文件引用的用户消息才有)。
|
|
145
|
+
*
|
|
146
|
+
* 语义与 `quotes` 并列但**不同源**:删除引用是**快照**(存正文,原消息没了也还在),
|
|
147
|
+
* 这里是**路径**(存位置,AI 读的那一刻磁盘上是什么就是什么)。与桌面输入框的文件附件
|
|
148
|
+
* 同一条口径:不搬运文件内容,只把「用户附了这个文件」告诉模型(`[User attached file] <path>`),
|
|
149
|
+
* 具体内容由模型用 `read_file` 按需读取。
|
|
150
|
+
*
|
|
151
|
+
* 为什么结构化下行而不展平进 `text`:电脑侧投影正文时本就已经把文件块展平成
|
|
152
|
+
* `[文件] <名字>` 的占位符(§7-⑦,与图片同一套降级规则),而那个占位符**只有名字没有路径、
|
|
153
|
+
* 也没有体积**(同一目录下的两个 `index.ts` 长得一模一样)。手机端要显示「附了哪个文件」
|
|
154
|
+
* 就得靠这个结构化字段;两条路同时走会显示两遍(与 `quotes` 同一条纪律)。
|
|
155
|
+
*
|
|
156
|
+
* 消费方注意:`path` 是**电脑上的绝对路径**(与 `SessionSummaryDTO.workspace` / `host.file.list`
|
|
157
|
+
* 的 `absPath` 同一口径,手机上本来就看得见工作目录),不是可在手机上打开的东西 ——
|
|
158
|
+
* 手机上它只是一段可复制的文本。
|
|
159
|
+
*/
|
|
160
|
+
files?: MessageFileRef[]
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface RuntimeDTO {
|
|
164
|
+
working: boolean
|
|
165
|
+
paused?: boolean
|
|
166
|
+
error?: string
|
|
167
|
+
/** 是否正在压缩上下文(压缩是长任务,手机据此显示进度)。 */
|
|
168
|
+
compacting?: boolean
|
|
169
|
+
/**
|
|
170
|
+
* 正在生成的工具调用进度(引擎在**累积工具参数**期间推送)。
|
|
171
|
+
*
|
|
172
|
+
* 为什么需要:provider 在累积参数 JSON 期间不发任何事件(见 `docs/phone-control-bridge.md`
|
|
173
|
+
* §27),长参数(如 `write_file` 写一篇文章)意味着数秒到数十秒内上层看不到任何变化 ——
|
|
174
|
+
* 这个字段让界面能显示「正在写入 write_file · 1.2k 字符…」,而不是看起来卡死。
|
|
175
|
+
*
|
|
176
|
+
* 只带工具名与已累积字符数,**不含参数内容**(带宽 / 隐私 / 界面也不需要)。
|
|
177
|
+
*/
|
|
178
|
+
toolProgress?: { name: string; chars: number } | null
|
|
179
|
+
/**
|
|
180
|
+
* **正在执行中**的工具调用(引擎**已开始执行、结果还没回来**的那几个)。
|
|
181
|
+
*
|
|
182
|
+
* 为什么需要(`toolProgress` 管不到这一段):`toolProgress` 只在**累积参数**期间有值,
|
|
183
|
+
* 一旦工具开始执行就被清掉(那时的判据是「参数生成完了」)—— 于是从「开始执行」到
|
|
184
|
+
* 「结果消息到达」之间,电脑侧界面上有一张**呼吸点卡片**(桌面上由
|
|
185
|
+
* `message-bubble.tsx` 用「assistant 的 `toolCalls[]` 减去已有结果」推导出来),
|
|
186
|
+
* 而手机端此前**一点都看不到**:工具消息只在**执行完之后**才作为消息下行,
|
|
187
|
+
* 这段静默期手机上只剩「正在思考…」,看起来像卡死(真机反馈)。
|
|
188
|
+
*
|
|
189
|
+
* 语义(电脑侧权威,与桌面 pending 卡片**同一判据**):已声明、尚无同 `toolCallId`
|
|
190
|
+
* 结果消息的那些调用;仅 `working === true` 时可能有值,字段缺席 = 此刻没有执行中的工具。
|
|
191
|
+
*
|
|
192
|
+
* ⚠️ **不要把它当成「进度」来理解**:这里没有百分比、没有输出。`args` 是一行**摘要**
|
|
193
|
+
* (与 `MessageDTO.toolArgs` 同一格式化口径),**绝不含参数正文** —— 工具的执行输出走
|
|
194
|
+
* 结果消息(`role:'tool'`),运行中的实时输出目前不下行。
|
|
195
|
+
*/
|
|
196
|
+
runningTools?: RunningToolDTO[]
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** `RuntimeDTO.runningTools` 的一条:正在执行的一次工具调用。 */
|
|
200
|
+
export interface RunningToolDTO {
|
|
201
|
+
/**
|
|
202
|
+
* 本次调用的 id(与结果消息 `MessageDTO.toolCallId` 同一个 id)。
|
|
203
|
+
*
|
|
204
|
+
* 消费方可用它把「执行中」与「已到达的结果」对齐(例如结果到达后收掉那一行);
|
|
205
|
+
* 只按顺序对齐是不行的 —— 同一批可能有多个调用并行执行。
|
|
206
|
+
*/
|
|
207
|
+
toolCallId: string
|
|
208
|
+
/** 工具名(如 `read_file`)—— 手机端「这一步在干什么」的主语。 */
|
|
209
|
+
name: string
|
|
210
|
+
/**
|
|
211
|
+
* 入参**一行摘要**(与 `MessageDTO.toolArgs` 同一条格式化口径、同一个长度上限)。
|
|
212
|
+
*
|
|
213
|
+
* 拿不到主参数时字段缺席(旧电脑端不会发这个字段;新电脑端给不出摘要时也不发)——
|
|
214
|
+
* 此时只显示工具名,**不要用别的字段反推**。
|
|
215
|
+
*/
|
|
216
|
+
args?: string
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 授权分级(M4,见 docs/phone-control-bridge.md §16.2)。
|
|
221
|
+
*
|
|
222
|
+
* **分级只决定「手机批准前是否需二次确认」,不决定「谁能批」**(宽松档下手机可批全部)。
|
|
223
|
+
* 判定在电脑侧(`virlen-app/src/bridge/approval-policy.ts`),手机只消费结果。
|
|
224
|
+
*/
|
|
225
|
+
export type ApprovalTier = 'low' | 'high'
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* 一次待应答交互的完整投影(手机端据此渲染卡片)。
|
|
229
|
+
*
|
|
230
|
+
* ⚠️ **电脑侧的 `interactionId` 是唯一权威标识**:手机应答时必须原样回传,
|
|
231
|
+
* 电脑侧按 id 精确路由(见 `events/toolInteractEvent.ts` 文件头)。
|
|
232
|
+
*/
|
|
233
|
+
export interface InteractionDTO {
|
|
234
|
+
interactionId: string
|
|
235
|
+
/**
|
|
236
|
+
* 归属会话。
|
|
237
|
+
* **空字符串 = 会话未知** —— 终端内确认(`presentation:'terminal'`)的 `PendingConfirmInfo`
|
|
238
|
+
* 不带会话信息,手机端以「全局卡片」呈现(§16.4)。
|
|
239
|
+
*/
|
|
240
|
+
sessionId: string
|
|
241
|
+
toolCallId?: string
|
|
242
|
+
kind: 'choice' | 'authorization'
|
|
243
|
+
createdAt: number
|
|
244
|
+
/** 风险分级:`high` 时手机必须先展开原文 + 勾选确认,并在应答时带 `confirmed:true`。 */
|
|
245
|
+
tier: ApprovalTier
|
|
246
|
+
// ── kind='choice'(AI 提问,非安全边界)──
|
|
247
|
+
question?: string
|
|
248
|
+
options?: string[]
|
|
249
|
+
multi?: boolean
|
|
250
|
+
// ── kind='authorization' ──
|
|
251
|
+
/** 权限唯一 key(跨 TS / Rust 稳定契约),如 `terminal.normal.execute` */
|
|
252
|
+
permName?: string
|
|
253
|
+
title?: string
|
|
254
|
+
subTitle?: string
|
|
255
|
+
/** 正文:命令文本 / 脚本正文 */
|
|
256
|
+
desc?: string
|
|
257
|
+
/** 实际执行命令(当 `desc` 不是命令本身时提供,如脚本正文) */
|
|
258
|
+
command?: string
|
|
259
|
+
hint?: string
|
|
260
|
+
/** 风险等级(`safe` / `install` / `dangerous`,与 `classify.rs` 对齐) */
|
|
261
|
+
risk?: string
|
|
262
|
+
/** AI 申请不使用沙盒 */
|
|
263
|
+
sandboxBypass?: boolean
|
|
264
|
+
/**
|
|
265
|
+
* 呈现方式:
|
|
266
|
+
* - `modal`:桌面弹窗授权(默认)
|
|
267
|
+
* - `terminal`:**终端内确认** —— 手机只能「原样放行」或「拒绝」,**不能编辑命令**(§16.4)
|
|
268
|
+
*/
|
|
269
|
+
presentation?: 'modal' | 'terminal'
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* 一次交互的终态。
|
|
274
|
+
*
|
|
275
|
+
* 与电脑侧 `toolInteractEvent` 的 `InteractionOutcome`(`allow|reject|shelve`)**语义对齐但命名不同**:
|
|
276
|
+
* 线上统一用 `deny`(对手机而言「拒绝」比「reject」更一致),另加 `expired`(电脑侧已不再挂起)。
|
|
277
|
+
*/
|
|
278
|
+
export type InteractionOutcome = 'allow' | 'deny' | 'shelve' | 'expired'
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* 流式正文的发送方式(见 `host.event.message.stream`)。
|
|
282
|
+
*
|
|
283
|
+
* - `full`:每帧发**整段正文** —— 实现简单,但一条 n 字的消息会传 O(n²) 字节;
|
|
284
|
+
* - `delta`:一帧只发**新增后缀**(配 `offset` 对齐)—— O(n)。
|
|
285
|
+
*
|
|
286
|
+
* **由客户端在 `hello` 里声明偏好**(`HelloParams.streamMode`),服务端不猜:
|
|
287
|
+
* 没声明就按 `full` 发,否则旧客户端会把一帧增量当成全文渲染(正文错位)。
|
|
288
|
+
*/
|
|
289
|
+
export type StreamMode = 'full' | 'delta'
|
|
290
|
+
|
|
291
|
+
// ───────────────────────────── 方法表 ─────────────────────────────
|
|
292
|
+
|
|
293
|
+
export interface SendParams {
|
|
294
|
+
sessionId: string
|
|
295
|
+
text: string
|
|
296
|
+
/**
|
|
297
|
+
* 引用若干条历史消息(可选)。
|
|
298
|
+
*
|
|
299
|
+
* 电脑端把它组装成 `{type:'quote'}` 内容块(与桌面输入框的引用附件走**同一个**
|
|
300
|
+
* `buildUserContent`),因此下游(引擎 / 持久化 / 桌面渲染 / 导出)的语义与桌面完全一致:
|
|
301
|
+
* 模型看到的是结构化的引用块,桌面端显示引用条并可点击跳回原消息,引文是**快照**
|
|
302
|
+
* (原消息被删 / 被压缩也不影响)。
|
|
303
|
+
*
|
|
304
|
+
* ⚠️ **只在电脑端声明 `MESSAGE_QUOTE_CAPABILITY` 时才能发**:旧电脑端不认这个参数,
|
|
305
|
+
* RPC 会「成功」而引文被静默丢掉(用户会以为引用了,AI 却当没看见)。
|
|
306
|
+
* 另外这是 `session.send` 的一个参数,**不是新权限** —— 越权防线仍是 `session.send`。
|
|
307
|
+
*/
|
|
308
|
+
quotes?: MessageQuote[]
|
|
309
|
+
/**
|
|
310
|
+
* 引用电脑上的若干文件(可选)—— 手机端在文件面板里挑出来的那些。
|
|
311
|
+
*
|
|
312
|
+
* 电脑端把它组装成 `{type:'file'}` 内容块(与桌面输入框的文件附件走**同一个**
|
|
313
|
+
* `buildUserContent`),因此下游(引擎 / 持久化 / 桌面渲染 / 导出)的语义与桌面完全一致。
|
|
314
|
+
* **不搬运内容**:文件内容由 AI 用 `read_file` 按需读取(桌面 `FileAttachment` 同此口径)。
|
|
315
|
+
*
|
|
316
|
+
* ⚠️ **只在电脑端声明 `MESSAGE_FILE_CAPABILITY` 时才能发**(与 `quotes` 同一条教训):
|
|
317
|
+
* 旧电脑端不认这个字段,RPC 会「成功」而文件引用被静默丢掉。
|
|
318
|
+
* ⚠️ 形状非法 / 超过 `MESSAGE_FILE_MAX` 时电脑侧**拒整条**(`E_BAD_REQUEST`,不静默丢那一条)——
|
|
319
|
+
* 校验口径见 `message-files.ts::sanitizeFileRefs`(两端同一份)。
|
|
320
|
+
*/
|
|
321
|
+
files?: MessageFileRef[]
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export interface MsgPageParams {
|
|
325
|
+
sessionId: string
|
|
326
|
+
/**
|
|
327
|
+
* 更早一页的游标(M5)—— 原样回传上一次应答的 `MsgPageDTO.cursor`。
|
|
328
|
+
*
|
|
329
|
+
* ⚠️ 不传 = 取**尾部窗口**;传了 = 取该游标之前的一页。
|
|
330
|
+
* 不要把 `undefined` 与 `null` 混淆:电脑侧判据是 `!= null`。
|
|
331
|
+
*/
|
|
332
|
+
fromRowid?: number
|
|
333
|
+
/**
|
|
334
|
+
* 每页条数。
|
|
335
|
+
*
|
|
336
|
+
* ⚠️ **电脑侧实现目前忽略本字段**:分页窗口由电脑侧的加载窗口
|
|
337
|
+
* (`MESSAGE_PAGE_SIZE` = 60)决定 —— 展示窗口必须 ≡ 已加载窗口,否则游标会指向
|
|
338
|
+
* 窗口**之外**的消息,续页时漏掉中间消息(见 §20.2-A)。预留给二期(届时需同步调整游标语义)。
|
|
339
|
+
*/
|
|
340
|
+
limit?: number
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export interface MsgPageDTO {
|
|
344
|
+
messages: MessageDTO[]
|
|
345
|
+
hasMore: boolean
|
|
346
|
+
/**
|
|
347
|
+
* 下一页(更早)游标(M5)。
|
|
348
|
+
*
|
|
349
|
+
* **不透明**:手机端原样回传给 `MsgPageParams.fromRowid` 即可,**不要解析其数值语义**
|
|
350
|
+
* (电脑侧实现是 rowid,但那是实现细节)。
|
|
351
|
+
* `hasMore=false` 时为 `null`。
|
|
352
|
+
*/
|
|
353
|
+
cursor?: number | null
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export interface AnswerParams {
|
|
357
|
+
interactionId: string
|
|
358
|
+
action: AnswerAction
|
|
359
|
+
/**
|
|
360
|
+
* `action='choose'` 时的选择结果:`{ selected?: string[]; customReply?: string }`。
|
|
361
|
+
* 电脑侧会按与桌面 UI **完全相同的形态**构造给引擎的 `ToolResult`(含 `uiData`)。
|
|
362
|
+
*/
|
|
363
|
+
value?: unknown
|
|
364
|
+
/**
|
|
365
|
+
* 高风险(`tier='high'`)的**批准**必须为 `true`。
|
|
366
|
+
* ⚠️ 电脑侧**独立校验**:手机 UI 漏改不算数(缺则 `E_CONFIRM_REQUIRED`)—— §16.2。
|
|
367
|
+
*/
|
|
368
|
+
confirmed?: boolean
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** 手机一次应答的四种动作。 */
|
|
372
|
+
export type AnswerAction = 'allow' | 'deny' | 'shelve' | 'choose'
|
|
373
|
+
|
|
374
|
+
// ── §22:模型 / 工作目录 / 上下文(手机端可看可切的部分)──
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* 「100%」对应的上下文窗口默认值(电脑侧设置里可改;CLI 读同一份设置)。
|
|
378
|
+
*
|
|
379
|
+
* 放在协议层是因为**两端要用同一个数**:电脑侧算 `ContextInfoDTO.windowTokens`,
|
|
380
|
+
* 手机侧算占用比例,mock 宿主也用它。各端各写一份必然会分叉。
|
|
381
|
+
*/
|
|
382
|
+
export const DEFAULT_CONTEXT_WINDOW_TOKENS = 200_000
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* 压缩上下文的最小占用比例。
|
|
386
|
+
*
|
|
387
|
+
* 低于它:桌面 token 环只提示「无需压缩」,电脑侧 `host.session.compress` 直接拒
|
|
388
|
+
* (`E_BAD_REQUEST`),手机端也不显示压缩按钮 —— **同一条判据,不许两端各写一份**
|
|
389
|
+
* (§18.5 的教训:口径写两份的那次是「给 AI 的文案」)。
|
|
390
|
+
*/
|
|
391
|
+
export const COMPRESS_MIN_RATIO = 0.4
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* 一个**已启用**的模型服务及其可选模型(`host.model.list`)。
|
|
395
|
+
*
|
|
396
|
+
* 白名单投影:只给 `id` / `name` / `models` —— **不含** `apiKey` / `baseUrl` / `params`
|
|
397
|
+
* (那些是电脑侧的机密与本地配置,§7-⑥)。
|
|
398
|
+
*/
|
|
399
|
+
export interface ModelProviderDTO {
|
|
400
|
+
id: string
|
|
401
|
+
name: string
|
|
402
|
+
models: string[]
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* 新建会话可选的工作目录(`host.workspace.list`)。
|
|
407
|
+
*
|
|
408
|
+
* ⚠️ **候选集由电脑侧给出,手机不得自造**(「不能让手机端创建没有过的目录」)。
|
|
409
|
+
* 电脑侧从既有数据里收集:各会话的 `workspace` + 各 Agent 的 `defaultWorkspace` + 全局默认工作目录。
|
|
410
|
+
* `host.session.create` 会**独立校验**回传的 `workspace` 是否属于该集合(§22.3)。
|
|
411
|
+
*/
|
|
412
|
+
export interface WorkspaceOptionDTO {
|
|
413
|
+
/** 归一化绝对路径(`/` 分隔);也是 `host.session.create` 回传的值。 */
|
|
414
|
+
path: string
|
|
415
|
+
/** 末级目录名(电脑侧算好,避免两端各写一份路径解析)。 */
|
|
416
|
+
name: string
|
|
417
|
+
/** 使用该目录的既有会话数(展示用)。 */
|
|
418
|
+
sessionCount: number
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* 新建会话可选的 Agent(`host.agent.list`)。
|
|
423
|
+
*
|
|
424
|
+
* 白名单投影:只给 `id` / `name` / 默认模型 / 默认工作目录 —— **不含** `systemPrompt`
|
|
425
|
+
* (可达数十 KB)、`allowTools` / `skills` / `params`(本地配置,§7-⑥)。
|
|
426
|
+
*
|
|
427
|
+
* 默认值**只用于展示**(让手机端把「选它会发生什么」说清楚,并在切换 Agent 时联动显示);
|
|
428
|
+
* 真正的会话组装仍在电脑侧 `createSession` 里做 —— 手机端传不传模型 / 目录都不影响结果。
|
|
429
|
+
*/
|
|
430
|
+
export interface AgentOptionDTO {
|
|
431
|
+
id: string
|
|
432
|
+
/** Agent 显示名(电脑侧已解析,手机不查表)。 */
|
|
433
|
+
name: string
|
|
434
|
+
/** Agent 的默认模型;未配置则缺省(不显示)。 */
|
|
435
|
+
defaultModel?: { providerConfigId: string; modelId: string }
|
|
436
|
+
/** Agent 的默认工作目录(归一化绝对路径);未配置则缺省。 */
|
|
437
|
+
defaultWorkspace?: string
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* 上下文占用(`host.session.context` / `host.event.session.context.changed`)。
|
|
442
|
+
*
|
|
443
|
+
* 口径与桌面 token 环**完全一致**(`domain/usage/context-occupancy.ts`,单一真源):
|
|
444
|
+
* 取最后一条带 `uiData.contextTokens`(压缩产物)或 `usage.totalTokens` 的消息。
|
|
445
|
+
*/
|
|
446
|
+
export interface ContextInfoDTO {
|
|
447
|
+
/** 当前上下文占用(token);无法判定(消息未加载 / 无用量)时为 `null`。 */
|
|
448
|
+
tokens: number | null
|
|
449
|
+
/** 「100%」对应的上下文窗口大小(来自电脑侧设置,CLI 读同一份)。 */
|
|
450
|
+
windowTokens: number
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export interface SetModelParams {
|
|
454
|
+
sessionId: string
|
|
455
|
+
providerConfigId: string
|
|
456
|
+
modelId: string
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export interface ContextParams {
|
|
460
|
+
sessionId: string
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export interface CompressParams {
|
|
464
|
+
sessionId: string
|
|
465
|
+
/**
|
|
466
|
+
* 压缩方式(缺省 = 电脑侧设置里的 `contextCompressMode`)。
|
|
467
|
+
*
|
|
468
|
+
* ⚠️ **只在电脑端声明 `COMPRESS_MODE_CAPABILITY` 时才能传**:旧电脑端不认这个字段,
|
|
469
|
+
* RPC 照样成功 —— 但它会按电脑侧设置的方式压缩(见 `compress.ts` 头注释)。
|
|
470
|
+
*/
|
|
471
|
+
mode?: CompressMode
|
|
472
|
+
/**
|
|
473
|
+
* 必须为 `true`。
|
|
474
|
+
*
|
|
475
|
+
* 压缩会**用摘要替换整段历史**(不可逆,与删除会话同档)——§16.3-3 要求手机端二次确认,
|
|
476
|
+
* 电脑侧独立校验,缺则 `E_CONFIRM_REQUIRED`。
|
|
477
|
+
*/
|
|
478
|
+
confirm: true
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** 交互已被处理 / 手机应答被拒的原因(UI 据此提示,不弹错误)。 */
|
|
482
|
+
export type AnswerRejectReason =
|
|
483
|
+
| 'not-found'
|
|
484
|
+
| 'already-settled'
|
|
485
|
+
| 'confirm-required'
|
|
486
|
+
| 'invalid-value'
|
|
487
|
+
| 'unsupported-by-host'
|
|
488
|
+
|
|
489
|
+
export interface AnswerResult {
|
|
490
|
+
accepted: boolean
|
|
491
|
+
reason?: AnswerRejectReason
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ── §37:工作目录文件(手机端浏览 / 预览 / 下载 / 上传)──
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* 目录里的一条(`host.file.list`)。
|
|
498
|
+
*
|
|
499
|
+
* 白名单投影:**只有名字、是否目录、大小、修改时刻** —— 不给绝对路径(那要拼接才有意义,
|
|
500
|
+
* 而拼接口径放在电脑侧由 `FileListResult` 统一给)、不给权限位、不给 inode 这类本机细节。
|
|
501
|
+
*/
|
|
502
|
+
export interface FileEntryDTO {
|
|
503
|
+
name: string
|
|
504
|
+
isDir: boolean
|
|
505
|
+
/** 字节数(目录恒为 0)。 */
|
|
506
|
+
size: number
|
|
507
|
+
/** 修改时刻(ms 时间戳);拿不到时字段缺席(手机端不显示而不是显示 1970)。 */
|
|
508
|
+
mtimeMs?: number
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export interface FileListParams {
|
|
512
|
+
sessionId: string
|
|
513
|
+
/**
|
|
514
|
+
* **相对**会话工作目录的路径(`'src/store'`);缺省 / 空串 = 工作目录根。
|
|
515
|
+
*
|
|
516
|
+
* ⚠️ 手机端**不得**传绝对路径、也不得传 `..` —— 电脑侧会用 `normalizeRelPath` 规整,
|
|
517
|
+
* 逃出工作目录的段一律被丢掉;真正的拒绝发生在 `resolveSafePath`(工作目录 + 黑白名单)。
|
|
518
|
+
*/
|
|
519
|
+
path?: string
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export interface FileListResult {
|
|
523
|
+
/** 规整后的相对路径(`''` = 工作目录根)—— 手机端原样用于后续请求。 */
|
|
524
|
+
relPath: string
|
|
525
|
+
/** 绝对路径(电脑侧算好,手机端只用于显示「你在看电脑上的哪个目录」)。 */
|
|
526
|
+
absPath: string
|
|
527
|
+
entries: FileEntryDTO[]
|
|
528
|
+
/** 条目超过 `FILE_LIST_MAX_ENTRIES` 而截断(手机端如实提示,不假装目录就这么大)。 */
|
|
529
|
+
truncated?: boolean
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
export interface FileReadParams {
|
|
533
|
+
sessionId: string
|
|
534
|
+
/** 相对会话工作目录的文件路径。 */
|
|
535
|
+
path: string
|
|
536
|
+
/** 起始字节偏移(缺省 0)—— 分块下载 / 预览都靠它。 */
|
|
537
|
+
offset?: number
|
|
538
|
+
/** 期望长度(字节);电脑侧按 `FILE_CHUNK_BYTES` 再夹一次。 */
|
|
539
|
+
length?: number
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export interface FileReadResult {
|
|
543
|
+
/** 本块字节的 **base64**(无 `data:` 前缀)。 */
|
|
544
|
+
data: string
|
|
545
|
+
/** 本块在文件中的起始偏移(与请求一致;电脑侧夹过长度后仍是原值)。 */
|
|
546
|
+
offset: number
|
|
547
|
+
/** 文件总大小(字节)—— 首块即给全,手机端因此能先算进度再决定要不要继续。 */
|
|
548
|
+
size: number
|
|
549
|
+
/** 本块是否已到文件末尾(`true` = 拼完就完整)。 */
|
|
550
|
+
eof: boolean
|
|
551
|
+
/**
|
|
552
|
+
* 预览分类(**电脑侧算,手机端不猜**)。
|
|
553
|
+
*
|
|
554
|
+
* 手机端据此选渲染器;`binary` = 只能下载。两边跑的是共享包里同一个
|
|
555
|
+
* `previewKindOf`,所以正常情况下永远一致 —— 把它放进协议是为了让**将来变规则时**
|
|
556
|
+
* 旧手机端仍按电脑的意思渲染,而不是按自己那份过期的表。
|
|
557
|
+
*/
|
|
558
|
+
kind: FilePreviewKind
|
|
559
|
+
/** MIME(手机端给 Blob / `<img>` 用)。 */
|
|
560
|
+
mime: string
|
|
561
|
+
/**
|
|
562
|
+
* 文件当前的修改时刻(ms 时间戳;拿不到时字段缺席)。
|
|
563
|
+
*
|
|
564
|
+
* 它是**编辑保存的第一步**:手机端打开文件时记下它,保存时原样回传(`expectMtimeMs`),
|
|
565
|
+
* 电脑侧比对不一致就拒 —— 否则手机上改的是「几分钟前的那一版」,一保存就把电脑上
|
|
566
|
+
* (AI / 用户 / 编辑器)刚写进去的内容**静默吞掉**。
|
|
567
|
+
*/
|
|
568
|
+
mtimeMs?: number
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** 上传时同名冲突怎么办。 */
|
|
572
|
+
export type FileConflictPolicy =
|
|
573
|
+
/** 自动加「 - 副本」(默认;与桌面侧文件操作同一条口径)。 */
|
|
574
|
+
| 'rename'
|
|
575
|
+
/** 直接拒(`E_CONFLICT`),让用户在手机上自己改名。 */
|
|
576
|
+
| 'reject'
|
|
577
|
+
|
|
578
|
+
export interface FileWriteBeginParams {
|
|
579
|
+
sessionId: string
|
|
580
|
+
/** 目标目录(**相对**工作目录;缺省 = 根)。 */
|
|
581
|
+
dir?: string
|
|
582
|
+
/** 目标文件名(不含路径)。 */
|
|
583
|
+
name: string
|
|
584
|
+
/** 文件总大小(字节)—— 电脑侧据此在**开始前**就拒掉超限的上传。 */
|
|
585
|
+
size: number
|
|
586
|
+
/** 缺省 `'rename'`。 */
|
|
587
|
+
onConflict?: FileConflictPolicy
|
|
588
|
+
/**
|
|
589
|
+
* **覆写已存在的文件**(编辑保存走这条路;缺省 = 上传新建)。
|
|
590
|
+
*
|
|
591
|
+
* ⚠️ 三处语义分叉,都靠它切换:
|
|
592
|
+
* 1. 目标**必须已存在**(不存在 = 路径错了 → `E_NOT_FOUND`,**不新建** —— 否则手机端一个
|
|
593
|
+
* 路径笔误会在用户目录里凭空造出一个文件);
|
|
594
|
+
* 2. **不做同名改名**:`finish` 原样覆写目标,不回「 - 副本」(那是上传才需要的口径);
|
|
595
|
+
* 3. 需要 `FILE_EDIT_CAPABILITY`,并把 `expectMtimeMs` / `expectSize` 一起校验。
|
|
596
|
+
*/
|
|
597
|
+
overwrite?: boolean
|
|
598
|
+
/**
|
|
599
|
+
* 打开这份文件时电脑侧给的 `mtimeMs`(覆写时校验;不一致 = 电脑上已经变了 → `E_CONFLICT`)。
|
|
600
|
+
*
|
|
601
|
+
* 电脑侧**拿不到 mtime 时按冲突处理**(宁可让用户重新载入,也不拿一份可能已经过期的内容
|
|
602
|
+
* 去覆盖别人的改动)。
|
|
603
|
+
*/
|
|
604
|
+
expectMtimeMs?: number
|
|
605
|
+
/** 打开这份文件时的大小(覆写时校验;与 `expectMtimeMs` 一起用,任一不符即拒)。 */
|
|
606
|
+
expectSize?: number
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
export interface FileWriteBeginResult {
|
|
610
|
+
/** 本次上传的票据(后续 `chunk` / `finish` / `abort` 都带着它)。 */
|
|
611
|
+
uploadId: string
|
|
612
|
+
/** 冲突消解后的**最终**文件名 —— 手机端据此显示「已存为 a - 副本.txt」。 */
|
|
613
|
+
name: string
|
|
614
|
+
/** 最终文件的相对路径(相对工作目录)。 */
|
|
615
|
+
relPath: string
|
|
616
|
+
/** 已接收的字节数(恒 0;预留给将来的续传)。 */
|
|
617
|
+
received: number
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
export interface FileWriteChunkParams {
|
|
621
|
+
uploadId: string
|
|
622
|
+
/** 本块在文件中的起始偏移(必须等于电脑侧已接收的字节数 —— 乱序即拒)。 */
|
|
623
|
+
offset: number
|
|
624
|
+
/** **base64** 的本块字节。 */
|
|
625
|
+
data: string
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
export interface FileWriteChunkResult {
|
|
629
|
+
/** 电脑侧累计已接收的字节数(手机端据此显示进度)。 */
|
|
630
|
+
received: number
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
export interface FileWriteFinishParams {
|
|
634
|
+
uploadId: string
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
export interface FileWriteFinishResult {
|
|
638
|
+
/** 最终文件名(与 `begin` 应答一致,便于手机端不依赖本地状态显示结果)。 */
|
|
639
|
+
name: string
|
|
640
|
+
relPath: string
|
|
641
|
+
/** 落盘后的实际字节数(与申报的 `size` 不一致时以它为准)。 */
|
|
642
|
+
size: number
|
|
643
|
+
/**
|
|
644
|
+
* 落盘后的修改时刻 —— 覆写保存的回执。
|
|
645
|
+
*
|
|
646
|
+
* 为何要回这一个字段:手机端要能**连改两次**。第一次保存后本地记的 mtime 已经过期,
|
|
647
|
+
* 拿它去校验第二次保存必然「冲突」;覆写时电脑侧因此回一个当前值。
|
|
648
|
+
*/
|
|
649
|
+
mtimeMs?: number
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
export interface FileWriteAbortParams {
|
|
653
|
+
uploadId: string
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ── M4 写操作参数 ──
|
|
657
|
+
|
|
658
|
+
export interface CreateSessionParams {
|
|
659
|
+
/** 不传则用电脑端的默认命名 */
|
|
660
|
+
title?: string
|
|
661
|
+
/**
|
|
662
|
+
* 工作目录(**必须来自 `host.workspace.list`**;电脑侧独立校验,越权即 `E_BAD_REQUEST`)。
|
|
663
|
+
*
|
|
664
|
+
* 不传 = 沿用 Agent 的 `defaultWorkspace`(电脑侧行为)。
|
|
665
|
+
*/
|
|
666
|
+
workspace?: string
|
|
667
|
+
/**
|
|
668
|
+
* 模型服务与模型 id;不传 = 沿用**所选 Agent** 的默认模型(未选 Agent 即默认 Agent 的)。
|
|
669
|
+
*/
|
|
670
|
+
providerConfigId?: string
|
|
671
|
+
modelId?: string
|
|
672
|
+
/**
|
|
673
|
+
* 归属 Agent(**必须来自 `host.agent.list`**;电脑侧独立校验,未知 id 即 `E_BAD_REQUEST`)。
|
|
674
|
+
*
|
|
675
|
+
* 不传 = 电脑侧的默认 Agent(与桌面「直接新建」的同构行为)。归属 Agent 决定
|
|
676
|
+
* systemPrompt / 工具白名单 / skills / 默认参数,因此电脑侧接受这条字段受
|
|
677
|
+
* `session.agent` 权限约束(见 `SESSION_AGENT_CAPABILITY`)。
|
|
678
|
+
*/
|
|
679
|
+
agentId?: string
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
export interface RenameSessionParams {
|
|
683
|
+
sessionId: string
|
|
684
|
+
title: string
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
export interface PinSessionParams {
|
|
688
|
+
sessionId: string
|
|
689
|
+
pinned: boolean
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
export interface DeleteSessionParams {
|
|
693
|
+
sessionId: string
|
|
694
|
+
/**
|
|
695
|
+
* 必须为 `true`。会话删除不可逆(消息级联删除)—— §16.3-3 要求手机端二次确认,
|
|
696
|
+
* 电脑侧独立校验,缺则 `E_CONFIRM_REQUIRED`。
|
|
697
|
+
*/
|
|
698
|
+
confirm: true
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* 删除单条消息**及其之后的全部消息**(截断)—— 与桌面右键菜单的「删除」同一条路径
|
|
703
|
+
* (`deleteSessionMessage`),**不是**「把这一条从中间抽走」。
|
|
704
|
+
*
|
|
705
|
+
* 为什么是截断而不是单删:一条消息的历史是**因果链** —— 抽走中间一条用户提问,
|
|
706
|
+
* 后面 AI 的回答就失去了提问;抽走一条带 `tool_calls` 的 assistant 消息,后面的
|
|
707
|
+
* 工具结果就成了孤儿(引擎侧要额外做悬空修补)。桌面端已经用「本条及后续」表达了这个
|
|
708
|
+
* 事实,手机端**不得**另立一套更「温和」的语义 —— 那只会让两个入口对同一次操作给出
|
|
709
|
+
* 不同的后果(用户删了中间一条,电脑上的历史却不一样)。
|
|
710
|
+
*
|
|
711
|
+
* 角色限制:`role:'tool'` 的消息**不可删**(工具结果与发起它的 assistant 消息是一体两面,
|
|
712
|
+
* 单独删掉只会留下悬空调用)—— 电脑侧拒绝,手机端也不提供入口。
|
|
713
|
+
*/
|
|
714
|
+
export interface DeleteMessageParams {
|
|
715
|
+
sessionId: string
|
|
716
|
+
messageId: string
|
|
717
|
+
/**
|
|
718
|
+
* 必须为 `true`。截断不可逆 —— §16.3-3 要求手机端二次确认,电脑侧独立校验,
|
|
719
|
+
* 缺则 `E_CONFIRM_REQUIRED`(手机端 UI 的确认弹窗不算数)。
|
|
720
|
+
*/
|
|
721
|
+
confirm: true
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** 电脑实现、手机调用。 */
|
|
725
|
+
export interface HostApi {
|
|
726
|
+
'host.hello': { params: import('./hello').HelloParams; result: import('./hello').HelloResult }
|
|
727
|
+
'host.session.list': { params: Record<string, never>; result: { sessions: SessionSummaryDTO[] } }
|
|
728
|
+
'host.session.messages': { params: MsgPageParams; result: MsgPageDTO }
|
|
729
|
+
/** delta 模式对齐用(§3.6):跳号时拉全文。 */
|
|
730
|
+
'host.session.message.get': { params: { sessionId: string; messageId: string }; result: { message: MessageDTO } }
|
|
731
|
+
'host.session.send': { params: SendParams; result: { messageId: string } }
|
|
732
|
+
'host.session.cancel': { params: { sessionId: string }; result: { ok: true } }
|
|
733
|
+
/**
|
|
734
|
+
* 从暂停的 run 快照恢复执行(M5,与手机端「暂存」配对)。
|
|
735
|
+
*
|
|
736
|
+
* 电脑侧落点为唯一恢复入口 `resumePausedRun`;会话未处于暂停态时电脑侧报 `E_BAD_REQUEST`。
|
|
737
|
+
*/
|
|
738
|
+
'host.session.resume': { params: { sessionId: string }; result: { ok: true } }
|
|
739
|
+
'host.session.subscribe': { params: { sessionId: string; fromRowid?: number }; result: { ok: true } }
|
|
740
|
+
// ── M4 写操作(§16.1)──
|
|
741
|
+
'host.session.create': { params: CreateSessionParams; result: { sessionId: string } }
|
|
742
|
+
'host.session.rename': { params: RenameSessionParams; result: { ok: true } }
|
|
743
|
+
'host.session.pin': { params: PinSessionParams; result: { ok: true } }
|
|
744
|
+
/** ⚠️ 不可逆:必须带 `confirm: true`(缺则 `E_CONFIRM_REQUIRED`)。 */
|
|
745
|
+
'host.session.delete': { params: DeleteSessionParams; result: { ok: true } }
|
|
746
|
+
/**
|
|
747
|
+
* 删除单条消息及其之后的全部消息(截断,不可逆)。
|
|
748
|
+
*
|
|
749
|
+
* 与 `host.session.message.get` 同属「消息级」方法。结果**不**在本应答里回传快照:
|
|
750
|
+
* 电脑侧会推 `host.event.session.messages.reset`,手机端据此重拉窗口(§3.5 的同一策略——
|
|
751
|
+
* 不发明增量协议,让「重拉快照」这个天然幂等的动作承担一致性)。
|
|
752
|
+
*
|
|
753
|
+
* 电脑侧还会拦两种情况:会话正在回复(`E_BUSY`,与 `send` / `compress` 同一条并发纪律)、
|
|
754
|
+
* 目标是 `role:'tool'` 的消息(`E_BAD_REQUEST`)。
|
|
755
|
+
*/
|
|
756
|
+
'host.session.message.delete': { params: DeleteMessageParams; result: { ok: true } }
|
|
757
|
+
'host.interaction.answer': { params: AnswerParams; result: AnswerResult }
|
|
758
|
+
/**
|
|
759
|
+
* 当前待应答交互(**拉取式**,M4)。
|
|
760
|
+
*
|
|
761
|
+
* 为什么必需:`host.event.interaction.requested` 是**一次性事件** ——
|
|
762
|
+
* 手机在交互发起之后才连上/重连时,事件已错过,它只看到「会话卡在 working」而不知为何。
|
|
763
|
+
* 手机在每次链路就绪后拉一次本方法,即可补齐(权威源是电脑侧注册表)。
|
|
764
|
+
*/
|
|
765
|
+
'host.interaction.list': { params: Record<string, never>; result: { interactions: InteractionDTO[] } }
|
|
766
|
+
// ── §22:模型 / 工作目录 / 上下文 ──
|
|
767
|
+
/** 已启用的模型服务与模型(手机端模型选择器)。 */
|
|
768
|
+
'host.model.list': { params: Record<string, never>; result: { providers: ModelProviderDTO[] } }
|
|
769
|
+
/**
|
|
770
|
+
* 切换会话使用的模型(**对已有会话**;桌面 model-switcher 的等价操作)。
|
|
771
|
+
*
|
|
772
|
+
* 与桌面同构:不刷新 `updatedAt`(会话时间只由用户发消息刷新),下一轮生效。
|
|
773
|
+
*/
|
|
774
|
+
'host.session.setModel': { params: SetModelParams; result: { ok: true } }
|
|
775
|
+
/**
|
|
776
|
+
* 新建会话可选的 Agent 候选集(手机端 Agent 选择器)。
|
|
777
|
+
*
|
|
778
|
+
* 与 `host.model.list` / `host.workspace.list` 同族,同样**只用于新建会话**:
|
|
779
|
+
* 已有会话的 Agent 不可改 —— 换了 Agent 就是换 systemPrompt / 工具白名单 / skills,
|
|
780
|
+
* 历史对话会前后错配(与工作目录不可改同一条理由,§22.3)。
|
|
781
|
+
*/
|
|
782
|
+
'host.agent.list': { params: Record<string, never>; result: { agents: AgentOptionDTO[] } }
|
|
783
|
+
/**
|
|
784
|
+
* 新建会话可选的工作目录候选集。
|
|
785
|
+
*
|
|
786
|
+
* ⚠️ 只用于**新建会话**;已有会话的工作目录不可改(用户 2026-09-28 拍板,§22.3)。
|
|
787
|
+
*/
|
|
788
|
+
'host.workspace.list': { params: Record<string, never>; result: { workspaces: WorkspaceOptionDTO[] } }
|
|
789
|
+
/** 上下文占用快照(拉取式,配合 `host.event.session.context.changed` 增量)。 */
|
|
790
|
+
'host.session.context': { params: ContextParams; result: ContextInfoDTO }
|
|
791
|
+
/**
|
|
792
|
+
* 压缩上下文(用摘要替换历史)。
|
|
793
|
+
*
|
|
794
|
+
* fire-and-forget:RPC 只回投递确认 —— 进度走 `runtime.compacting`,结果走
|
|
795
|
+
* `host.event.session.messages.reset`(手机重拉窗口)+ `message.added`(摘要消息)。
|
|
796
|
+
* ⚠️ 必须带 `confirm: true`;上下文充裕(低于 40%)时电脑侧直接拒(与桌面 token 环同判据)。
|
|
797
|
+
* 方式由 `mode` 指定(需电脑端声明 `COMPRESS_MODE_CAPABILITY`),不传则用电脑侧设置里的那一档。
|
|
798
|
+
*/
|
|
799
|
+
'host.session.compress': { params: CompressParams; result: { ok: true } }
|
|
800
|
+
// ── §37:工作目录文件(只读两档 + 写一档,能力名见 `files.ts`)──
|
|
801
|
+
/**
|
|
802
|
+
* 列目录(`file.browse`)。
|
|
803
|
+
*
|
|
804
|
+
* 候选集就是**会话自己的工作目录**(含子目录):越权防线是电脑侧的 `resolveSafePath`
|
|
805
|
+
* (工作目录 + 黑白名单),手机端传什么路径都不构成越权。
|
|
806
|
+
*/
|
|
807
|
+
'host.file.list': { params: FileListParams; result: FileListResult }
|
|
808
|
+
/**
|
|
809
|
+
* 读文件的一个分块(`file.download`)—— 预览与下载走**同一条**路径。
|
|
810
|
+
*
|
|
811
|
+
* 分块而不是整文件(见 `files.ts` 文件头):单次请求的内存上界固定,进度可见、可取消。
|
|
812
|
+
*/
|
|
813
|
+
'host.file.read': { params: FileReadParams; result: FileReadResult }
|
|
814
|
+
/**
|
|
815
|
+
* 上传 / **覆写**:开始(`file.upload`;`overwrite: true` 时走 `file.edit`)。
|
|
816
|
+
*
|
|
817
|
+
* 三道校验都在这里做完(大小上限 / 名字合法性 / 目标目录可写),**且冲突消解在开始时就定名**
|
|
818
|
+
* —— 于是「传到一半才发现同名」不会发生,手机端能在动手前就把最终名字告诉用户。
|
|
819
|
+
*
|
|
820
|
+
* 覆写(编辑保存)复用同一个方法,但语义分叉见 `FileWriteBeginParams.overwrite`:
|
|
821
|
+
* 目标必须已存在、不改名、并校验 `expectMtimeMs` / `expectSize`。
|
|
822
|
+
*/
|
|
823
|
+
'host.file.write.begin': { params: FileWriteBeginParams; result: FileWriteBeginResult }
|
|
824
|
+
/** 上传 / 覆写:写一块(偏移必须与电脑侧已接收字节数一致)。 */
|
|
825
|
+
'host.file.write.chunk': { params: FileWriteChunkParams; result: FileWriteChunkResult }
|
|
826
|
+
/** 上传 / 覆写:收尾(校验字节数 → 从临时文件 `rename` 落盘;覆写时**替换**目标,不改名)。 */
|
|
827
|
+
'host.file.write.finish': { params: FileWriteFinishParams; result: FileWriteFinishResult }
|
|
828
|
+
/** 上传 / 覆写:放弃(删临时文件;用户取消 / 出错都走它,避免留垃圾)。 */
|
|
829
|
+
'host.file.write.abort': { params: FileWriteAbortParams; result: { ok: true } }
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/** 手机实现、电脑调用。 */
|
|
833
|
+
export interface MobileApi {
|
|
834
|
+
'mobile.ping': { params: Record<string, never>; result: { ts: number } }
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// ───────────────────────────── 事件表 ─────────────────────────────
|
|
838
|
+
|
|
839
|
+
export interface HostEvents {
|
|
840
|
+
'host.event.session.list.changed': { sessions: SessionSummaryDTO[] }
|
|
841
|
+
/** 已定稿消息的变化 —— 完整对象,天然幂等。 */
|
|
842
|
+
'host.event.message.added': { sessionId: string; message: MessageDTO }
|
|
843
|
+
'host.event.message.updated': { sessionId: string; message: MessageDTO }
|
|
844
|
+
/**
|
|
845
|
+
* 「正在生成中的那一条消息」的流式同步 —— 与 message.* 分离的独立通道。
|
|
846
|
+
*
|
|
847
|
+
* **一期发 `mode='full'`(每帧整段),二期(2026-09-30,§32)起支持 `mode='delta'`。**
|
|
848
|
+
* 带宽差一个量级:一条 n 字的消息,整帧发是 O(n²) 字节(每帧都把已有全文再传一遍),
|
|
849
|
+
* 增量发是 O(n)。真机上「长回复越到后面越卡」的观感就来自前者。
|
|
850
|
+
*/
|
|
851
|
+
'host.event.message.stream': {
|
|
852
|
+
sessionId: string
|
|
853
|
+
messageId: string
|
|
854
|
+
/** 同一 `messageId` 内递增,从 1 开始;换消息重新从 1 算。 */
|
|
855
|
+
seq: number
|
|
856
|
+
/**
|
|
857
|
+
* `full` = `text` 是整段正文(客户端直接替换);
|
|
858
|
+
* `delta` = `text` 只是新增后缀(客户端按 `offset` 追加)。
|
|
859
|
+
*
|
|
860
|
+
* 什么时候是 `full`(三个时机,客户端不能假定「只有首帧是 full」):
|
|
861
|
+
* 1. 该消息的**首帧**(客户端没有任何基准);
|
|
862
|
+
* 2. 正文**被改写**(新正文不是旧正文的前缀,如定稿回填 / 修复);
|
|
863
|
+
* 3. `final=true` 的收尾帧(定稿全文)。
|
|
864
|
+
*/
|
|
865
|
+
mode: StreamMode
|
|
866
|
+
text: string
|
|
867
|
+
/**
|
|
868
|
+
* 仅 `mode='delta'`:本段 `text` 在整段正文中的**起始偏移**(= 发送前已发出的长度)。
|
|
869
|
+
*
|
|
870
|
+
* 为什么必须有它:客户端手上有多少正文**只有它自己知道** —— 中途订阅、
|
|
871
|
+
* `messages.reset` 后重拉、断线重连都会让它落后于服务端的发送基准。有了偏移,客户端能:
|
|
872
|
+
* - 把**完全重复**的段丢掉(`offset + text.length ≤ 本地长度`);
|
|
873
|
+
* - 把**部分重叠**的段按尾巴补上(本地长度 − `offset` 之后的那些字符);
|
|
874
|
+
* - 在**真缺一段**时(`offset > 本地长度`)发现缺口,调 `host.session.message.get` 拉全文对齐。
|
|
875
|
+
* 没有它就只能靠 seq 连续性猜,而猜错是**静默的正文错位**(比丢帧难查得多)。
|
|
876
|
+
*/
|
|
877
|
+
offset?: number
|
|
878
|
+
/** 收尾帧:`text` 为定稿全文(客户端渲染最终版本以随后的 `message.added` 为准)。 */
|
|
879
|
+
final: boolean
|
|
880
|
+
}
|
|
881
|
+
'host.event.session.runtime.changed': { sessionId: string; runtime: RuntimeDTO }
|
|
882
|
+
'host.event.interaction.requested': { interaction: InteractionDTO }
|
|
883
|
+
'host.event.interaction.resolved': {
|
|
884
|
+
interactionId: string
|
|
885
|
+
by: 'host' | 'mobile'
|
|
886
|
+
/** 终态;`expired` = 电脑侧已不再挂起该交互(如会话被取消) */
|
|
887
|
+
outcome?: InteractionOutcome
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* 上下文占用变化(§22)。
|
|
891
|
+
*
|
|
892
|
+
* 与 `interaction.requested` 同样是「增量 + 快照」组合里的增量:快照是
|
|
893
|
+
* `host.session.context`。只推**已订阅**会话(与消息推送同一订阅集合)。
|
|
894
|
+
*/
|
|
895
|
+
'host.event.session.context.changed': { sessionId: string; context: ContextInfoDTO }
|
|
896
|
+
/**
|
|
897
|
+
* 该会话的消息**被整体替换或删除**(压缩 / 删消息),本地增删 diff 已无法表达。
|
|
898
|
+
*
|
|
899
|
+
* 语义:手机**丢弃**该会话缓存的消息窗口并重拉(`host.session.messages`),
|
|
900
|
+
* 而不是逐条对账 —— 压缩会把整段历史换成一条 summary,逐条 diff 只会得到一份错位的历史。
|
|
901
|
+
* 为什么必需:此前删除的消息只是从电脑侧快照里消失(不发事件),手机端会一直显示幽灵消息。
|
|
902
|
+
*/
|
|
903
|
+
'host.event.session.messages.reset': { sessionId: string }
|
|
904
|
+
/**
|
|
905
|
+
* 本机(电脑)视角的链路通讯类型 —— 「这次连接到底是直连还是走了 TURN 中继」。
|
|
906
|
+
*
|
|
907
|
+
* 同一事实在本端也成立(手机端同样有一条所选候选对,可自行判定),本事件的用途是让手机
|
|
908
|
+
* **拿电脑视角交叉校验**:两端口径已收敛到共享包的 `classifyLinkKind`(见 `HostEvents` 之外
|
|
909
|
+
* 的 `virlen-remote` 导出),若仍不一致,说明有一端的 stats 读取出了问题。
|
|
910
|
+
*
|
|
911
|
+
* 只在结论**确定**(`direct` / `relay`)时发送:链路刚建立 / 正在重协商时电脑端算不出结论,
|
|
912
|
+
* 那属于「没有结论」,**不发事件**(而不是发一个猜测);`unknown` 不在本事件的取值域内。
|
|
913
|
+
*/
|
|
914
|
+
'host.event.connection.changed': {
|
|
915
|
+
path: 'direct' | 'relay'
|
|
916
|
+
/**
|
|
917
|
+
* ⚠️ **预留字段,当前不发送**(`virlen-remote@0.1.2` 起转为可选)。
|
|
918
|
+
*
|
|
919
|
+
* 语义(若将来启用):链路虽可用但已**降级**(例如被迫走中继、或质量明显下滑)。
|
|
920
|
+
* 当前实现只区分 `path`,没有任何独立可观测的「降级」判据,故先留空 —— 与其用一个尚未
|
|
921
|
+
* 定义的口径误导消费方,不如让它缺席。消费方**不得**用 `path === 'relay'` 反推它。
|
|
922
|
+
*/
|
|
923
|
+
degraded?: boolean
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
export interface MobileEvents {
|
|
928
|
+
'mobile.event.visibility': { state: 'foreground' | 'background'; at: number }
|
|
929
|
+
'mobile.event.viewing': { sessionId: string | null }
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// ───────────────────────────── 推导辅助 ─────────────────────────────
|
|
933
|
+
|
|
934
|
+
export type ParamsOf<A, K extends keyof A> = A[K] extends { params: infer P } ? P : never
|
|
935
|
+
export type ResultOf<A, K extends keyof A> = A[K] extends { result: infer R } ? R : never
|
|
936
|
+
|
|
937
|
+
/** 由方法表推导的类型安全客户端(手机侧用它调 `host.*`)。 */
|
|
938
|
+
export interface TypedCaller<A> {
|
|
939
|
+
call<K extends keyof A & string>(method: K, params: ParamsOf<A, K>, timeoutMs?: number): Promise<ResultOf<A, K>>
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/** 由方法表推导的 handler 实现类型(电脑侧用它实现 `host.*`,key 必须齐全)。 */
|
|
943
|
+
export type HandlerImpl<A> = {
|
|
944
|
+
[K in keyof A & string]: (params: ParamsOf<A, K>, ctx: CallContext) => ResultOf<A, K> | Promise<ResultOf<A, K>>
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/** 由事件表推导的订阅辅助。 */
|
|
948
|
+
export interface TypedSubscriber<E> {
|
|
949
|
+
subscribe<K extends keyof E & string>(topic: K, listener: (payload: E[K]) => void): () => void
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
export function createCaller<A>(endpoint: Endpoint): TypedCaller<A> {
|
|
953
|
+
return {
|
|
954
|
+
call<K extends keyof A & string>(method: K, params: ParamsOf<A, K>, timeoutMs?: number): Promise<ResultOf<A, K>> {
|
|
955
|
+
return endpoint.call(method, params, timeoutMs === undefined ? undefined : { timeoutMs }) as Promise<ResultOf<A, K>>
|
|
956
|
+
},
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
export function createSubscriber<E>(endpoint: Endpoint): TypedSubscriber<E> {
|
|
961
|
+
return {
|
|
962
|
+
subscribe<K extends keyof E & string>(topic: K, listener: (payload: E[K]) => void): () => void {
|
|
963
|
+
return endpoint.subscribe(topic, listener as (payload: unknown) => void)
|
|
964
|
+
},
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/** 把一份 handler 实现注册到 endpoint(key 必须齐全,返回值类型必须匹配)。 */
|
|
969
|
+
export function registerHandlers<A>(endpoint: Endpoint, impl: HandlerImpl<A>): void {
|
|
970
|
+
for (const method of Object.keys(impl)) {
|
|
971
|
+
const fn = impl[method as keyof A & string] as (params: unknown, ctx: CallContext) => unknown
|
|
972
|
+
endpoint.handle(method, (params, ctx) => Promise.resolve(fn(params, ctx)))
|
|
973
|
+
}
|
|
974
|
+
}
|