virlen-remote 0.4.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +362 -137
- package/README.md +389 -201
- package/dist/index.d.ts +12 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -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 +422 -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 +41 -1
- package/dist/protocol/host.d.ts.map +1 -1
- package/dist/protocol/host.js +10 -1
- package/dist/protocol/host.js.map +1 -1
- package/dist/protocol/message-actions.d.ts +58 -0
- package/dist/protocol/message-actions.d.ts.map +1 -0
- package/dist/protocol/message-actions.js +38 -0
- package/dist/protocol/message-actions.js.map +1 -0
- 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 +487 -8
- 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 -179
- package/src/protocol/agents.ts +13 -0
- package/src/protocol/api.ts +960 -553
- package/src/protocol/compress.ts +65 -0
- package/src/protocol/files.ts +544 -0
- package/src/protocol/host.ts +205 -150
- package/src/protocol/message-actions.ts +60 -0
- 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 -691
- 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/testing/mock-host.ts
CHANGED
|
@@ -1,691 +1,1285 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Mock 宿主数据源 —— 供「浏览器 harness」与单测使用。
|
|
3
|
-
*
|
|
4
|
-
* 行为:`send` 立即回投递确认(`messageId`),随后**异步**推送
|
|
5
|
-
* `message.added`(用户消息)→ `runtime.changed`(working)→ 多次 `message.stream`
|
|
6
|
-
* → `message.added`(AI 完整消息)+ `runtime.changed`(空闲)。
|
|
7
|
-
*
|
|
8
|
-
* 流式帧的形状**由客户端在 `hello` 里声明**(`streamMode`,默认 `full`):`delta` 时只发新增后缀
|
|
9
|
-
* (带 `offset`),规则与真实电脑侧(`store-bridge.ts` 的 `pushStream`)**逐条对齐**:
|
|
10
|
-
* 消息首帧 / 正文被改写 / `final` 收尾帧都发**整段**,只有「新正文以已发出内容为前缀」时才发增量。
|
|
11
|
-
* 两边不一致的后果很具体:mock 宽松 = 用例对着假行为发绿灯(§24 的教训)。
|
|
12
|
-
*
|
|
13
|
-
* 这正是 §3.3「RPC 只做投递确认、过程走事件」的可运行样例。
|
|
14
|
-
*
|
|
15
|
-
* ⚠️ **订阅门(§24,2026-09-29)**:消息 / 流式 / 运行时 / 占用这四类事件**只推已订阅的会话**
|
|
16
|
-
* (`host.event.session.list.changed` 与交互事件恒推)——与真实电脑侧(`store-bridge` 的
|
|
17
|
-
* `subscriptions.has(s.id)`)**同一条规则**。
|
|
18
|
-
*
|
|
19
|
-
* 为何必须一致:mock 过去无条件推送,于是「客户端忘了 `host.session.subscribe`」这类缺陷在
|
|
20
|
-
* 联调 / 单测里**完全看不见**,真机上才表现为「新建会话的标题更新了,但消息永远是空的」。
|
|
21
|
-
* mock 比真机宽松 = 测试对着假行为发绿灯,这条闸是故意的:宁可让用例早失败。
|
|
22
|
-
*/
|
|
23
|
-
import { BridgeError } from '../protocol/errors'
|
|
24
|
-
import { answerActionError, normalizeChoiceAnswer } from '../protocol/answer'
|
|
25
|
-
import { COMPRESS_MIN_RATIO, DEFAULT_CONTEXT_WINDOW_TOKENS } from '../protocol/api'
|
|
26
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Mock 宿主数据源 —— 供「浏览器 harness」与单测使用。
|
|
3
|
+
*
|
|
4
|
+
* 行为:`send` 立即回投递确认(`messageId`),随后**异步**推送
|
|
5
|
+
* `message.added`(用户消息)→ `runtime.changed`(working)→ 多次 `message.stream`
|
|
6
|
+
* → `message.added`(AI 完整消息)+ `runtime.changed`(空闲)。
|
|
7
|
+
*
|
|
8
|
+
* 流式帧的形状**由客户端在 `hello` 里声明**(`streamMode`,默认 `full`):`delta` 时只发新增后缀
|
|
9
|
+
* (带 `offset`),规则与真实电脑侧(`store-bridge.ts` 的 `pushStream`)**逐条对齐**:
|
|
10
|
+
* 消息首帧 / 正文被改写 / `final` 收尾帧都发**整段**,只有「新正文以已发出内容为前缀」时才发增量。
|
|
11
|
+
* 两边不一致的后果很具体:mock 宽松 = 用例对着假行为发绿灯(§24 的教训)。
|
|
12
|
+
*
|
|
13
|
+
* 这正是 §3.3「RPC 只做投递确认、过程走事件」的可运行样例。
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ **订阅门(§24,2026-09-29)**:消息 / 流式 / 运行时 / 占用这四类事件**只推已订阅的会话**
|
|
16
|
+
* (`host.event.session.list.changed` 与交互事件恒推)——与真实电脑侧(`store-bridge` 的
|
|
17
|
+
* `subscriptions.has(s.id)`)**同一条规则**。
|
|
18
|
+
*
|
|
19
|
+
* 为何必须一致:mock 过去无条件推送,于是「客户端忘了 `host.session.subscribe`」这类缺陷在
|
|
20
|
+
* 联调 / 单测里**完全看不见**,真机上才表现为「新建会话的标题更新了,但消息永远是空的」。
|
|
21
|
+
* mock 比真机宽松 = 测试对着假行为发绿灯,这条闸是故意的:宁可让用例早失败。
|
|
22
|
+
*/
|
|
23
|
+
import { BridgeError } from '../protocol/errors'
|
|
24
|
+
import { answerActionError, normalizeChoiceAnswer } from '../protocol/answer'
|
|
25
|
+
import { COMPRESS_MIN_RATIO, DEFAULT_CONTEXT_WINDOW_TOKENS } from '../protocol/api'
|
|
26
|
+
import {
|
|
27
|
+
COMPRESS_MODE_CAPABILITY,
|
|
28
|
+
DEFAULT_COMPRESS_MODE,
|
|
29
|
+
compressModeOf,
|
|
30
|
+
type CompressMode,
|
|
31
|
+
} from '../protocol/compress'
|
|
32
|
+
import { MESSAGE_DELETE_CAPABILITY, MESSAGE_QUOTE_CAPABILITY } from '../protocol/message-actions'
|
|
33
|
+
import { MESSAGE_FILE_CAPABILITY, sanitizeFileRefs } from '../protocol/message-files'
|
|
34
|
+
import { SESSION_AGENT_CAPABILITY } from '../protocol/agents'
|
|
35
|
+
import { summarizeToolArgs, formatToolArgs } from '../protocol/tool-args'
|
|
36
|
+
import {
|
|
37
|
+
FILE_BROWSE_CAPABILITY,
|
|
38
|
+
FILE_CHUNK_BYTES,
|
|
39
|
+
FILE_DOWNLOAD_CAPABILITY,
|
|
40
|
+
FILE_EDIT_CAPABILITY,
|
|
41
|
+
FILE_EDIT_MAX_BYTES,
|
|
42
|
+
FILE_LIST_MAX_ENTRIES,
|
|
43
|
+
FILE_UPLOAD_CAPABILITY,
|
|
44
|
+
FILE_UPLOAD_MAX_BYTES,
|
|
45
|
+
base64ToBytes,
|
|
46
|
+
baseNameOfPath,
|
|
47
|
+
bytesToBase64,
|
|
48
|
+
compareFileEntries,
|
|
49
|
+
duplicateNameCandidate,
|
|
50
|
+
fileTransferDeniedReason,
|
|
51
|
+
formatFileSize,
|
|
52
|
+
isEditableFileName,
|
|
53
|
+
isSafeEntryName,
|
|
54
|
+
mimeTypeOf,
|
|
55
|
+
normalizeRelPath,
|
|
56
|
+
previewKindOf,
|
|
57
|
+
} from '../protocol/files'
|
|
58
|
+
import type { HostDataSource, HostEmit } from '../protocol/host'
|
|
59
|
+
import type { HostEvents, StreamMode } from '../protocol/api'
|
|
60
|
+
import type {
|
|
61
|
+
AgentOptionDTO,
|
|
62
|
+
AnswerParams,
|
|
63
|
+
AnswerResult,
|
|
64
|
+
ApprovalTier,
|
|
65
|
+
CompressParams,
|
|
66
|
+
ContextInfoDTO,
|
|
67
|
+
ContextParams,
|
|
68
|
+
CreateSessionParams,
|
|
69
|
+
DeleteMessageParams,
|
|
70
|
+
DeleteSessionParams,
|
|
71
|
+
FileEntryDTO,
|
|
72
|
+
FileListParams,
|
|
73
|
+
FileListResult,
|
|
74
|
+
FileReadParams,
|
|
75
|
+
FileReadResult,
|
|
76
|
+
FileWriteAbortParams,
|
|
77
|
+
FileWriteBeginParams,
|
|
78
|
+
FileWriteBeginResult,
|
|
79
|
+
FileWriteChunkParams,
|
|
80
|
+
FileWriteChunkResult,
|
|
81
|
+
FileWriteFinishParams,
|
|
82
|
+
FileWriteFinishResult,
|
|
83
|
+
InteractionDTO,
|
|
84
|
+
MessageDTO,
|
|
85
|
+
ModelProviderDTO,
|
|
86
|
+
MsgPageDTO,
|
|
87
|
+
MsgPageParams,
|
|
88
|
+
PinSessionParams,
|
|
89
|
+
RenameSessionParams,
|
|
90
|
+
RunningToolDTO,
|
|
91
|
+
SendParams,
|
|
92
|
+
SessionSummaryDTO,
|
|
93
|
+
SetModelParams,
|
|
94
|
+
WorkspaceOptionDTO,
|
|
95
|
+
} from '../protocol/api'
|
|
96
|
+
import type { HelloParams, HelloResult } from '../protocol/hello'
|
|
97
|
+
|
|
98
|
+
export interface MockHostOptions {
|
|
99
|
+
/** 流式段数。 */
|
|
100
|
+
streamSteps?: number
|
|
101
|
+
/** 每段间隔(ms)。 */
|
|
102
|
+
streamDelayMs?: number
|
|
103
|
+
/**
|
|
104
|
+
* `demo-1` 的预置消息条数(默认 2)。
|
|
105
|
+
*
|
|
106
|
+
* 供分页验证 / harness 压测用:默认值下只有 2 条,永远触发不了「还有更早的消息」。
|
|
107
|
+
*/
|
|
108
|
+
demoMessageCount?: number
|
|
109
|
+
/**
|
|
110
|
+
* 给 `demo-1` 追加一条带工具名的工具消息(默认关:会改变默认消息条数,影响分页用例)。
|
|
111
|
+
*
|
|
112
|
+
* 打开后手机端能看到「工具 · list_files」气泡(harness 人工验证用)。
|
|
113
|
+
*/
|
|
114
|
+
demoToolMessage?: boolean
|
|
115
|
+
/**
|
|
116
|
+
* 模拟链路通讯类型(§37):`'relay'` 时**所有 `host.file.*` 一律拒**,
|
|
117
|
+
* 与真实电脑侧的「非中继才给传文件」同一条口径(文案也同一句)。
|
|
118
|
+
*
|
|
119
|
+
* 为何要能模拟:手机端「中继时置灰入口」与「电脑端拒了之后怎么提示」是两条独立分支,
|
|
120
|
+
* 而不给一个会拒的宿主就测不到后者的错误路径。
|
|
121
|
+
*/
|
|
122
|
+
fileLinkKind?: 'direct' | 'relay'
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 构造演示用交互(供 harness / 单测触发手机端卡片)。 */
|
|
126
|
+
export interface MockInteractionSpec {
|
|
127
|
+
kind?: 'choice' | 'authorization'
|
|
128
|
+
tier?: ApprovalTier
|
|
129
|
+
sessionId?: string
|
|
130
|
+
question?: string
|
|
131
|
+
options?: string[]
|
|
132
|
+
multi?: boolean
|
|
133
|
+
permName?: string
|
|
134
|
+
title?: string
|
|
135
|
+
subTitle?: string
|
|
136
|
+
desc?: string
|
|
137
|
+
hint?: string
|
|
138
|
+
risk?: string
|
|
139
|
+
sandboxBypass?: boolean
|
|
140
|
+
presentation?: 'modal' | 'terminal'
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface MockHostDataSource extends HostDataSource {
|
|
144
|
+
/** 由 harness / 测试在注册后回填(把 `HostEvents` 推给对端)。 */
|
|
145
|
+
bind(emit: HostEmit): void
|
|
146
|
+
/** 推一个待应答交互给手机(返回其 `interactionId`)。 */
|
|
147
|
+
triggerInteraction(spec?: MockInteractionSpec): string
|
|
148
|
+
/** 当前仍挂起的交互 id。 */
|
|
149
|
+
pendingInteractions(): string[]
|
|
150
|
+
/**
|
|
151
|
+
* 已收到的 RPC 方法名流水(M5)。
|
|
152
|
+
*
|
|
153
|
+
* 供单测断言「请求确实到达了电脑侧」—— 否则只能靠副作用反推,
|
|
154
|
+
* 而像 `cancel` 这种「无事发生」的方法就没有可观察的副作用。
|
|
155
|
+
*/
|
|
156
|
+
readonly calls: string[]
|
|
157
|
+
/** 手动推一次上下文占用变化(模拟电脑侧使用量增长 / 压缩),供手机端 UI 联调。 */
|
|
158
|
+
bumpContext(sessionId: string, tokens: number): void
|
|
159
|
+
/**
|
|
160
|
+
* 手动推一次「工具参数生成进度」(§27),供手机端 UI 联调 / 单测。
|
|
161
|
+
*
|
|
162
|
+
* 真实链路里由引擎的 `AgentEvent.tool_progress` 驱动 —— 那是 provider 在**累积 tool 参数**
|
|
163
|
+
* 期间唯一的事件(其余时间静默)。`progress = null` 表示清空(工具开始执行 / 本轮结束)。
|
|
164
|
+
*/
|
|
165
|
+
setToolProgress(sessionId: string, progress: { name: string; chars: number } | null): void
|
|
166
|
+
/**
|
|
167
|
+
* 手动推一次「**正在执行中**的工具」(`RuntimeDTO.runningTools`),供手机端 UI 联调 / 单测。
|
|
168
|
+
*
|
|
169
|
+
* 与 `setToolProgress` 是一对(先后相接的两个阶段):参数累积期看 `toolProgress`,
|
|
170
|
+
* 工具开始执行到结果回来之间看 `runningTools`。传 `null` = **跑完了**(一并把 `working` 收掉,
|
|
171
|
+
* 真机上工具跑完要么接着下一轮、要么整个 run 结束);传空数组 = 这次执行中的工具都结束了,
|
|
172
|
+
* 但会话仍在跑(模型正在接着思考)。
|
|
173
|
+
*
|
|
174
|
+
* ⚠️ 真实电脑侧没有这个「手动口」:那些字段是 `store-bridge` 从会话消息(assistant 的
|
|
175
|
+
* `toolCalls[]` 减去已有结果)**推导**出来的(§27 的姊妹字段),mock 手推是为了让手机端
|
|
176
|
+
* 在**没有引擎**的情况下也能看到这一帧。
|
|
177
|
+
*/
|
|
178
|
+
setRunningTools(sessionId: string, tools: RunningToolDTO[] | null): void
|
|
179
|
+
/**
|
|
180
|
+
* 读一份**演示文件树**里的文件(测试 / harness 断言用)—— `null` = 不存在。
|
|
181
|
+
*
|
|
182
|
+
* 为什么需要:上传是否真的落到「正确的相对路径、正确的字节」只能从宿主侧看,
|
|
183
|
+
* 而手机端的 RPC 应答只说「成功了」。只测应答 = 测了个寂寞。
|
|
184
|
+
*/
|
|
185
|
+
readMockFile(sessionId: string, relPath: string): Uint8Array | null
|
|
186
|
+
/** 往演示文件树里放一个文件(建目录是隐式的)。 */
|
|
187
|
+
writeMockFile(sessionId: string, relPath: string, content: string | Uint8Array): void
|
|
188
|
+
/** 当前文件树里的全部相对路径(排序后)。 */
|
|
189
|
+
listMockFiles(sessionId: string): string[]
|
|
190
|
+
/**
|
|
191
|
+
* 最后**真正执行**的那次压缩(`null` = 还没压过)—— 测试 / harness 观察口。
|
|
192
|
+
*
|
|
193
|
+
* 为什么需要:手机端的 RPC 应答只是一句 `{ok:true}`,它只说「电脑侧受理了」,**不说用了哪种方式**
|
|
194
|
+
* —— 而「选中了正文压缩却走了 AI 摘要(还花了钱)」正是这个参数最危险的失败形态。
|
|
195
|
+
*/
|
|
196
|
+
lastCompress(): { sessionId: string; mode: CompressMode } | null
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** 演示用模型服务(与 `sessions` 里的 provider 字段一致)。 */
|
|
200
|
+
const DEMO_PROVIDERS: ModelProviderDTO[] = [
|
|
201
|
+
{ id: 'p-openai', name: 'OpenAI(演示)', models: ['gpt-4o', 'gpt-4o-mini'] },
|
|
202
|
+
{ id: 'p-anthropic', name: 'Anthropic(演示)', models: ['claude-sonnet-4'] },
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
/** 演示用工作目录候选集(新建会话只能从这里选)。 */
|
|
206
|
+
const DEMO_WORKSPACES = ['E:/code/virlen-demo', 'E:/code/another-project']
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* 演示文件树的固定修改时刻 —— 不取 `Date.now()`:否则用例只能对着「大于某刻」断言,
|
|
210
|
+
* 而手机上要显示「3 分钟前修改」时又得再多一层换算。
|
|
211
|
+
*/
|
|
212
|
+
const DEMO_FILE_MTIME = Date.UTC(2026, 9, 1, 12, 0, 0)
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* 标准 1×1 透明 PNG(68 字节)。
|
|
216
|
+
*
|
|
217
|
+
* 为什么放一个**真的**图片字节而不是随便一段文本叫 `logo.png`:图片预览这条路要验证的是
|
|
218
|
+
* 「字节 → base64 → Blob → ObjectURL → <img> 真的能显示」,而假图片会让这条路永远发绿灯。
|
|
219
|
+
*/
|
|
220
|
+
const DEMO_PNG_BASE64 =
|
|
221
|
+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='
|
|
222
|
+
|
|
223
|
+
/** 演示文件树的初始内容(文本 / base64 两种写法)。 */
|
|
224
|
+
const DEMO_TREE_SPEC: Array<{ path: string; text?: string; base64?: string }> = [
|
|
225
|
+
{
|
|
226
|
+
path: 'README.md',
|
|
227
|
+
text: '# Virlen 演示项目\n\n这是一个用于验证**手机端文件浏览**的演示目录。\n\n- `src/` 下有 TypeScript 源码\n- `assets/logo.png` 是一张小图\n- `build/app.bin` 是未知类型(只能下载)\n',
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
path: 'src/index.ts',
|
|
231
|
+
text: [
|
|
232
|
+
// ⚠️ 演示内容里**不要**写相对导入说明符(构建脚本会把产物中所有形如相对导入的字符串
|
|
233
|
+
// 补上 `.js` 后缀,于是这段演示文本会被误报一句告警)。用路径别名即可。
|
|
234
|
+
"import { createApp } from '@/app'",
|
|
235
|
+
'',
|
|
236
|
+
'const app = createApp({ port: 3000 })',
|
|
237
|
+
'app.start()',
|
|
238
|
+
'',
|
|
239
|
+
].join('\n'),
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
path: 'src/store/chat.ts',
|
|
243
|
+
text: [
|
|
244
|
+
'export interface ChatState {',
|
|
245
|
+
' messages: string[]',
|
|
246
|
+
' working: boolean',
|
|
247
|
+
'}',
|
|
248
|
+
'',
|
|
249
|
+
'export const INITIAL: ChatState = { messages: [], working: false }',
|
|
250
|
+
'',
|
|
251
|
+
].join('\n'),
|
|
252
|
+
},
|
|
253
|
+
{ path: 'src/app.ts', text: 'export function createApp(config: { port: number }) {\n return { start: () => console.log(config.port) }\n}\n' },
|
|
254
|
+
{
|
|
255
|
+
path: 'package.json',
|
|
256
|
+
text: '{\n "name": "virlen-demo",\n "version": "1.0.0",\n "private": true\n}\n',
|
|
257
|
+
},
|
|
258
|
+
{ path: 'docs/notes.txt', text: '手机端浏览这个目录时,应该看到 src/ 与 docs/ 两个子目录。\n' },
|
|
259
|
+
{ path: '.gitignore', text: 'node_modules/\ndist/\n' },
|
|
260
|
+
{ path: 'assets/logo.png', base64: DEMO_PNG_BASE64 },
|
|
261
|
+
// 未知扩展名 → `previewKindOf` 给 binary → 手机端只给「下载」(这条路必须有用例盯着)
|
|
262
|
+
{ path: 'build/app.bin', text: 'not-a-real-binary-but-unknown-extension' },
|
|
263
|
+
]
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* 演示文件树的一格:**字节 + 修改时刻**。
|
|
267
|
+
*
|
|
268
|
+
* 为何要把 `mtimeMs` 放进树里(而不是像原来那样给所有条目一个常量):编辑保存要按「打开时的
|
|
269
|
+
* mtime」做冲突校验 —— 手机端改完再存、而期间电脑侧这份文件被改过就该拒。常量时间戳下这条
|
|
270
|
+
* 路径永远走不到(写盘不改 mtime = 永远不冲突)。
|
|
271
|
+
*/
|
|
272
|
+
interface MockFile {
|
|
273
|
+
bytes: Uint8Array
|
|
274
|
+
mtimeMs: number
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** 建一份演示文件树(相对工作目录的路径 → 文件)。每份工作目录各得一份,互不影响。 */
|
|
278
|
+
function createDemoTree(): Map<string, MockFile> {
|
|
279
|
+
const encoder = new TextEncoder()
|
|
280
|
+
const tree = new Map<string, MockFile>()
|
|
281
|
+
for (const item of DEMO_TREE_SPEC) {
|
|
282
|
+
tree.set(item.path, {
|
|
283
|
+
bytes: item.base64 ? base64ToBytes(item.base64) : encoder.encode(item.text ?? ''),
|
|
284
|
+
mtimeMs: DEMO_FILE_MTIME,
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
return tree
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* 演示用 Agent 候选集(与上面 `sessions` 里的 `agentId` / `agentName` 一致)。
|
|
292
|
+
*
|
|
293
|
+
* 两个 Agent 故意配**不同的默认模型 / 默认目录**:手机端切换 Agent 时能看出联动,
|
|
294
|
+
* 也能验证「不传模型 / 目录 = 用所选 Agent 的默认值」这条语义。
|
|
295
|
+
*/
|
|
296
|
+
const DEMO_AGENTS: AgentOptionDTO[] = [
|
|
297
|
+
{
|
|
298
|
+
id: 'agent-virlen',
|
|
299
|
+
name: 'Virlen',
|
|
300
|
+
defaultModel: { providerConfigId: 'p-openai', modelId: 'gpt-4o' },
|
|
301
|
+
defaultWorkspace: DEMO_WORKSPACES[0],
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
id: 'agent-reviewer',
|
|
305
|
+
name: '代码评审员',
|
|
306
|
+
defaultModel: { providerConfigId: 'p-anthropic', modelId: 'claude-sonnet-4' },
|
|
307
|
+
defaultWorkspace: DEMO_WORKSPACES[1],
|
|
308
|
+
},
|
|
309
|
+
]
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* 「只引用了消息、没写正文」时电脑侧补的那句话。
|
|
313
|
+
*
|
|
314
|
+
* 与桌面输入框的兜底**同一条**(`utils/messageContent.ts::buildUserContent` 的「有引用无文本」分支):
|
|
315
|
+
* 不变的话,用例会对着一个与真机不同的正文发绿灯。文案取桌面的中文原文 ——
|
|
316
|
+
* 桌面切成英文时会是另一句,但 mock 只求**行为同形**,不求与 i18n 逐字同步
|
|
317
|
+
* (真要与 i18n 一致,那是 virlen-app 侧的跨端用例该盯的事)。
|
|
318
|
+
*/
|
|
319
|
+
const QUOTE_ONLY_TEXT = '请针对引用的消息回复'
|
|
320
|
+
/** 只带文件、不写正文时的兜底正文(与电脑侧 `buildUserContent` 的「看看这些文件」同义)。 */
|
|
321
|
+
const FILE_ONLY_TEXT = '看看这些文件'
|
|
322
|
+
|
|
323
|
+
export function createMockHostDataSource(options: MockHostOptions = {}): MockHostDataSource {
|
|
324
|
+
const streamSteps = options.streamSteps ?? 3
|
|
325
|
+
const streamDelayMs = options.streamDelayMs ?? 40
|
|
326
|
+
|
|
327
|
+
let emit: HostEmit | null = null
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* 已订阅的会话(与真实电脑侧的 `SubscriptionRegistry` 同语义)。
|
|
331
|
+
*
|
|
332
|
+
* 未订阅的会话:只有会话列表(`session.list.changed`)与交互事件会推给对端 ——
|
|
333
|
+
* 消息 / 流式 / 运行时 / 占用一律不推(见文件头「订阅门」)。
|
|
334
|
+
*/
|
|
335
|
+
const subscribed = new Set<string>()
|
|
336
|
+
|
|
337
|
+
/** 会话维度的事件推送(受订阅门约束)。 */
|
|
338
|
+
function emitFor<K extends keyof HostEvents & string>(
|
|
339
|
+
sessionId: string,
|
|
340
|
+
topic: K,
|
|
341
|
+
payload: HostEvents[K],
|
|
342
|
+
): void {
|
|
343
|
+
if (!subscribed.has(sessionId)) return
|
|
344
|
+
emit?.(topic, payload)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** RPC 方法名流水(见 `MockHostDataSource.calls`)。 */
|
|
348
|
+
const calls: string[] = []
|
|
349
|
+
|
|
350
|
+
const sessions: SessionSummaryDTO[] = [
|
|
351
|
+
{
|
|
352
|
+
id: 'demo-1',
|
|
353
|
+
title: '演示:手机控制',
|
|
354
|
+
updatedAt: Date.now(),
|
|
355
|
+
working: false,
|
|
356
|
+
agentId: 'agent-virlen',
|
|
357
|
+
agentName: 'Virlen',
|
|
358
|
+
workspace: DEMO_WORKSPACES[0],
|
|
359
|
+
providerConfigId: 'p-openai',
|
|
360
|
+
providerName: 'OpenAI(演示)',
|
|
361
|
+
modelId: 'gpt-4o',
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
id: 'demo-2',
|
|
365
|
+
title: '空会话',
|
|
366
|
+
updatedAt: Date.now() - 60_000,
|
|
367
|
+
working: false,
|
|
368
|
+
agentId: 'agent-reviewer',
|
|
369
|
+
agentName: '代码评审员',
|
|
370
|
+
workspace: DEMO_WORKSPACES[1],
|
|
371
|
+
providerConfigId: 'p-anthropic',
|
|
372
|
+
providerName: 'Anthropic(演示)',
|
|
373
|
+
modelId: 'claude-sonnet-4',
|
|
374
|
+
},
|
|
375
|
+
]
|
|
376
|
+
const messages = new Map<string, MessageDTO[]>()
|
|
377
|
+
const demoCount = options.demoMessageCount ?? 2
|
|
378
|
+
messages.set(
|
|
379
|
+
'demo-1',
|
|
380
|
+
demoCount <= 2
|
|
381
|
+
? [
|
|
382
|
+
{ id: 'm1', role: 'user', text: '你好,帮我看下今天的安排', createdAt: Date.now() - 120_000 },
|
|
383
|
+
{ id: 'm2', role: 'assistant', text: '好的,我来看看。', createdAt: Date.now() - 110_000 },
|
|
384
|
+
]
|
|
385
|
+
: Array.from({ length: demoCount }, (_, i) => ({
|
|
386
|
+
id: `m${i}`,
|
|
387
|
+
role: i % 2 === 0 ? ('user' as const) : ('assistant' as const),
|
|
388
|
+
text: `演示消息 #${i}`,
|
|
389
|
+
createdAt: Date.now() - (demoCount - i) * 1000,
|
|
390
|
+
})),
|
|
391
|
+
)
|
|
392
|
+
messages.set('demo-2', [])
|
|
393
|
+
if (options.demoToolMessage) {
|
|
394
|
+
messages.get('demo-1')?.push(
|
|
395
|
+
{
|
|
396
|
+
id: 'tool-1',
|
|
397
|
+
role: 'tool',
|
|
398
|
+
text: 'src/index.ts\nsrc/store.ts\nsrc/ui/pages/Chat.tsx',
|
|
399
|
+
createdAt: Date.now() - 105_000,
|
|
400
|
+
toolName: 'list_files',
|
|
401
|
+
/*
|
|
402
|
+
* 入参摘要与真实电脑侧**同源**(`summarizeToolArgs`):演示宿主不手写一行
|
|
403
|
+
* 会与真实格式化规则打架的假数据 —— 那种假数据会让联调时看到的现象失去意义。
|
|
404
|
+
*/
|
|
405
|
+
toolArgs: summarizeToolArgs('list_files', { path: 'src' }),
|
|
406
|
+
/*
|
|
407
|
+
* 展开区的完整入参同样**同源**(`formatToolArgs`):演示宿主手写一段会在形态上
|
|
408
|
+
* 与真实电脑侧打架(缩进 / 路径缩不缩短),那会让联调时看到的现象失去意义。
|
|
409
|
+
*/
|
|
410
|
+
toolArgsFull: formatToolArgs({ path: 'src' }),
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
id: 'empty-assistant',
|
|
414
|
+
role: 'assistant',
|
|
415
|
+
text: '',
|
|
416
|
+
createdAt: Date.now() - 100_000,
|
|
417
|
+
},
|
|
418
|
+
)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* 上下文占用(模拟电脑侧口径:最后一条带 usage / contextTokens 的消息)。
|
|
423
|
+
*
|
|
424
|
+
* `demo-2` 特意给一个很低的占用 —— 让手机端「上下文充裕时不显示压缩」的分支可验证。
|
|
425
|
+
*/
|
|
426
|
+
const contextTokens = new Map<string, number>([
|
|
427
|
+
['demo-1', 120_000],
|
|
428
|
+
['demo-2', 1_000],
|
|
429
|
+
])
|
|
430
|
+
|
|
431
|
+
let counter = 0
|
|
432
|
+
const nextId = (prefix: string) => `${prefix}-${++counter}-${Date.now().toString(36)}`
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* 待应答交互(模拟电脑侧的交互注册表)。
|
|
436
|
+
*/
|
|
437
|
+
const interactions = new Map<string, InteractionDTO>()
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* 手机在 `hello` 里声明的流式偏好(§32)。默认 `full` —— 不声明就按整帧发,
|
|
441
|
+
* 与真实电脑侧同一条规则(旧客户端收到增量帧会把一帧当全文,正文就错位了)。
|
|
442
|
+
*/
|
|
443
|
+
let peerStreamMode: StreamMode = 'full'
|
|
444
|
+
|
|
445
|
+
/** 被「暂存」而暂停的会话(模拟电脑侧的 paused 运行态,供 resume 演示 / 测试)。 */
|
|
446
|
+
const pausedSessions = new Set<string>()
|
|
447
|
+
|
|
448
|
+
function emitList(): void {
|
|
449
|
+
emit?.('host.event.session.list.changed', { sessions: sessions.map((s) => ({ ...s })) })
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* 发一帧流式正文(按客户端的 `streamMode` 声明决定整帧还是增量)。
|
|
454
|
+
*
|
|
455
|
+
* `sent` = 上一步**已发出**的正文;首帧传 `''` → 必然走 `full`(客户端还没有基准)。
|
|
456
|
+
* 与真实电脑侧一样:写改(非前缀增长)时回落整帧,而不是硬发一个错的增量。
|
|
457
|
+
*/
|
|
458
|
+
function emitStreamFrame(
|
|
459
|
+
sessionId: string,
|
|
460
|
+
messageId: string,
|
|
461
|
+
seq: number,
|
|
462
|
+
text: string,
|
|
463
|
+
sent: string,
|
|
464
|
+
): void {
|
|
465
|
+
const delta = peerStreamMode === 'delta' && sent !== '' && text.startsWith(sent)
|
|
466
|
+
emitFor(sessionId, 'host.event.message.stream', {
|
|
467
|
+
sessionId,
|
|
468
|
+
messageId,
|
|
469
|
+
seq,
|
|
470
|
+
mode: delta ? 'delta' : 'full',
|
|
471
|
+
text: delta ? text.slice(sent.length) : text,
|
|
472
|
+
...(delta ? { offset: sent.length } : {}),
|
|
473
|
+
final: false,
|
|
474
|
+
})
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function streamReply(sessionId: string, list: MessageDTO[]): Promise<void> {
|
|
478
|
+
const messageId = nextId('a')
|
|
479
|
+
let text = ''
|
|
480
|
+
// 模拟生成过程中的积压:内容真实增长 → 流式帧逐次变长(与真实电脑侧同规则)
|
|
481
|
+
list.push({ id: messageId, role: 'assistant', text: '', createdAt: Date.now() })
|
|
482
|
+
for (let i = 0; i < streamSteps; i++) {
|
|
483
|
+
await delay(streamDelayMs)
|
|
484
|
+
const sent = text
|
|
485
|
+
text += `这是模拟流式的第 ${i + 1} 段。`
|
|
486
|
+
emitStreamFrame(sessionId, messageId, i + 1, text, sent)
|
|
487
|
+
}
|
|
488
|
+
await delay(streamDelayMs)
|
|
489
|
+
const finalMessage: MessageDTO = {
|
|
490
|
+
id: messageId,
|
|
491
|
+
role: 'assistant',
|
|
492
|
+
text,
|
|
493
|
+
createdAt: Date.now(),
|
|
494
|
+
}
|
|
495
|
+
// 定稿:就地替换那条临时消息(与电脑侧「流式期间不发 message.updated」的语义对齐)
|
|
496
|
+
const idx = list.findIndex((m) => m.id === messageId)
|
|
497
|
+
if (idx >= 0) list[idx] = finalMessage
|
|
498
|
+
else list.push(finalMessage)
|
|
499
|
+
// 收尾帧同样是**整段**(定稿全文可能被回填/修复改写,增量已经不可靠)
|
|
500
|
+
emitFor(sessionId, 'host.event.message.stream', {
|
|
501
|
+
sessionId,
|
|
502
|
+
messageId,
|
|
503
|
+
seq: streamSteps + 1,
|
|
504
|
+
mode: 'full',
|
|
505
|
+
text,
|
|
506
|
+
final: true,
|
|
507
|
+
})
|
|
508
|
+
emitFor(sessionId, 'host.event.message.added', { sessionId, message: finalMessage })
|
|
509
|
+
emitFor(sessionId, 'host.event.session.runtime.changed', { sessionId, runtime: { working: false } })
|
|
510
|
+
// 生成一轮 → 上下文占用增长(手机端可看到实时变化)
|
|
511
|
+
bumpContext(sessionId, (contextTokens.get(sessionId) ?? 0) + 2_000)
|
|
512
|
+
const session = sessions.find((s) => s.id === sessionId)
|
|
513
|
+
if (session) {
|
|
514
|
+
session.working = false
|
|
515
|
+
session.updatedAt = Date.now()
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** 推一次上下文变化(内部与 harness 共用)—— 同样受订阅门约束。 */
|
|
520
|
+
function bumpContext(sessionId: string, tokens: number): void {
|
|
521
|
+
contextTokens.set(sessionId, tokens)
|
|
522
|
+
emitFor(sessionId, 'host.event.session.context.changed', { sessionId, context: contextOf(sessionId) })
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** 推一次工具参数生成进度(§27)—— 同上,受订阅门约束。 */
|
|
526
|
+
function setToolProgress(
|
|
527
|
+
sessionId: string,
|
|
528
|
+
progress: { name: string; chars: number } | null,
|
|
529
|
+
): void {
|
|
530
|
+
emitFor(sessionId, 'host.event.session.runtime.changed', {
|
|
531
|
+
sessionId,
|
|
532
|
+
// 参数累积期一定是 working(真实链路里两者同源);progress=null 即清空
|
|
533
|
+
runtime: { working: true, ...(progress ? { toolProgress: progress } : {}) },
|
|
534
|
+
})
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** 推一次「正在执行中的工具」(`RuntimeDTO.runningTools`)—— 同上,受订阅门约束。 */
|
|
538
|
+
function setRunningTools(sessionId: string, tools: RunningToolDTO[] | null): void {
|
|
539
|
+
if (tools === null) {
|
|
540
|
+
// 跑完了:没有执行中的工具,也不再工作(真机上这一步之后要么空闲、要么出新消息)
|
|
541
|
+
emitFor(sessionId, 'host.event.session.runtime.changed', {
|
|
542
|
+
sessionId,
|
|
543
|
+
runtime: { working: false },
|
|
544
|
+
})
|
|
545
|
+
return
|
|
546
|
+
}
|
|
547
|
+
emitFor(sessionId, 'host.event.session.runtime.changed', {
|
|
548
|
+
sessionId,
|
|
549
|
+
// 工具在执行 → 一定是 working;空数组即「都执行完了,但本轮还没结束」
|
|
550
|
+
runtime: { working: true, ...(tools.length > 0 ? { runningTools: tools } : {}) },
|
|
551
|
+
})
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function contextOf(sessionId: string): ContextInfoDTO {
|
|
555
|
+
const tokens = contextTokens.get(sessionId)
|
|
556
|
+
return {
|
|
557
|
+
tokens: tokens ?? null,
|
|
558
|
+
windowTokens: DEFAULT_CONTEXT_WINDOW_TOKENS,
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/* ───────────────────── §37:演示文件树(手机端文件浏览 / 上传) ───────────────────── */
|
|
563
|
+
|
|
564
|
+
/** 各工作目录一份演示文件树(懒建:没碰过就没有)。 */
|
|
565
|
+
const trees = new Map<string, Map<string, MockFile>>()
|
|
566
|
+
|
|
567
|
+
function treeOf(sessionId: string): Map<string, MockFile> {
|
|
568
|
+
const workspace = sessions.find((s) => s.id === sessionId)?.workspace
|
|
569
|
+
if (!workspace) throw new BridgeError('E_NOT_FOUND', `session not found: ${sessionId}`)
|
|
570
|
+
let tree = trees.get(workspace)
|
|
571
|
+
if (!tree) {
|
|
572
|
+
tree = createDemoTree()
|
|
573
|
+
trees.set(workspace, tree)
|
|
574
|
+
}
|
|
575
|
+
return tree
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* 单调递增的修改时刻。
|
|
580
|
+
*
|
|
581
|
+
* 不用裸 `Date.now()`:同毫秒内的两次保存会拿到**相同**的 mtime,冲突就检不出来 ——
|
|
582
|
+
* 而「连续存两次」正是这条路径最常见的用法。
|
|
583
|
+
*/
|
|
584
|
+
let mtimeSeq = 0
|
|
585
|
+
function nowMtime(): number {
|
|
586
|
+
mtimeSeq = Math.max(Date.now(), mtimeSeq + 1)
|
|
587
|
+
return mtimeSeq
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* 非中继门槛 —— **与真实电脑侧同一句文案**(`fileTransferDeniedReason`),
|
|
592
|
+
* 差别只在模拟开关:`MockHostOptions.fileLinkKind`。
|
|
593
|
+
*/
|
|
594
|
+
function assertFileLinkUsable(): void {
|
|
595
|
+
const reason = fileTransferDeniedReason(options.fileLinkKind ?? 'direct')
|
|
596
|
+
if (reason) throw new BridgeError('E_DENIED', reason)
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** 目录是否存在(目录由路径前缀隐式推出;根目录恒存在)。 */
|
|
600
|
+
function dirExists(tree: Map<string, MockFile>, relPath: string): boolean {
|
|
601
|
+
if (!relPath) return true
|
|
602
|
+
const prefix = `${relPath}/`
|
|
603
|
+
for (const key of tree.keys()) if (key.startsWith(prefix)) return true
|
|
604
|
+
return false
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** 把扁平的文件表按当前层拆成条目(目录在前、按名排序,超过上限即截断)。 */
|
|
608
|
+
function entriesOf(tree: Map<string, MockFile>, relPath: string): { entries: FileEntryDTO[]; truncated: boolean } {
|
|
609
|
+
const prefix = relPath ? `${relPath}/` : ''
|
|
610
|
+
const byName = new Map<string, FileEntryDTO>()
|
|
611
|
+
for (const [path, file] of tree) {
|
|
612
|
+
if (!path.startsWith(prefix)) continue
|
|
613
|
+
const rest = path.slice(prefix.length)
|
|
614
|
+
const slash = rest.indexOf('/')
|
|
615
|
+
if (slash < 0) {
|
|
616
|
+
byName.set(rest, { name: rest, isDir: false, size: file.bytes.length, mtimeMs: file.mtimeMs })
|
|
617
|
+
} else {
|
|
618
|
+
const dir = rest.slice(0, slash)
|
|
619
|
+
if (!byName.has(dir)) byName.set(dir, { name: dir, isDir: true, size: 0, mtimeMs: DEMO_FILE_MTIME })
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
const all = [...byName.values()].sort(compareFileEntries)
|
|
623
|
+
return { entries: all.slice(0, FILE_LIST_MAX_ENTRIES), truncated: all.length > FILE_LIST_MAX_ENTRIES }
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
interface MockUpload {
|
|
627
|
+
sessionId: string
|
|
628
|
+
dir: string
|
|
629
|
+
name: string
|
|
630
|
+
relPath: string
|
|
631
|
+
bytes: Uint8Array
|
|
632
|
+
received: number
|
|
633
|
+
/** `true` = 覆写已有文件(编辑保存);缺省 = 上传新建。 */
|
|
634
|
+
overwrite?: boolean
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const uploads = new Map<string, MockUpload>()
|
|
638
|
+
|
|
639
|
+
/** 最后一次真正执行的压缩(见 `MockHostDataSource.lastCompress`)。 */
|
|
640
|
+
let lastCompress: { sessionId: string; mode: CompressMode } | null = null
|
|
641
|
+
|
|
642
|
+
function requireUpload(uploadId: string): MockUpload {
|
|
643
|
+
const upload = uploads.get(uploadId)
|
|
644
|
+
if (!upload) throw new BridgeError('E_NOT_FOUND', `上传任务不存在或已结束:${uploadId}`)
|
|
645
|
+
return upload
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const host: MockHostDataSource = {
|
|
649
|
+
calls,
|
|
650
|
+
bind(fn) {
|
|
651
|
+
emit = fn
|
|
652
|
+
},
|
|
653
|
+
/**
|
|
654
|
+
* 模拟配对校验 + 授权凭证签发(M6 口径,见 docs/phone-control-bridge.md §30):
|
|
655
|
+
*
|
|
656
|
+
* - 无 token → 拒(`invalid`)
|
|
657
|
+
* - `token === 'expired'` → 凭证过期(`data.reason='expired'`)
|
|
658
|
+
* - `token === 'removed'` → 手机已被电脑端移除(`data.reason='revoked'`)
|
|
659
|
+
* - `token === 'ticket-old'` → 二维码已过期(`data.reason='ticket-expired'`)
|
|
660
|
+
* - 其它非空 token → 通过,并**回传一条授权凭证**(手机端存的应该是它,不是手上那张票)
|
|
661
|
+
*/
|
|
662
|
+
async hello(params: HelloParams): Promise<HelloResult> {
|
|
663
|
+
const token = params.token
|
|
664
|
+
// §32:记下手机声明的流式偏好(后续 `message.stream` 按它决定整帧/增量)
|
|
665
|
+
peerStreamMode = params.streamMode === 'delta' ? 'delta' : 'full'
|
|
666
|
+
if (!token) {
|
|
667
|
+
throw new BridgeError('E_DENIED', '缺少配对令牌', { data: { reason: 'invalid' } })
|
|
668
|
+
}
|
|
669
|
+
if (token === 'expired') {
|
|
670
|
+
throw new BridgeError('E_DENIED', '令牌已过期', { data: { reason: 'expired' } })
|
|
671
|
+
}
|
|
672
|
+
if (token === 'removed') {
|
|
673
|
+
throw new BridgeError('E_DENIED', '该设备已被电脑端移除', { data: { reason: 'revoked' } })
|
|
674
|
+
}
|
|
675
|
+
if (token === 'ticket-old') {
|
|
676
|
+
throw new BridgeError('E_DENIED', '二维码已过期', { data: { reason: 'ticket-expired' } })
|
|
677
|
+
}
|
|
678
|
+
const now = Date.now()
|
|
679
|
+
return {
|
|
680
|
+
protocolVersion: params.protocolVersion,
|
|
681
|
+
host: { platform: 'web-harness', appVersion: '0.0.0' },
|
|
682
|
+
capabilities: [
|
|
683
|
+
'session.list',
|
|
684
|
+
'session.send',
|
|
685
|
+
'session.cancel',
|
|
686
|
+
'session.resume',
|
|
687
|
+
'session.create',
|
|
688
|
+
'session.rename',
|
|
689
|
+
'session.pin',
|
|
690
|
+
'session.delete',
|
|
691
|
+
'interaction.answer',
|
|
692
|
+
'stream.delta',
|
|
693
|
+
'session.model',
|
|
694
|
+
'session.workspace',
|
|
695
|
+
'session.context',
|
|
696
|
+
'session.compress',
|
|
697
|
+
// 压缩方式(§22):mock 真的按 `CompressParams.mode` 出不同产物,故如实声明 ——
|
|
698
|
+
// 不声明的话手机端只会渲染一个「压缩上下文」按钮,用例就测不到选择器那条路
|
|
699
|
+
COMPRESS_MODE_CAPABILITY,
|
|
700
|
+
// Agent 选择:mock 确实按候选集校验 agentId(见 createSession),故如实声明 ——
|
|
701
|
+
// 不声明的话手机端会隐藏选择器,用例就测不到真实链路
|
|
702
|
+
SESSION_AGENT_CAPABILITY,
|
|
703
|
+
// 消息级操作:mock 确实实现了这两项(引用进内容块 / 截断删除),故如实声明 ——
|
|
704
|
+
// 不声明的话手机端会隐藏入口,用例就测不到真实链路
|
|
705
|
+
MESSAGE_QUOTE_CAPABILITY,
|
|
706
|
+
MESSAGE_DELETE_CAPABILITY,
|
|
707
|
+
// 文件引用(`SendParams.files`):mock 真的做两步 —— 校验归一(`sanitizeFileRefs`,
|
|
708
|
+
// 与真电脑侧同一份)与「文件块不进 `text`」的投影规则 —— 故如实声明
|
|
709
|
+
MESSAGE_FILE_CAPABILITY,
|
|
710
|
+
// §37:文件四档 —— mock 真的按这四档实现了(列 / 读 / 写 / 覆写),故如实声明
|
|
711
|
+
FILE_BROWSE_CAPABILITY,
|
|
712
|
+
FILE_DOWNLOAD_CAPABILITY,
|
|
713
|
+
FILE_UPLOAD_CAPABILITY,
|
|
714
|
+
// 覆写已有文件(编辑保存):mock 真的按「目标必须存在 + mtime/size 校验 + 不改名」
|
|
715
|
+
// 实现了,故如实声明 —— 不声明的话手机端只会给只读预览,用例就测不到编辑那条路
|
|
716
|
+
FILE_EDIT_CAPABILITY,
|
|
717
|
+
],
|
|
718
|
+
paired: true,
|
|
719
|
+
deviceName: 'Virlen 电脑(演示)',
|
|
720
|
+
deviceId: 'demo-host',
|
|
721
|
+
// M6:派发授权凭证(30 天)—— 手机端应把它存起来,下次直接连
|
|
722
|
+
grant: {
|
|
723
|
+
token: 'gt-demo',
|
|
724
|
+
issuedAt: now,
|
|
725
|
+
expiresAt: now + 30 * 24 * 60 * 60 * 1000,
|
|
726
|
+
lastSeenAt: now,
|
|
727
|
+
},
|
|
728
|
+
}
|
|
729
|
+
},
|
|
730
|
+
async listSessions() {
|
|
731
|
+
return sessions.map((s) => ({ ...s }))
|
|
732
|
+
},
|
|
733
|
+
async getMessages(params: MsgPageParams): Promise<MsgPageDTO> {
|
|
734
|
+
calls.push(params.fromRowid != null ? 'host.session.messages(older)' : 'host.session.messages')
|
|
735
|
+
const list = messages.get(params.sessionId) ?? []
|
|
736
|
+
const limit = params.limit ?? 50
|
|
737
|
+
// 游标 = 已返回窗口中最旧一条的下标(与真实电脑侧的 rowid 同形:不透明、原样回传)
|
|
738
|
+
if (params.fromRowid != null) {
|
|
739
|
+
const end = Math.max(0, Math.min(params.fromRowid, list.length))
|
|
740
|
+
const start = Math.max(0, end - limit)
|
|
741
|
+
return {
|
|
742
|
+
messages: list.slice(start, end).map((m) => ({ ...m })),
|
|
743
|
+
hasMore: start > 0,
|
|
744
|
+
cursor: start > 0 ? start : null,
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
const start = Math.max(0, list.length - limit)
|
|
748
|
+
return {
|
|
749
|
+
messages: list.slice(start).map((m) => ({ ...m })),
|
|
750
|
+
hasMore: start > 0,
|
|
751
|
+
cursor: start > 0 ? start : null,
|
|
752
|
+
}
|
|
753
|
+
},
|
|
754
|
+
async getMessage(params: { sessionId: string; messageId: string }) {
|
|
755
|
+
const list = messages.get(params.sessionId) ?? []
|
|
756
|
+
const found = list.find((m) => m.id === params.messageId)
|
|
757
|
+
if (!found) throw new BridgeError('E_NOT_FOUND', `message not found: ${params.messageId}`)
|
|
758
|
+
return { ...found }
|
|
759
|
+
},
|
|
760
|
+
async send(params: SendParams) {
|
|
761
|
+
calls.push('host.session.send')
|
|
762
|
+
const list = messages.get(params.sessionId)
|
|
763
|
+
if (!list) throw new BridgeError('E_NOT_FOUND', `session not found: ${params.sessionId}`)
|
|
764
|
+
const quotes = params.quotes ?? []
|
|
765
|
+
/*
|
|
766
|
+
* 文件引用(§37 的延伸):与真电脑侧**同一个校验口径**(`sanitizeFileRefs`)——
|
|
767
|
+
* 形状非法 / 超上限 → `E_BAD_REQUEST` 拒整条。mock 若比真机宽松(比如照单全收),
|
|
768
|
+
* 手机端侧的处理分支就永远测不到,真机上才表现为「发了但什么都没发生」。
|
|
769
|
+
*/
|
|
770
|
+
const sanitized = sanitizeFileRefs(params.files)
|
|
771
|
+
if (!sanitized.ok) {
|
|
772
|
+
throw new BridgeError('E_BAD_REQUEST', sanitized.reason)
|
|
773
|
+
}
|
|
774
|
+
const files = sanitized.files
|
|
775
|
+
const userMessage: MessageDTO = {
|
|
776
|
+
id: nextId('u'),
|
|
777
|
+
role: 'user',
|
|
778
|
+
// 与真实电脑侧的白名单投影同一条规则:引用与文件都**不进 `text`**
|
|
779
|
+
// (引用走 `quotes`、文件走 `files`),否则客户端会各显示两遍
|
|
780
|
+
// (引用条 / 文件 chip + 正文里的 `[引用] …` / `[文件] …`)
|
|
781
|
+
text: params.text || (quotes.length > 0 ? QUOTE_ONLY_TEXT : files.length > 0 ? FILE_ONLY_TEXT : ''),
|
|
782
|
+
createdAt: Date.now(),
|
|
783
|
+
...(quotes.length > 0 ? { quotes: quotes.map((q) => ({ ...q })) } : {}),
|
|
784
|
+
// 无文件则整个字段不带(与 `quotes` 同一条纪律:不为旧手机端凭空多出一个空数组)
|
|
785
|
+
...(files.length > 0 ? { files: files.map((f) => ({ ...f })) } : {}),
|
|
786
|
+
}
|
|
787
|
+
list.push(userMessage)
|
|
788
|
+
emitFor(params.sessionId, 'host.event.message.added', { sessionId: params.sessionId, message: userMessage })
|
|
789
|
+
emitFor(params.sessionId, 'host.event.session.runtime.changed', {
|
|
790
|
+
sessionId: params.sessionId,
|
|
791
|
+
runtime: { working: true },
|
|
792
|
+
})
|
|
793
|
+
const session = sessions.find((s) => s.id === params.sessionId)
|
|
794
|
+
if (session) {
|
|
795
|
+
session.working = true
|
|
796
|
+
session.updatedAt = Date.now()
|
|
797
|
+
}
|
|
798
|
+
void streamReply(params.sessionId, list)
|
|
799
|
+
return { messageId: userMessage.id }
|
|
800
|
+
},
|
|
801
|
+
async cancel() {
|
|
802
|
+
calls.push('host.session.cancel')
|
|
803
|
+
return { ok: true as const }
|
|
804
|
+
},
|
|
805
|
+
async resume(params: { sessionId: string }) {
|
|
806
|
+
calls.push('host.session.resume')
|
|
807
|
+
if (!sessions.some((s) => s.id === params.sessionId)) {
|
|
808
|
+
throw new BridgeError('E_NOT_FOUND', `session not found: ${params.sessionId}`)
|
|
809
|
+
}
|
|
810
|
+
pausedSessions.delete(params.sessionId)
|
|
811
|
+
emitFor(params.sessionId, 'host.event.session.runtime.changed', {
|
|
812
|
+
sessionId: params.sessionId,
|
|
813
|
+
runtime: { working: true },
|
|
814
|
+
})
|
|
815
|
+
return { ok: true as const }
|
|
816
|
+
},
|
|
817
|
+
/**
|
|
818
|
+
* 记录订阅(与真实电脑侧同语义:只推已订阅会话的会话维度事件)。
|
|
819
|
+
*
|
|
820
|
+
* ⚠️ 真实电脑侧还会先 `requireSession`(未知名会话 → `E_NOT_FOUND`),mock 保持一致:
|
|
821
|
+
* 否则「客户端订阅了一个不存在的会话」这类接线错误在单测里喑默通过。
|
|
822
|
+
*/
|
|
823
|
+
async subscribe(params: { sessionId: string }) {
|
|
824
|
+
calls.push('host.session.subscribe')
|
|
825
|
+
if (!sessions.some((s) => s.id === params.sessionId)) {
|
|
826
|
+
throw new BridgeError('E_NOT_FOUND', `session not found: ${params.sessionId}`)
|
|
827
|
+
}
|
|
828
|
+
subscribed.add(params.sessionId)
|
|
829
|
+
return { ok: true as const }
|
|
830
|
+
},
|
|
831
|
+
/**
|
|
832
|
+
* 模拟「终端内确认」(`presentation:'terminal'`):
|
|
833
|
+
* 手机只能原样放行 / 拒绝;电脑侧落点是 `terminalConfirmSubmit/Cancel`(§16.4)。
|
|
834
|
+
*/
|
|
835
|
+
triggerInteraction(spec: MockInteractionSpec = {}): string {
|
|
836
|
+
const interactionId = nextId('it')
|
|
837
|
+
const kind = spec.kind ?? 'authorization'
|
|
838
|
+
const interaction: InteractionDTO = {
|
|
839
|
+
interactionId,
|
|
840
|
+
sessionId: spec.sessionId ?? 'demo-1',
|
|
841
|
+
toolCallId: nextId('tc'),
|
|
842
|
+
kind,
|
|
843
|
+
createdAt: Date.now(),
|
|
844
|
+
tier: spec.tier ?? 'low',
|
|
845
|
+
question: spec.question,
|
|
846
|
+
options: spec.options,
|
|
847
|
+
multi: spec.multi,
|
|
848
|
+
permName: spec.permName,
|
|
849
|
+
title: spec.title ?? (kind === 'authorization' ? '执行命令' : undefined),
|
|
850
|
+
subTitle: spec.subTitle,
|
|
851
|
+
desc: spec.desc,
|
|
852
|
+
hint: spec.hint,
|
|
853
|
+
risk: spec.risk,
|
|
854
|
+
sandboxBypass: spec.sandboxBypass,
|
|
855
|
+
presentation: spec.presentation ?? 'modal',
|
|
856
|
+
}
|
|
857
|
+
interactions.set(interactionId, interaction)
|
|
858
|
+
emit?.('host.event.interaction.requested', { interaction })
|
|
859
|
+
return interactionId
|
|
860
|
+
},
|
|
861
|
+
/**
|
|
862
|
+
* 应答:与真实电脑侧同样的**独立校验**(高风险批准必须带 `confirmed`;
|
|
863
|
+
* 选择结果必须能规范化成非空回执)—— 演示 / 测试才能真实反映「手机 UI 漏改不算放行」。
|
|
864
|
+
*/
|
|
865
|
+
async answer(params: AnswerParams): Promise<AnswerResult> {
|
|
866
|
+
const interaction = interactions.get(params.interactionId)
|
|
867
|
+
if (!interaction) return { accepted: false, reason: 'not-found' }
|
|
868
|
+
// 动作合法性:与真实电脑侧(`interaction-registry`)**共用同一份**判据 ——
|
|
869
|
+
// mock 偏宽松会让「手机 UI 的动作与交互类型不匹配」这类缺陷测不出来(§18.6)。
|
|
870
|
+
const actionError = answerActionError(interaction, params.action)
|
|
871
|
+
if (actionError) return { accepted: false, reason: actionError }
|
|
872
|
+
if (params.action === 'allow' && interaction.tier === 'high' && params.confirmed !== true) {
|
|
873
|
+
return { accepted: false, reason: 'confirm-required' }
|
|
874
|
+
}
|
|
875
|
+
if (params.action === 'choose' && !normalizeChoiceAnswer(params.value)) {
|
|
876
|
+
return { accepted: false, reason: 'invalid-value' }
|
|
877
|
+
}
|
|
878
|
+
interactions.delete(params.interactionId)
|
|
879
|
+
// 暂存 → 会话进入「暂停运行」态(供手机端显示「继续」,与电脑侧 paused 语义一致)
|
|
880
|
+
if (params.action === 'shelve') {
|
|
881
|
+
pausedSessions.add(interaction.sessionId)
|
|
882
|
+
emitFor(interaction.sessionId, 'host.event.session.runtime.changed', {
|
|
883
|
+
sessionId: interaction.sessionId,
|
|
884
|
+
runtime: { working: false, paused: true },
|
|
885
|
+
})
|
|
886
|
+
}
|
|
887
|
+
emit?.('host.event.interaction.resolved', {
|
|
888
|
+
interactionId: params.interactionId,
|
|
889
|
+
by: 'mobile',
|
|
890
|
+
outcome: params.action === 'allow' ? 'allow' : params.action === 'shelve' ? 'shelve' : 'deny',
|
|
891
|
+
})
|
|
892
|
+
return { accepted: true }
|
|
893
|
+
},
|
|
894
|
+
pendingInteractions() {
|
|
895
|
+
return [...interactions.keys()]
|
|
896
|
+
},
|
|
897
|
+
async listInteractions(): Promise<InteractionDTO[]> {
|
|
898
|
+
return [...interactions.values()].map((i) => ({ ...i }))
|
|
899
|
+
},
|
|
900
|
+
// ── M4 写操作 ──
|
|
901
|
+
async createSession(params: CreateSessionParams) {
|
|
902
|
+
calls.push('host.session.create')
|
|
903
|
+
const id = nextId('s')
|
|
904
|
+
// 工作目录:**与真实电脑侧同一条防线** —— 候选集由电脑侧给出,手机不能自造(§22.3)
|
|
905
|
+
let workspace: string | undefined
|
|
906
|
+
if (params.workspace != null) {
|
|
907
|
+
const match = DEMO_WORKSPACES.find((w) => w === params.workspace)
|
|
908
|
+
if (!match) {
|
|
909
|
+
throw new BridgeError('E_BAD_REQUEST', `工作目录不在电脑侧既有目录内:${params.workspace}`)
|
|
910
|
+
}
|
|
911
|
+
workspace = match
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Agent:**与真实电脑侧同一条防线** —— id 必须来自 `host.agent.list` 的候选集,
|
|
915
|
+
* 手机不能自造(未知 id 即 `E_BAD_REQUEST`)。不传 = 默认 Agent。
|
|
916
|
+
*/
|
|
917
|
+
const agent =
|
|
918
|
+
params.agentId != null ? DEMO_AGENTS.find((a) => a.id === params.agentId) : undefined
|
|
919
|
+
if (params.agentId != null && !agent) {
|
|
920
|
+
throw new BridgeError('E_BAD_REQUEST', `Agent 不存在:${params.agentId}`)
|
|
921
|
+
}
|
|
922
|
+
// 模型:手机没给就用**所选 Agent** 的默认模型(与真实电脑侧 createSession 同序)
|
|
923
|
+
const model = resolveModel(
|
|
924
|
+
params.providerConfigId ?? agent?.defaultModel?.providerConfigId,
|
|
925
|
+
params.modelId ?? agent?.defaultModel?.modelId,
|
|
926
|
+
)
|
|
927
|
+
sessions.unshift({
|
|
928
|
+
id,
|
|
929
|
+
title: params.title || '手机新建的会话',
|
|
930
|
+
updatedAt: Date.now(),
|
|
931
|
+
working: false,
|
|
932
|
+
agentId: agent?.id ?? 'agent-virlen',
|
|
933
|
+
agentName: agent?.name ?? 'Virlen',
|
|
934
|
+
workspace: workspace ?? agent?.defaultWorkspace ?? DEMO_WORKSPACES[0],
|
|
935
|
+
...model,
|
|
936
|
+
})
|
|
937
|
+
messages.set(id, [])
|
|
938
|
+
contextTokens.set(id, 0)
|
|
939
|
+
// 与真实电脑侧同一条兜底(§24):**自建的会话直接纳入订阅集合** ——
|
|
940
|
+
// 客户端创建会话的意图就是要马上看它,订阅不该押在它记得再调一次 RPC 上。
|
|
941
|
+
subscribed.add(id)
|
|
942
|
+
emitList()
|
|
943
|
+
return { sessionId: id }
|
|
944
|
+
},
|
|
945
|
+
async renameSession(params: RenameSessionParams) {
|
|
946
|
+
const session = sessions.find((s) => s.id === params.sessionId)
|
|
947
|
+
if (!session) throw new BridgeError('E_NOT_FOUND', `session not found: ${params.sessionId}`)
|
|
948
|
+
session.title = params.title
|
|
949
|
+
session.updatedAt = Date.now()
|
|
950
|
+
emitList()
|
|
951
|
+
return { ok: true as const }
|
|
952
|
+
},
|
|
953
|
+
async setPinned(params: PinSessionParams) {
|
|
954
|
+
const session = sessions.find((s) => s.id === params.sessionId)
|
|
955
|
+
if (!session) throw new BridgeError('E_NOT_FOUND', `session not found: ${params.sessionId}`)
|
|
956
|
+
;(session as { pinned?: boolean }).pinned = params.pinned
|
|
957
|
+
emitList()
|
|
958
|
+
return { ok: true as const }
|
|
959
|
+
},
|
|
960
|
+
async deleteSession(params: DeleteSessionParams) {
|
|
961
|
+
if (params.confirm !== true) {
|
|
962
|
+
throw new BridgeError('E_CONFIRM_REQUIRED', '删除会话需二次确认')
|
|
963
|
+
}
|
|
964
|
+
const idx = sessions.findIndex((s) => s.id === params.sessionId)
|
|
965
|
+
if (idx === -1) throw new BridgeError('E_NOT_FOUND', `session not found: ${params.sessionId}`)
|
|
966
|
+
sessions.splice(idx, 1)
|
|
967
|
+
messages.delete(params.sessionId)
|
|
968
|
+
contextTokens.delete(params.sessionId)
|
|
969
|
+
emitList()
|
|
970
|
+
return { ok: true as const }
|
|
971
|
+
},
|
|
972
|
+
/**
|
|
973
|
+
* 删除单条消息及其之后的全部消息(截断)—— **与真实电脑侧同一条规则**:
|
|
974
|
+
* confirm 必填 / 回复中拒绝(E_BUSY)/ tool 消息不可删 / 删完推 `messages.reset`。
|
|
975
|
+
*
|
|
976
|
+
* ⚠️ mock 偏宽松 = 用例对着假行为发绿灯(§18.6 的教训),故三道闸一道不少。
|
|
977
|
+
*/
|
|
978
|
+
async deleteMessage(params: DeleteMessageParams) {
|
|
979
|
+
calls.push('host.session.message.delete')
|
|
980
|
+
if (params.confirm !== true) {
|
|
981
|
+
throw new BridgeError('E_CONFIRM_REQUIRED', '删除消息需二次确认(confirm:true)')
|
|
982
|
+
}
|
|
983
|
+
const list = messages.get(params.sessionId)
|
|
984
|
+
if (!list) throw new BridgeError('E_NOT_FOUND', `session not found: ${params.sessionId}`)
|
|
985
|
+
const session = sessions.find((s) => s.id === params.sessionId)
|
|
986
|
+
if (session?.working === true) {
|
|
987
|
+
throw new BridgeError('E_BUSY', '该会话正在回复中,请稍后再试')
|
|
988
|
+
}
|
|
989
|
+
const idx = list.findIndex((m) => m.id === params.messageId)
|
|
990
|
+
if (idx === -1) throw new BridgeError('E_NOT_FOUND', `message not found: ${params.messageId}`)
|
|
991
|
+
if (list[idx].role === 'tool') {
|
|
992
|
+
throw new BridgeError('E_BAD_REQUEST', '工具消息不能单独删除')
|
|
993
|
+
}
|
|
994
|
+
// 本条及其后全部删除,并推 `messages.reset` 让客户端重拉窗口
|
|
995
|
+
list.splice(idx)
|
|
996
|
+
if (session) session.updatedAt = Date.now()
|
|
997
|
+
emitFor(params.sessionId, 'host.event.session.messages.reset', { sessionId: params.sessionId })
|
|
998
|
+
bumpContext(params.sessionId, Math.max(0, (contextTokens.get(params.sessionId) ?? 0) - 1_000))
|
|
999
|
+
return { ok: true as const }
|
|
1000
|
+
},
|
|
1001
|
+
// ── §22:模型 / Agent / 工作目录 / 上下文 ──
|
|
1002
|
+
async listModels() {
|
|
1003
|
+
calls.push('host.model.list')
|
|
1004
|
+
return DEMO_PROVIDERS.map((p) => ({ ...p, models: [...p.models] }))
|
|
1005
|
+
},
|
|
1006
|
+
async listAgents(): Promise<AgentOptionDTO[]> {
|
|
1007
|
+
calls.push('host.agent.list')
|
|
1008
|
+
return DEMO_AGENTS.map((a) => ({
|
|
1009
|
+
...a,
|
|
1010
|
+
...(a.defaultModel ? { defaultModel: { ...a.defaultModel } } : {}),
|
|
1011
|
+
}))
|
|
1012
|
+
},
|
|
1013
|
+
async setModel(params: SetModelParams) {
|
|
1014
|
+
calls.push('host.session.setModel')
|
|
1015
|
+
const session = sessions.find((s) => s.id === params.sessionId)
|
|
1016
|
+
if (!session) throw new BridgeError('E_NOT_FOUND', `session not found: ${params.sessionId}`)
|
|
1017
|
+
Object.assign(session, resolveModel(params.providerConfigId, params.modelId))
|
|
1018
|
+
emitList()
|
|
1019
|
+
return { ok: true as const }
|
|
1020
|
+
},
|
|
1021
|
+
async listWorkspaces(): Promise<WorkspaceOptionDTO[]> {
|
|
1022
|
+
calls.push('host.workspace.list')
|
|
1023
|
+
return DEMO_WORKSPACES.map((path) => ({
|
|
1024
|
+
path,
|
|
1025
|
+
name: path.split('/').pop() ?? path,
|
|
1026
|
+
sessionCount: sessions.filter((s) => s.workspace === path).length,
|
|
1027
|
+
}))
|
|
1028
|
+
},
|
|
1029
|
+
async getContext(params: ContextParams): Promise<ContextInfoDTO> {
|
|
1030
|
+
calls.push('host.session.context')
|
|
1031
|
+
if (!sessions.some((s) => s.id === params.sessionId)) {
|
|
1032
|
+
throw new BridgeError('E_NOT_FOUND', `session not found: ${params.sessionId}`)
|
|
1033
|
+
}
|
|
1034
|
+
return contextOf(params.sessionId)
|
|
1035
|
+
},
|
|
1036
|
+
/**
|
|
1037
|
+
* 压缩上下文:**与真实电脑侧同判据**(confirm + 最小占用比例 + 方式可辨认),
|
|
1038
|
+
* 然后把消息换成一条 summary,并推 `messages.reset`(手机重拉窗口)。
|
|
1039
|
+
*
|
|
1040
|
+
* `mode` 不同则产物不同(`ai` 是一句摘要口吻、`raw` 是「历史已重排为文本」)——
|
|
1041
|
+
* 两种方式的差别在真机上恰恰就是产物形态,mock 若给同一句文本,两条支路就分不开。
|
|
1042
|
+
*/
|
|
1043
|
+
async compress(params: CompressParams) {
|
|
1044
|
+
calls.push('host.session.compress')
|
|
1045
|
+
if (params.confirm !== true) {
|
|
1046
|
+
throw new BridgeError('E_CONFIRM_REQUIRED', '压缩上下文需二次确认(confirm:true)')
|
|
1047
|
+
}
|
|
1048
|
+
// 没传 = 用缺省(真实电脑侧是「设置里的 contextCompressMode」);传了但不认识 → 拒
|
|
1049
|
+
const mode = params.mode == null ? DEFAULT_COMPRESS_MODE : compressModeOf(params.mode)
|
|
1050
|
+
if (!mode) {
|
|
1051
|
+
throw new BridgeError('E_BAD_REQUEST', `未知的压缩方式: ${String(params.mode)}`)
|
|
1052
|
+
}
|
|
1053
|
+
if (!sessions.some((s) => s.id === params.sessionId)) {
|
|
1054
|
+
throw new BridgeError('E_NOT_FOUND', `session not found: ${params.sessionId}`)
|
|
1055
|
+
}
|
|
1056
|
+
const context = contextOf(params.sessionId)
|
|
1057
|
+
const tokens = context.tokens ?? 0
|
|
1058
|
+
if (tokens < context.windowTokens * COMPRESS_MIN_RATIO) {
|
|
1059
|
+
throw new BridgeError('E_BAD_REQUEST', '当前上下文很充裕,无需压缩')
|
|
1060
|
+
}
|
|
1061
|
+
const summary: MessageDTO = {
|
|
1062
|
+
id: nextId('sum'),
|
|
1063
|
+
role: 'system',
|
|
1064
|
+
text:
|
|
1065
|
+
mode === 'raw'
|
|
1066
|
+
? '[上下文摘要] 正文压缩:整段历史已本地渲染为一段文本(正文一字未删)。'
|
|
1067
|
+
: '[上下文摘要] 之前的内容已压缩为摘要。',
|
|
1068
|
+
createdAt: Date.now(),
|
|
1069
|
+
}
|
|
1070
|
+
lastCompress = { sessionId: params.sessionId, mode }
|
|
1071
|
+
messages.set(params.sessionId, [summary])
|
|
1072
|
+
emitFor(params.sessionId, 'host.event.session.messages.reset', { sessionId: params.sessionId })
|
|
1073
|
+
emitFor(params.sessionId, 'host.event.message.added', { sessionId: params.sessionId, message: summary })
|
|
1074
|
+
bumpContext(params.sessionId, 2_000)
|
|
1075
|
+
return { ok: true as const }
|
|
1076
|
+
},
|
|
1077
|
+
/** 手动推一次占用变化(harness 按钮 / 测试用)。 */
|
|
1078
|
+
bumpContext,
|
|
1079
|
+
setToolProgress,
|
|
1080
|
+
setRunningTools,
|
|
1081
|
+
|
|
1082
|
+
// ── §37:工作目录文件(与真实电脑侧同几条纪律:非中继拒 / 分块 / 临时态不落盘)──
|
|
1083
|
+
async listFiles(params: FileListParams): Promise<FileListResult> {
|
|
1084
|
+
calls.push('host.file.list')
|
|
1085
|
+
assertFileLinkUsable()
|
|
1086
|
+
const tree = treeOf(params.sessionId)
|
|
1087
|
+
const relPath = normalizeRelPath(params.path ?? '')
|
|
1088
|
+
if (!dirExists(tree, relPath)) {
|
|
1089
|
+
throw new BridgeError('E_NOT_FOUND', `目录不存在:${relPath || '/'}`)
|
|
1090
|
+
}
|
|
1091
|
+
const { entries, truncated } = entriesOf(tree, relPath)
|
|
1092
|
+
const workspace = sessions.find((s) => s.id === params.sessionId)?.workspace ?? ''
|
|
1093
|
+
return {
|
|
1094
|
+
relPath,
|
|
1095
|
+
absPath: relPath ? `${workspace}/${relPath}` : workspace,
|
|
1096
|
+
entries,
|
|
1097
|
+
...(truncated ? { truncated: true } : {}),
|
|
1098
|
+
}
|
|
1099
|
+
},
|
|
1100
|
+
async readFile(params: FileReadParams): Promise<FileReadResult> {
|
|
1101
|
+
calls.push('host.file.read')
|
|
1102
|
+
assertFileLinkUsable()
|
|
1103
|
+
const relPath = normalizeRelPath(params.path)
|
|
1104
|
+
const file = treeOf(params.sessionId).get(relPath)
|
|
1105
|
+
if (!file) throw new BridgeError('E_NOT_FOUND', `文件不存在:${relPath}`)
|
|
1106
|
+
const bytes = file.bytes
|
|
1107
|
+
const offset = Math.max(0, Math.floor(params.offset ?? 0))
|
|
1108
|
+
const want = Math.max(1, Math.floor(params.length ?? FILE_CHUNK_BYTES))
|
|
1109
|
+
const slice = bytes.subarray(offset, offset + Math.min(want, FILE_CHUNK_BYTES))
|
|
1110
|
+
const name = baseNameOfPath(relPath)
|
|
1111
|
+
return {
|
|
1112
|
+
data: bytesToBase64(slice),
|
|
1113
|
+
offset,
|
|
1114
|
+
size: bytes.length,
|
|
1115
|
+
eof: offset + slice.length >= bytes.length,
|
|
1116
|
+
kind: previewKindOf(name),
|
|
1117
|
+
mime: mimeTypeOf(name),
|
|
1118
|
+
// 编辑保存的冲突校验靠它(手机端打开时记下、保存时回传)
|
|
1119
|
+
mtimeMs: file.mtimeMs,
|
|
1120
|
+
}
|
|
1121
|
+
},
|
|
1122
|
+
async beginFileWrite(params: FileWriteBeginParams): Promise<FileWriteBeginResult> {
|
|
1123
|
+
calls.push('host.file.write.begin')
|
|
1124
|
+
assertFileLinkUsable()
|
|
1125
|
+
const tree = treeOf(params.sessionId)
|
|
1126
|
+
const dir = normalizeRelPath(params.dir ?? '')
|
|
1127
|
+
const target = (n: string): string => (dir ? `${dir}/${n}` : n)
|
|
1128
|
+
if (!isSafeEntryName(params.name)) {
|
|
1129
|
+
throw new BridgeError('E_BAD_REQUEST', `文件名不合法:${params.name}`)
|
|
1130
|
+
}
|
|
1131
|
+
if (!Number.isFinite(params.size) || params.size < 0) {
|
|
1132
|
+
throw new BridgeError('E_BAD_REQUEST', '文件大小非法')
|
|
1133
|
+
}
|
|
1134
|
+
// 超限在**开始前**就拒(而不是传到一半才说)—— 这是 u91cf 级纪律,与真实电脑侧同一条
|
|
1135
|
+
if (params.size > FILE_UPLOAD_MAX_BYTES) {
|
|
1136
|
+
throw new BridgeError('E_BAD_REQUEST', `文件超过上限(${formatFileSize(FILE_UPLOAD_MAX_BYTES)})`)
|
|
1137
|
+
}
|
|
1138
|
+
if (!dirExists(tree, dir)) throw new BridgeError('E_NOT_FOUND', `目录不存在:${dir || '/'}`)
|
|
1139
|
+
/*
|
|
1140
|
+
* ── 覆写(编辑保存):与上传是**两条不同的路** ──
|
|
1141
|
+
* 这里的每一条都与真实电脑侧逐条对齐(否则手机端用例在 mock 上跑出来的行为不算数):
|
|
1142
|
+
* 目标必须已存在、只收可编辑的扩展名、按编辑上限夹一次、mtime/size 不符即冲突。
|
|
1143
|
+
*/
|
|
1144
|
+
if (params.overwrite) {
|
|
1145
|
+
const existing = tree.get(target(params.name))
|
|
1146
|
+
if (!existing) {
|
|
1147
|
+
throw new BridgeError('E_NOT_FOUND', `文件不存在:${target(params.name)}`)
|
|
1148
|
+
}
|
|
1149
|
+
if (!isEditableFileName(params.name)) {
|
|
1150
|
+
throw new BridgeError('E_BAD_REQUEST', `这类文件不支持编辑:${params.name}`)
|
|
1151
|
+
}
|
|
1152
|
+
if (params.size > FILE_EDIT_MAX_BYTES) {
|
|
1153
|
+
throw new BridgeError(
|
|
1154
|
+
'E_BAD_REQUEST',
|
|
1155
|
+
`文件超过编辑上限(${formatFileSize(FILE_EDIT_MAX_BYTES)})`,
|
|
1156
|
+
)
|
|
1157
|
+
}
|
|
1158
|
+
if (params.expectMtimeMs != null && params.expectMtimeMs !== existing.mtimeMs) {
|
|
1159
|
+
throw new BridgeError('E_CONFLICT', '电脑上的这份文件已经变了,请重新载入')
|
|
1160
|
+
}
|
|
1161
|
+
if (params.expectSize != null && params.expectSize !== existing.bytes.length) {
|
|
1162
|
+
throw new BridgeError('E_CONFLICT', '电脑上的这份文件已经变了,请重新载入')
|
|
1163
|
+
}
|
|
1164
|
+
const editId = nextId('up')
|
|
1165
|
+
uploads.set(editId, {
|
|
1166
|
+
sessionId: params.sessionId,
|
|
1167
|
+
dir,
|
|
1168
|
+
name: params.name,
|
|
1169
|
+
relPath: target(params.name),
|
|
1170
|
+
bytes: new Uint8Array(params.size),
|
|
1171
|
+
received: 0,
|
|
1172
|
+
overwrite: true,
|
|
1173
|
+
})
|
|
1174
|
+
return { uploadId: editId, name: params.name, relPath: target(params.name), received: 0 }
|
|
1175
|
+
}
|
|
1176
|
+
let name = params.name
|
|
1177
|
+
if (tree.has(target(name))) {
|
|
1178
|
+
if ((params.onConflict ?? 'rename') === 'reject') {
|
|
1179
|
+
throw new BridgeError('E_CONFLICT', `同名文件已存在:${name}`)
|
|
1180
|
+
}
|
|
1181
|
+
let index = 1
|
|
1182
|
+
for (; index <= 100; index++) {
|
|
1183
|
+
const candidate = duplicateNameCandidate(params.name, index)
|
|
1184
|
+
if (!tree.has(target(candidate))) {
|
|
1185
|
+
name = candidate
|
|
1186
|
+
break
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
if (index > 100) throw new BridgeError('E_CONFLICT', '同名文件过多,无法自动改名')
|
|
1190
|
+
}
|
|
1191
|
+
const uploadId = nextId('up')
|
|
1192
|
+
uploads.set(uploadId, {
|
|
1193
|
+
sessionId: params.sessionId,
|
|
1194
|
+
dir,
|
|
1195
|
+
name,
|
|
1196
|
+
relPath: target(name),
|
|
1197
|
+
bytes: new Uint8Array(params.size),
|
|
1198
|
+
received: 0,
|
|
1199
|
+
})
|
|
1200
|
+
return { uploadId, name, relPath: target(name), received: 0 }
|
|
1201
|
+
},
|
|
1202
|
+
async writeFileChunk(params: FileWriteChunkParams): Promise<FileWriteChunkResult> {
|
|
1203
|
+
calls.push('host.file.write.chunk')
|
|
1204
|
+
assertFileLinkUsable()
|
|
1205
|
+
const upload = requireUpload(params.uploadId)
|
|
1206
|
+
// 乱序即拒:拼接错位得到的是一份「看起来成功了」的坏文件,比报错难查得多
|
|
1207
|
+
if (params.offset !== upload.received) {
|
|
1208
|
+
throw new BridgeError('E_CONFLICT', `分块偏移不对:期望 ${upload.received},收到 ${params.offset}`)
|
|
1209
|
+
}
|
|
1210
|
+
const chunk = base64ToBytes(params.data)
|
|
1211
|
+
if (upload.received + chunk.length > upload.bytes.length) {
|
|
1212
|
+
throw new BridgeError('E_BAD_REQUEST', '写入字节数超过申报的文件大小')
|
|
1213
|
+
}
|
|
1214
|
+
upload.bytes.set(chunk, upload.received)
|
|
1215
|
+
upload.received += chunk.length
|
|
1216
|
+
return { received: upload.received }
|
|
1217
|
+
},
|
|
1218
|
+
async finishFileWrite(params: FileWriteFinishParams): Promise<FileWriteFinishResult> {
|
|
1219
|
+
calls.push('host.file.write.finish')
|
|
1220
|
+
assertFileLinkUsable()
|
|
1221
|
+
const upload = requireUpload(params.uploadId)
|
|
1222
|
+
// 字节数不符就不落盘:申报 1MB 只收到 300KB 时,用户在电脑上得到的是一份坏文件
|
|
1223
|
+
if (upload.received !== upload.bytes.length) {
|
|
1224
|
+
throw new BridgeError('E_BAD_REQUEST', `文件未传完:${upload.received}/${upload.bytes.length}`)
|
|
1225
|
+
}
|
|
1226
|
+
uploads.delete(params.uploadId)
|
|
1227
|
+
// 写盘即换 mtime(编辑保存的冲突校验靠它)
|
|
1228
|
+
const mtimeMs = nowMtime()
|
|
1229
|
+
treeOf(upload.sessionId).set(upload.relPath, { bytes: upload.bytes, mtimeMs })
|
|
1230
|
+
// 覆写与上传的收尾差别只有一句注释那么大:**同一个 relPath**,不做冲突改名
|
|
1231
|
+
return { name: upload.name, relPath: upload.relPath, size: upload.bytes.length, mtimeMs }
|
|
1232
|
+
},
|
|
1233
|
+
/**
|
|
1234
|
+
* 放弃上传:**不过非中继门槛**(只删自己的临时态)。
|
|
1235
|
+
*
|
|
1236
|
+
* 为何例外:上传开始后链路可能刚好被换成中继(或断了),而那时用户/手机端恰恰最需要
|
|
1237
|
+
* 把临时文件清掉。在一个只做清理的动作上又加一道链路门,只会把临时文件永久留在用户项目里。
|
|
1238
|
+
*/
|
|
1239
|
+
async abortFileWrite(params: FileWriteAbortParams) {
|
|
1240
|
+
calls.push('host.file.write.abort')
|
|
1241
|
+
uploads.delete(params.uploadId)
|
|
1242
|
+
return { ok: true as const }
|
|
1243
|
+
},
|
|
1244
|
+
/** 测试 / harness:直接读演示文件树里的一个文件(`null` = 不存在)。 */
|
|
1245
|
+
readMockFile(sessionId, relPath) {
|
|
1246
|
+
const file = treeOf(sessionId).get(normalizeRelPath(relPath))
|
|
1247
|
+
return file ? file.bytes.slice() : null
|
|
1248
|
+
},
|
|
1249
|
+
/** 测试 / harness:往演示文件树里放一个文件(目录隐式创建)——模拟「电脑上有人改了它」。 */
|
|
1250
|
+
writeMockFile(sessionId, relPath, content) {
|
|
1251
|
+
const data = typeof content === 'string' ? new TextEncoder().encode(content) : content
|
|
1252
|
+
treeOf(sessionId).set(normalizeRelPath(relPath), { bytes: data, mtimeMs: nowMtime() })
|
|
1253
|
+
},
|
|
1254
|
+
/** 测试 / harness:当前文件树里的全部相对路径(排序后)。 */
|
|
1255
|
+
listMockFiles(sessionId) {
|
|
1256
|
+
return [...treeOf(sessionId).keys()].sort()
|
|
1257
|
+
},
|
|
1258
|
+
/** 测试 / harness:最后一次真正执行的压缩(含**实际生效的方式**)。 */
|
|
1259
|
+
lastCompress() {
|
|
1260
|
+
return lastCompress
|
|
1261
|
+
},
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
return host
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
function delay(ms: number): Promise<void> {
|
|
1268
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/** 把 `providerConfigId` / `modelId` 解析成会话上的三个展示 / 切换字段(非法组合 → E_BAD_REQUEST)。 */
|
|
1272
|
+
function resolveModel(
|
|
1273
|
+
providerConfigId?: string,
|
|
1274
|
+
modelId?: string,
|
|
1275
|
+
): Pick<SessionSummaryDTO, 'providerConfigId' | 'providerName' | 'modelId'> {
|
|
1276
|
+
if (providerConfigId == null && modelId == null) {
|
|
1277
|
+
return { providerConfigId: DEMO_PROVIDERS[0].id, providerName: DEMO_PROVIDERS[0].name, modelId: DEMO_PROVIDERS[0].models[0] }
|
|
1278
|
+
}
|
|
1279
|
+
const provider = DEMO_PROVIDERS.find((p) => p.id === providerConfigId)
|
|
1280
|
+
if (!provider) throw new BridgeError('E_BAD_REQUEST', `模型服务不存在或未启用:${providerConfigId}`)
|
|
1281
|
+
if (!modelId || !provider.models.includes(modelId)) {
|
|
1282
|
+
throw new BridgeError('E_BAD_REQUEST', `该服务下没有模型:${modelId}`)
|
|
1283
|
+
}
|
|
1284
|
+
return { providerConfigId: provider.id, providerName: provider.name, modelId }
|
|
1285
|
+
}
|