@danieltmn/tamnora 0.22.0 → 0.23.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 +25 -0
- package/README.md +3 -1
- package/SECURITY.md +1 -1
- package/harness/packages/cli/bin/tamnora.mjs +9 -1
- package/harness/packages/cli/src/commands/bridge.js +3 -3
- package/harness/packages/cli/src/commands/doctor.js +42 -4
- package/harness/packages/cli/src/commands/repl.js +1 -0
- package/harness/packages/cli/src/commands/run.js +1 -0
- package/harness/packages/cli/src/commands/tui.js +1 -0
- package/harness/packages/cli/src/commands/update.js +13 -2
- package/harness/packages/cli/src/lib/runtime.js +2 -0
- package/harness/packages/cli/src/tui/app.js +2 -0
- package/harness/packages/cli/test/bin-warning.test.js +27 -0
- package/harness/packages/cli/test/tunnel.test.js +49 -1
- package/harness/packages/core/src/browser/index.js +27 -13
- package/harness/packages/core/src/index.js +1 -1
- package/harness/packages/core/src/tts/edge.js +34 -4
- package/harness/packages/core/src/tunnel.js +32 -9
- package/harness/packages/core/test/agent.test.js +18 -0
- package/harness/packages/core/test/servicios-vivos.test.js +71 -0
- package/harness/packages/core/test/tts.test.js +81 -0
- package/harness/packages/server/bin/tamnora-server.mjs +9 -1
- package/harness/packages/server/public/app.js +4 -3
- package/harness/packages/server/public/cards.js +470 -0
- package/harness/packages/server/public/chat.js +1343 -1893
- package/harness/packages/server/public/chatFormat.js +156 -0
- package/harness/packages/server/public/chrome.js +5 -4
- package/harness/packages/server/public/icons/logo_dark.png +0 -0
- package/harness/packages/server/public/icons/logo_light.png +0 -0
- package/harness/packages/server/public/index.html +22 -23
- package/harness/packages/server/public/settings.js +4 -0
- package/harness/packages/server/public/styles.css +20 -50
- package/harness/packages/server/src/lib/channels/telegram/api.js +102 -0
- package/harness/packages/server/src/lib/channels/telegram/commands.js +309 -0
- package/harness/packages/server/src/lib/channels/telegram/media.js +64 -0
- package/harness/packages/server/src/lib/channels/telegram/permissions.js +0 -0
- package/harness/packages/server/src/lib/channels/telegram/projects.js +102 -0
- package/harness/packages/server/src/lib/channels/telegram/run.js +206 -0
- package/harness/packages/server/src/lib/channels/telegram/text.js +57 -0
- package/harness/packages/server/src/lib/channels/telegram.js +163 -933
- package/harness/packages/server/src/lib/procs.js +4 -1
- package/harness/packages/server/src/lib/scheduler.js +1 -0
- package/harness/packages/server/src/lib/tasks.js +1 -0
- package/harness/packages/server/src/routes/chat.js +1 -1
- package/harness/packages/server/src/routes/providers.js +2 -0
- package/harness/packages/server/src/server.js +6 -0
- package/harness/packages/server/test/chatFormat.test.js +119 -0
- package/harness/packages/server/test/consoleModules.test.js +301 -0
- package/harness/packages/server/test/consoleSmoke.test.js +34 -0
- package/harness/packages/server/test/helpers.js +15 -1
- package/harness/packages/server/test/ops.test.js +9 -5
- package/harness/packages/server/test/procs-manager.test.js +17 -0
- package/harness/packages/server/test/settings.test.js +18 -1
- package/harness/scripts/canary.mjs +24 -0
- package/harness/scripts/self-update.mjs +4 -1
- package/package.json +3 -2
|
@@ -1,1893 +1,1343 @@
|
|
|
1
|
-
import {
|
|
2
|
-
addTokens,
|
|
3
|
-
isMobile,
|
|
4
|
-
modeSelect,
|
|
5
|
-
modelSelect,
|
|
6
|
-
openFolderPicker,
|
|
7
|
-
providerSelect,
|
|
8
|
-
setContextUsage,
|
|
9
|
-
setCwd,
|
|
10
|
-
setSidebarOpen,
|
|
11
|
-
updateStatus,
|
|
12
|
-
yesInput,
|
|
13
|
-
} from './chrome.js';
|
|
14
|
-
import { hideSlashMenu, notify, onChatInputChanged, runCommand, toast } from './commands.js';
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
import {
|
|
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
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const
|
|
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
|
-
await api(`/api/sessions/${id}
|
|
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
|
-
if (
|
|
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
|
-
const
|
|
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
|
-
export function
|
|
467
|
-
const
|
|
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
|
-
const
|
|
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
|
-
card
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
if (
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
if (
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
const
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
};
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
if (
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
if (
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
export function
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
if (
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
*
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
const
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
}
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
);
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
export function
|
|
1187
|
-
const
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
const
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
if (
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
if (
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
addMessage('user', content, turn.pane);
|
|
1345
|
-
turn.sawUser = true;
|
|
1346
|
-
} else if (event === 'text') {
|
|
1347
|
-
turn.sawText = true;
|
|
1348
|
-
const target = turn.flowTarget();
|
|
1349
|
-
setText(target, data.full);
|
|
1350
|
-
setActivity(target, 'thinking');
|
|
1351
|
-
} else if (event === 'reasoning') {
|
|
1352
|
-
const target = turn.flowTarget();
|
|
1353
|
-
if (!turn.sawText) ensureReasoning(target).textContent = data.full;
|
|
1354
|
-
setActivity(target, 'thinking');
|
|
1355
|
-
} else if (event === 'tools') {
|
|
1356
|
-
turn.startNewOnText = true;
|
|
1357
|
-
} else if (event === 'tool_call') {
|
|
1358
|
-
const target = turn.assistant();
|
|
1359
|
-
setActivity(target, 'working');
|
|
1360
|
-
turn.lastTool = addToolCard(target, data.name, data.args, { callId: data.id, summary: data.summary });
|
|
1361
|
-
if (turn.live && data.name === 'speak') {
|
|
1362
|
-
const texto = String(data.args?.texto ?? '').trim();
|
|
1363
|
-
if (texto) reproducirVoz(texto, { voice: data.args?.voice, rate: data.args?.rate, pitch: data.args?.pitch, label: 'voz' }).catch(() => {});
|
|
1364
|
-
}
|
|
1365
|
-
if (turn.live && data.name === 'preview_url' && data.args?.url) {
|
|
1366
|
-
setExplorerMode('preview');
|
|
1367
|
-
// Desde otro dispositivo, 127.0.0.1 apunta al dispositivo: si el agente
|
|
1368
|
-
// pidio una URL local, la publicamos por tailscale/tunel.
|
|
1369
|
-
const port = isLocalHost() ? 0 : localPortOf(data.args.url);
|
|
1370
|
-
if (port) openRemoteTunnel(port);
|
|
1371
|
-
else loadPreview(data.args.url);
|
|
1372
|
-
}
|
|
1373
|
-
if (turn.live && data.name === 'offer_choices') {
|
|
1374
|
-
addChoiceCard(target, { question: data.args?.question, options: data.args?.options });
|
|
1375
|
-
}
|
|
1376
|
-
if (turn.live && data.name === 'show_plan') {
|
|
1377
|
-
addPlanCard(target, { title: data.args?.title, steps: data.args?.steps });
|
|
1378
|
-
}
|
|
1379
|
-
} else if (event === 'tool_result') {
|
|
1380
|
-
setActivity(turn.assistant(), 'thinking');
|
|
1381
|
-
finishToolCard(turn.lastTool, data);
|
|
1382
|
-
} else if (event === 'question') {
|
|
1383
|
-
if (!liveQuestion(data.askId)) addQuestionCard(turn.assistant(), data);
|
|
1384
|
-
state.pendingQuestions.set(data.askId, { sessionId: data.sessionId ?? turn.sessionKey });
|
|
1385
|
-
renderAlertSignals();
|
|
1386
|
-
if (paneOf(turn.active) !== activePane) toast('Formulario pendiente en otra pestaña.');
|
|
1387
|
-
} else if (event === 'question_answered') {
|
|
1388
|
-
state.pendingQuestions.delete(data?.askId);
|
|
1389
|
-
renderAlertSignals();
|
|
1390
|
-
const entry = state.questionCards.get(data.askId);
|
|
1391
|
-
if (entry && !entry.card.classList.contains('answered')) {
|
|
1392
|
-
entry.card.classList.add('answered');
|
|
1393
|
-
entry.status.textContent = data?.cancelled ? 'cancelado' : 'respondido en otra ventana';
|
|
1394
|
-
for (const { input } of entry.fields) input.disabled = true;
|
|
1395
|
-
}
|
|
1396
|
-
} else if (event === 'permission') {
|
|
1397
|
-
// Si la tarjeta ya esta en el DOM (la creo `renderPane` antes del replay, o
|
|
1398
|
-
// llego por el canal global) no hay que crear OTRO mensaje del asistente
|
|
1399
|
-
// vacio para colgarla.
|
|
1400
|
-
const existing = livePermission(data.askId);
|
|
1401
|
-
const target = existing ? existing.message : turn.assistant();
|
|
1402
|
-
if (!existing) addPermissionCard(target, data);
|
|
1403
|
-
state.pendingAsks.set(data.askId, {
|
|
1404
|
-
sessionId: data.sessionId ?? turn.sessionKey,
|
|
1405
|
-
name: data.name ?? '',
|
|
1406
|
-
args: data.args ?? {},
|
|
1407
|
-
});
|
|
1408
|
-
renderAlertSignals();
|
|
1409
|
-
// La tarjeta vive solo en el chat de esta sesion. Si el permiso cae en otra
|
|
1410
|
-
// pestaña, sin este aviso la tarjeta queda enterrada y el run bloqueado
|
|
1411
|
-
// esperando una decision que nadie ve.
|
|
1412
|
-
if (paneOf(turn.active) !== activePane) {
|
|
1413
|
-
toast(`Permiso pendiente: ${data.name} en otra pestaña.`);
|
|
1414
|
-
}
|
|
1415
|
-
} else if (event === 'permission_resolved') {
|
|
1416
|
-
setActivity(turn.assistant(), 'thinking');
|
|
1417
|
-
state.pendingAsks.delete(data?.askId);
|
|
1418
|
-
renderAlertSignals();
|
|
1419
|
-
const entry = state.permissionCards.get(data.askId);
|
|
1420
|
-
// Solo entra aca si el veredicto sigue pendiente, o sea que **otra** ventana
|
|
1421
|
-
// (la PC, otro celular, Telegram) lo resolvio: decirlo evita que el celular
|
|
1422
|
-
// muestre "aprobado" como si hubiera sido su decision.
|
|
1423
|
-
if (entry && entry.verdict.textContent === 'requiere aprobación') {
|
|
1424
|
-
resolvePermissionCard(entry, data.allow);
|
|
1425
|
-
if (paneOf(turn.assistant()) === activePane) {
|
|
1426
|
-
toast(`Permiso ${data.allow ? 'aprobado' : 'denegado'} desde otra ventana.`);
|
|
1427
|
-
}
|
|
1428
|
-
}
|
|
1429
|
-
} else if (event === 'stall') {
|
|
1430
|
-
if (!turn.sawText) setText(turn.assistant(), `⚠ ${data.seconds} s sin respuesta del modelo (proveedor caído o muy lento). Sigo esperando...`);
|
|
1431
|
-
} else if (event === 'retry') {
|
|
1432
|
-
if (!turn.sawText) setText(turn.assistant(), `↻ reintentando (${data.attempt}) en ${Math.round((data.delayMs || 0) / 1000)} s...`);
|
|
1433
|
-
} else if (event === 'trim') {
|
|
1434
|
-
turn.assistant().tools.appendChild(el('div', 'chip', `✂ contexto recortado (${data.trimmed} resultados, ${data.dropped} mensajes)`));
|
|
1435
|
-
} else if (event === 'repeat') {
|
|
1436
|
-
turn.assistant().tools.appendChild(el('div', 'chip warn', `↻ ${data.name} repetida sin cambios`));
|
|
1437
|
-
} else if (event === 'usage') {
|
|
1438
|
-
setContextUsage(data.tokens);
|
|
1439
|
-
} else if (event === 'warning') {
|
|
1440
|
-
turn.assistant().tools.appendChild(el('div', 'chip', `⚠ ${data.message}`));
|
|
1441
|
-
} else if (event === 'error') {
|
|
1442
|
-
setActivity(turn.active, null);
|
|
1443
|
-
turn.assistant().tools.appendChild(el('div', 'chip err', `✗ ${data.message}`));
|
|
1444
|
-
} else if (event === 'done') {
|
|
1445
|
-
setActivity(turn.active, null);
|
|
1446
|
-
const finalText = (turn.active?.textEl?.textContent ?? '').trim();
|
|
1447
|
-
const tools = Number(data.toolCalls) || 0;
|
|
1448
|
-
if (!finalText) {
|
|
1449
|
-
if (data.stopReason === 'no_progress') {
|
|
1450
|
-
setText(turn.assistant(), '⚠ El agente se quedó repitiendo las mismas herramientas sin avanzar.');
|
|
1451
|
-
} else if (data.stopReason === 'budget') {
|
|
1452
|
-
setText(turn.assistant(), '⚠ El agente alcanzó el presupuesto de tokens/tiempo sin respuesta final.');
|
|
1453
|
-
} else if (data.stopReason === 'max_rounds') {
|
|
1454
|
-
setText(
|
|
1455
|
-
turn.assistant(),
|
|
1456
|
-
`⚠ El agente alcanzó el máximo de rondas (${data.rounds ?? '?'}) sin dejar una respuesta final.${
|
|
1457
|
-
tools ? ` Hizo ${tools} llamadas a herramientas.` : ''
|
|
1458
|
-
}`
|
|
1459
|
-
);
|
|
1460
|
-
} else if (tools) {
|
|
1461
|
-
setText(turn.assistant(), '✓ El agente terminó sus herramientas, pero no devolvió un mensaje final.');
|
|
1462
|
-
} else {
|
|
1463
|
-
setText(turn.assistant(), '(sin texto)');
|
|
1464
|
-
}
|
|
1465
|
-
}
|
|
1466
|
-
renderCompletion(turn.active, data);
|
|
1467
|
-
if (data.continuable) addContinueButton(turn, data);
|
|
1468
|
-
updateStatus({ rounds: data.rounds, stopReason: data.stopReason });
|
|
1469
|
-
addTokens(data.tokens);
|
|
1470
|
-
} else if (event === 'aborted') {
|
|
1471
|
-
setActivity(turn.active, null);
|
|
1472
|
-
setText(turn.assistant(), '⏹ cancelado.');
|
|
1473
|
-
} else if (event === 'resumed') {
|
|
1474
|
-
// Reconexion al stream de la sesion: el server dice hasta donde replayo.
|
|
1475
|
-
turn.cursor = Number(data.cursor) || turn.cursor;
|
|
1476
|
-
if (data.active === false) closeTurn(turn, 'El turno ya había terminado.');
|
|
1477
|
-
} else if (event === 'idle') {
|
|
1478
|
-
// El server cerro el stream porque no hay nada corriendo.
|
|
1479
|
-
closeTurn(turn, '');
|
|
1480
|
-
}
|
|
1481
|
-
}
|
|
1482
|
-
|
|
1483
|
-
/**
|
|
1484
|
-
* Cierra un turno cuyo stream se agoto sin `done`: deja el mensaje en un estado
|
|
1485
|
-
* coherente en vez de colgado en "pensando...".
|
|
1486
|
-
*/
|
|
1487
|
-
export function closeTurn(turn, text) {
|
|
1488
|
-
setActivity(turn.active, null);
|
|
1489
|
-
const message = turn.assistant();
|
|
1490
|
-
if (!text) return;
|
|
1491
|
-
if ((message.textEl?.textContent ?? '').trim()) return;
|
|
1492
|
-
setText(message, text);
|
|
1493
|
-
}
|
|
1494
|
-
|
|
1495
|
-
/**
|
|
1496
|
-
* El mensaje del usuario se emite antes de persistirse (routes/chat.js), asi que
|
|
1497
|
-
* un cliente que se engancha a mitad de run no lo tiene todavia. Y al reconectar
|
|
1498
|
-
* el mismo evento se vuelve a reproducir, cuando `renderPane` ya lo dibujo desde
|
|
1499
|
-
* SQLite: por eso la doble guarda.
|
|
1500
|
-
*/
|
|
1501
|
-
export function lastMessageMatches(pane, role, text) {
|
|
1502
|
-
const nodes = pane.querySelectorAll(':scope > .msg');
|
|
1503
|
-
const last = nodes[nodes.length - 1];
|
|
1504
|
-
if (!last || !last.classList.contains(role)) return false;
|
|
1505
|
-
return last.textContent.trim() === String(text ?? '').trim();
|
|
1506
|
-
}
|
|
1507
|
-
|
|
1508
|
-
/**
|
|
1509
|
-
* Dueña de un run: mantiene el stream vivo hasta que el turno termina.
|
|
1510
|
-
*
|
|
1511
|
-
* Un corte del tunel (4G, pantalla bloqueada, proxy) deja el `fetch` colgado
|
|
1512
|
-
* sin error, asi que reconectamos con backoff desde el ultimo cursor en vez de
|
|
1513
|
-
* dejar la UI en "pensando..." para siempre. El server honra `?from=`, asi que
|
|
1514
|
-
* reanudar no duplica eventos.
|
|
1515
|
-
*
|
|
1516
|
-
* `rerender` solo aplica al primer intento: al reconectar NO hay que volver a
|
|
1517
|
-
* pintar el pane, porque borraria el mensaje del asistente en vuelo y sus
|
|
1518
|
-
* tarjetas de tools.
|
|
1519
|
-
*/
|
|
1520
|
-
export async function followRunSession(sessionKey, turn, { from = 0, rerender = false, attempts = 6 } = {}) {
|
|
1521
|
-
const record = state.panes.get(sessionKey);
|
|
1522
|
-
const controller = new AbortController();
|
|
1523
|
-
controllers.set(sessionKey, controller);
|
|
1524
|
-
if (record) setWorking(sessionKey, true);
|
|
1525
|
-
let needsRerender = rerender;
|
|
1526
|
-
let chip = null;
|
|
1527
|
-
|
|
1528
|
-
try {
|
|
1529
|
-
await followRun({
|
|
1530
|
-
attempts,
|
|
1531
|
-
from,
|
|
1532
|
-
signal: controller.signal,
|
|
1533
|
-
open: async (cursor) => {
|
|
1534
|
-
if (needsRerender) {
|
|
1535
|
-
needsRerender = false;
|
|
1536
|
-
await renderPane(sessionKey);
|
|
1537
|
-
}
|
|
1538
|
-
await openStream(
|
|
1539
|
-
`/api/sessions/${sessionKey}/stream?from=${cursor}`,
|
|
1540
|
-
{ signal: controller.signal },
|
|
1541
|
-
(event, data, id) => {
|
|
1542
|
-
if (id !== null) turn.cursor = Number(id) || turn.cursor;
|
|
1543
|
-
handleChatEvent(event, data, turn);
|
|
1544
|
-
}
|
|
1545
|
-
);
|
|
1546
|
-
return { finished: true, cursor: turn.cursor };
|
|
1547
|
-
},
|
|
1548
|
-
onRetry: ({ attempt, delay }) => {
|
|
1549
|
-
setActivity(turn.active, 'reconnecting');
|
|
1550
|
-
if (!chip) {
|
|
1551
|
-
chip = el('div', 'chip warn', '⟳ reconectando…');
|
|
1552
|
-
turn.assistant().tools.appendChild(chip);
|
|
1553
|
-
}
|
|
1554
|
-
chip.textContent = `⟳ reconectando (intento ${attempt}, en ${Math.round(delay / 1000)} s)…`;
|
|
1555
|
-
},
|
|
1556
|
-
});
|
|
1557
|
-
} catch (error) {
|
|
1558
|
-
// Un error del handler (no del transporte) no reintenta: es un bug de la
|
|
1559
|
-
// consola y dejarlo pasar deja un "reconectando" que no dice nada.
|
|
1560
|
-
if (error?.eventHandler) {
|
|
1561
|
-
setActivity(turn.active, null);
|
|
1562
|
-
toast(`Error de la consola al procesar el stream: ${error.message}`);
|
|
1563
|
-
}
|
|
1564
|
-
throw error;
|
|
1565
|
-
} finally {
|
|
1566
|
-
controllers.delete(sessionKey);
|
|
1567
|
-
if (chip) chip.remove();
|
|
1568
|
-
if (state.panes.get(sessionKey) === record) setWorking(sessionKey, false);
|
|
1569
|
-
setActivity(turn.active, null);
|
|
1570
|
-
renderAlertSignals();
|
|
1571
|
-
reconcile({ immediate: true });
|
|
1572
|
-
}
|
|
1573
|
-
}
|
|
1574
|
-
|
|
1575
|
-
/**
|
|
1576
|
-
* Se engancha a un run en curso: re-renderiza el historial persistido y
|
|
1577
|
-
* reproduce los eventos del turno actual (asi el progreso no se pierde si el
|
|
1578
|
-
* telefono bloqueo la pantalla o se corto la red).
|
|
1579
|
-
*/
|
|
1580
|
-
export async function attachRun(sessionKey, pane) {
|
|
1581
|
-
const record = state.panes.get(sessionKey);
|
|
1582
|
-
if (record?.working) return;
|
|
1583
|
-
// `reconcile` puede pedirlo dos veces seguidas (sweep + canal global).
|
|
1584
|
-
if (controllers.has(sessionKey)) return;
|
|
1585
|
-
const turn = createChatTurn(pane, sessionKey);
|
|
1586
|
-
await followRunSession(sessionKey, turn, { from: 0, rerender: true });
|
|
1587
|
-
}
|
|
1588
|
-
|
|
1589
|
-
export function contextTokensForRequest() {
|
|
1590
|
-
const context = Number(modelSelect.selectedOptions[0]?.dataset.context) || 0;
|
|
1591
|
-
return context ? Math.floor(context * 0.8) : undefined;
|
|
1592
|
-
}
|
|
1593
|
-
|
|
1594
|
-
export async function startTurn(sessionKey, pane, input, images, opts = {}) {
|
|
1595
|
-
const user = addMessage('user', input || `(${images.length} imagen${images.length > 1 ? 'es' : ''})`, pane);
|
|
1596
|
-
appendImages(user, images);
|
|
1597
|
-
const turn = createChatTurn(pane, sessionKey);
|
|
1598
|
-
// Lo dibujamos nosotros, asi que el evento `user` del server no lo repite.
|
|
1599
|
-
turn.sawUser = true;
|
|
1600
|
-
turn.live = true;
|
|
1601
|
-
turn.active = addMessage('assistant', '', pane);
|
|
1602
|
-
setMessageMode(turn.active, turn.turnMode);
|
|
1603
|
-
const controller = new AbortController();
|
|
1604
|
-
controllers.set(sessionKey, controller);
|
|
1605
|
-
setWorking(sessionKey, true);
|
|
1606
|
-
|
|
1607
|
-
// El tipo de agente lo manda la sesion (no los controles globales): asi dos
|
|
1608
|
-
// pestañas con modelos distintos no se pisan.
|
|
1609
|
-
const owner = state.panes.get(sessionKey);
|
|
1610
|
-
try {
|
|
1611
|
-
await streamPost(
|
|
1612
|
-
'/api/chat',
|
|
1613
|
-
{
|
|
1614
|
-
input: input || '(imagen adjunta)',
|
|
1615
|
-
images: images.length ? images : undefined,
|
|
1616
|
-
sessionId: sessionKey,
|
|
1617
|
-
provider: owner?.provider || providerSelect.value || undefined,
|
|
1618
|
-
model: owner?.model || modelSelect.value || undefined,
|
|
1619
|
-
mode: owner?.mode || modeSelect.value || undefined,
|
|
1620
|
-
yes: yesInput.checked ? true : undefined,
|
|
1621
|
-
contextTokens: contextTokensForRequest(),
|
|
1622
|
-
interrupt: opts.interrupt ? true : undefined,
|
|
1623
|
-
},
|
|
1624
|
-
(event, data, id) => {
|
|
1625
|
-
if (id !== null) turn.cursor = Number(id) || turn.cursor;
|
|
1626
|
-
handleChatEvent(event, data, turn);
|
|
1627
|
-
},
|
|
1628
|
-
controller.signal
|
|
1629
|
-
);
|
|
1630
|
-
} catch (error) {
|
|
1631
|
-
if (error.name === 'AbortError') {
|
|
1632
|
-
setActivity(turn.active, null);
|
|
1633
|
-
setText(turn.assistant(), '⏹ cancelado.');
|
|
1634
|
-
} else if (error.status === 409) {
|
|
1635
|
-
// Otro cliente (celular, Telegram) tiene el turno: este mensaje no se
|
|
1636
|
-
// guardo, asi que sacamos lo que se dibujo y va a la cola visible.
|
|
1637
|
-
const bubble = turn.active;
|
|
1638
|
-
turn.active = null;
|
|
1639
|
-
user.wrap.remove();
|
|
1640
|
-
bubble?.wrap.remove();
|
|
1641
|
-
enqueueMessage(sessionKey, input, images);
|
|
1642
|
-
toast('La sesión ya tiene una respuesta en curso; tu mensaje quedó en la cola.');
|
|
1643
|
-
} else {
|
|
1644
|
-
// El POST se cayo pero el run puede seguir vivo: seguimos el turno con el
|
|
1645
|
-
// mismo `turn` para no perder las tools ya dibujadas.
|
|
1646
|
-
setActivity(turn.active, 'reconnecting');
|
|
1647
|
-
await followRunSession(sessionKey, turn, { from: turn.cursor, rerender: false });
|
|
1648
|
-
}
|
|
1649
|
-
} finally {
|
|
1650
|
-
if (controllers.get(sessionKey) === controller) controllers.delete(sessionKey);
|
|
1651
|
-
setActivity(turn.active, null);
|
|
1652
|
-
setWorking(sessionKey, false);
|
|
1653
|
-
renderAlertSignals();
|
|
1654
|
-
drainQueue(sessionKey);
|
|
1655
|
-
}
|
|
1656
|
-
}
|
|
1657
|
-
|
|
1658
|
-
/* --- cola de mensajes (enviar mientras el agente trabaja) ----------------- */
|
|
1659
|
-
|
|
1660
|
-
export function queueFor(sessionKey, create = false) {
|
|
1661
|
-
if (!state.queues.has(sessionKey)) {
|
|
1662
|
-
if (!create) return [];
|
|
1663
|
-
state.queues.set(sessionKey, []);
|
|
1664
|
-
}
|
|
1665
|
-
return state.queues.get(sessionKey);
|
|
1666
|
-
}
|
|
1667
|
-
|
|
1668
|
-
export function enqueueMessage(sessionKey, input, images) {
|
|
1669
|
-
queueFor(sessionKey, true).push({ id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, input, images });
|
|
1670
|
-
renderQueue(sessionKey);
|
|
1671
|
-
}
|
|
1672
|
-
|
|
1673
|
-
export function renderQueue(sessionKey = state.sessionId) {
|
|
1674
|
-
const queueEl = $('#chat-queue');
|
|
1675
|
-
if (!queueEl || sessionKey !== state.sessionId) return;
|
|
1676
|
-
clear(queueEl);
|
|
1677
|
-
const items = queueFor(sessionKey);
|
|
1678
|
-
queueEl.hidden = !items.length;
|
|
1679
|
-
if (!items.length) return;
|
|
1680
|
-
queueEl.appendChild(el('div', 'queue-label', `en cola (${items.length})`));
|
|
1681
|
-
for (const item of items) {
|
|
1682
|
-
const row = el('div', 'queue-item');
|
|
1683
|
-
const text = item.input || `(${item.images.length} imagen${item.images.length > 1 ? 'es' : ''})`;
|
|
1684
|
-
row.append(el('span', 'queue-text', text));
|
|
1685
|
-
row.appendChild(
|
|
1686
|
-
button('✕', () => {
|
|
1687
|
-
const list = queueFor(sessionKey);
|
|
1688
|
-
const index = list.findIndex((entry) => entry.id === item.id);
|
|
1689
|
-
if (index >= 0) list.splice(index, 1);
|
|
1690
|
-
renderQueue(sessionKey);
|
|
1691
|
-
}, 'queue-remove')
|
|
1692
|
-
);
|
|
1693
|
-
queueEl.appendChild(row);
|
|
1694
|
-
}
|
|
1695
|
-
}
|
|
1696
|
-
|
|
1697
|
-
export function drainQueue(sessionKey) {
|
|
1698
|
-
const record = state.panes.get(sessionKey);
|
|
1699
|
-
if (!record || record.working) return;
|
|
1700
|
-
// Mientras se interrumpe, el mensaje que manda el usuario lo arranca
|
|
1701
|
-
// `interruptAndSend`, no la cola.
|
|
1702
|
-
if (interrupting.has(sessionKey)) return;
|
|
1703
|
-
const list = queueFor(sessionKey);
|
|
1704
|
-
if (!list.length) return;
|
|
1705
|
-
const next = list.shift();
|
|
1706
|
-
renderQueue(sessionKey);
|
|
1707
|
-
startTurn(sessionKey, record.el, next.input, next.images);
|
|
1708
|
-
}
|
|
1709
|
-
|
|
1710
|
-
/* --- shell directo con "!" ------------------------------------------------ */
|
|
1711
|
-
|
|
1712
|
-
export async function runShell(command) {
|
|
1713
|
-
const record = state.panes.get(state.sessionId);
|
|
1714
|
-
const pane = activePane ?? record?.el;
|
|
1715
|
-
if (!pane) return;
|
|
1716
|
-
addMessage('user', `! ${command}`, pane);
|
|
1717
|
-
const message = addMessage('assistant', '', pane);
|
|
1718
|
-
message.textEl.appendChild(el('pre', 'shell-out', 'ejecutando…'));
|
|
1719
|
-
const data = await api('/api/shell', {
|
|
1720
|
-
method: 'POST',
|
|
1721
|
-
body: { command, cwd: record?.cwd || state.cwd || undefined },
|
|
1722
|
-
}).catch((error) => ({ error: error.message }));
|
|
1723
|
-
if (data?.error) {
|
|
1724
|
-
message.textEl.textContent = '';
|
|
1725
|
-
message.textEl.classList.add('notice', 'err');
|
|
1726
|
-
message.textEl.textContent = data.error;
|
|
1727
|
-
return;
|
|
1728
|
-
}
|
|
1729
|
-
message.textEl.textContent = '';
|
|
1730
|
-
const pre = el('pre', 'shell-out');
|
|
1731
|
-
pre.textContent = (data.output || '').trim() || '(sin salida)';
|
|
1732
|
-
message.textEl.appendChild(pre);
|
|
1733
|
-
scheduleScrollNav();
|
|
1734
|
-
}
|
|
1735
|
-
|
|
1736
|
-
export async function sendMessage() {
|
|
1737
|
-
const current = state.panes.get(state.sessionId);
|
|
1738
|
-
hideSlashMenu();
|
|
1739
|
-
let input = chatInput.value.trim();
|
|
1740
|
-
const images = state.attachments.slice();
|
|
1741
|
-
|
|
1742
|
-
// "!" ejecuta un comando de shell directo, sin pasar por el modelo.
|
|
1743
|
-
if (input.startsWith('!')) {
|
|
1744
|
-
const command = input.slice(1).trim();
|
|
1745
|
-
chatInput.value = '';
|
|
1746
|
-
state.attachments = [];
|
|
1747
|
-
renderAttachments();
|
|
1748
|
-
if (command) runShell(command);
|
|
1749
|
-
return;
|
|
1750
|
-
}
|
|
1751
|
-
|
|
1752
|
-
if (input.startsWith('/') && !images.length) {
|
|
1753
|
-
const name = input.slice(1).split(/\s+/)[0].toLowerCase();
|
|
1754
|
-
const arg = input.slice(1 + name.length).trim();
|
|
1755
|
-
// Limpiamos el input de una: hay comandos lentos (compactar, resumir) y no
|
|
1756
|
-
// queremos dejar el comando escrito mientras corren.
|
|
1757
|
-
chatInput.value = '';
|
|
1758
|
-
onChatInputChanged();
|
|
1759
|
-
const outcome = await runCommand(name, arg);
|
|
1760
|
-
if (!outcome || outcome.handled) return;
|
|
1761
|
-
input = outcome.input ?? input;
|
|
1762
|
-
}
|
|
1763
|
-
if (!input && !images.length) return;
|
|
1764
|
-
if (!state.sessionId) {
|
|
1765
|
-
state.pendingInput = input;
|
|
1766
|
-
openFolderPicker();
|
|
1767
|
-
return;
|
|
1768
|
-
}
|
|
1769
|
-
|
|
1770
|
-
// Si el agente está trabajando, el mensaje queda en cola y se envía solo.
|
|
1771
|
-
// Para cortarlo y mandar el mensaje ya, está el botón/atajo de interrumpir.
|
|
1772
|
-
if (current?.working) {
|
|
1773
|
-
enqueueMessage(state.sessionId, input, images);
|
|
1774
|
-
chatInput.value = '';
|
|
1775
|
-
state.attachments = [];
|
|
1776
|
-
renderAttachments();
|
|
1777
|
-
syncInterruptBtn();
|
|
1778
|
-
return;
|
|
1779
|
-
}
|
|
1780
|
-
|
|
1781
|
-
if (state.pendingEdit) {
|
|
1782
|
-
await api(`/api/sessions/${state.sessionId}/truncate`, {
|
|
1783
|
-
method: 'POST',
|
|
1784
|
-
body: { fromId: state.pendingEdit.fromId },
|
|
1785
|
-
}).catch(() => {});
|
|
1786
|
-
await reloadPane(state.sessionId);
|
|
1787
|
-
state.pendingEdit = null;
|
|
1788
|
-
}
|
|
1789
|
-
|
|
1790
|
-
chatInput.value = '';
|
|
1791
|
-
state.attachments = [];
|
|
1792
|
-
renderAttachments();
|
|
1793
|
-
await startTurn(state.sessionId, activePane, input, images);
|
|
1794
|
-
}
|
|
1795
|
-
|
|
1796
|
-
/**
|
|
1797
|
-
* Interrumpe el turno en curso y manda `input`/`images` ya mismo.
|
|
1798
|
-
*
|
|
1799
|
-
* Marcamos la sesion en `interrupting` para que el `finally` del turno viejo no
|
|
1800
|
-
* dispare la cola (lo arrancamos nosotros). Esperamos a que el turno anterior se
|
|
1801
|
-
* desenrolle antes de empezar el nuevo; el server, por su lado, cancela el run y
|
|
1802
|
-
* espera a que termine antes de aceptar el POST con `interrupt: true`.
|
|
1803
|
-
*/
|
|
1804
|
-
export async function interruptAndSend(sessionKey, input, images) {
|
|
1805
|
-
const record = state.panes.get(sessionKey);
|
|
1806
|
-
if (!record) return;
|
|
1807
|
-
interrupting.add(sessionKey);
|
|
1808
|
-
controllers.get(sessionKey)?.abort();
|
|
1809
|
-
await whenIdle(sessionKey);
|
|
1810
|
-
const promise = startTurn(sessionKey, record.el, input, images, { interrupt: true });
|
|
1811
|
-
// startTurn ya arranco (working=true y controller nuevo); soltamos el guardia
|
|
1812
|
-
// para que SU finally pueda drenar la cola.
|
|
1813
|
-
interrupting.delete(sessionKey);
|
|
1814
|
-
await promise;
|
|
1815
|
-
}
|
|
1816
|
-
|
|
1817
|
-
export function sendInterrupt() {
|
|
1818
|
-
const record = state.panes.get(state.sessionId);
|
|
1819
|
-
if (!record?.working) return;
|
|
1820
|
-
const input = chatInput.value.trim();
|
|
1821
|
-
// Los comandos (`/…`, `!…`) se resuelven por su camino normal, no interrumpen.
|
|
1822
|
-
if (input.startsWith('/') || input.startsWith('!')) return sendMessage();
|
|
1823
|
-
const images = state.attachments.slice();
|
|
1824
|
-
if (!input && !images.length) return;
|
|
1825
|
-
chatInput.value = '';
|
|
1826
|
-
state.attachments = [];
|
|
1827
|
-
renderAttachments();
|
|
1828
|
-
syncInterruptBtn();
|
|
1829
|
-
interruptAndSend(state.sessionId, input, images);
|
|
1830
|
-
}
|
|
1831
|
-
|
|
1832
|
-
/* --- image attachments ---------------------------------------------------- */
|
|
1833
|
-
|
|
1834
|
-
export const attachInput = $('#image-input');
|
|
1835
|
-
export const attachPreview = $('#attach-preview');
|
|
1836
|
-
|
|
1837
|
-
export function renderAttachments() {
|
|
1838
|
-
clear(attachPreview);
|
|
1839
|
-
attachPreview.hidden = !state.attachments.length;
|
|
1840
|
-
state.attachments.forEach((url, index) => {
|
|
1841
|
-
const thumb = el('div', 'attach-thumb');
|
|
1842
|
-
const img = el('img');
|
|
1843
|
-
img.src = url;
|
|
1844
|
-
img.alt = `adjunto ${index + 1}`;
|
|
1845
|
-
const remove = button('', () => {
|
|
1846
|
-
state.attachments.splice(index, 1);
|
|
1847
|
-
renderAttachments();
|
|
1848
|
-
}, 'attach-remove');
|
|
1849
|
-
remove.appendChild(icon('x', 12));
|
|
1850
|
-
thumb.append(img, remove);
|
|
1851
|
-
attachPreview.appendChild(thumb);
|
|
1852
|
-
});
|
|
1853
|
-
}
|
|
1854
|
-
|
|
1855
|
-
export function readAsDataUrl(file) {
|
|
1856
|
-
return new Promise((resolve, reject) => {
|
|
1857
|
-
const reader = new FileReader();
|
|
1858
|
-
reader.onerror = () => reject(new Error('No se pudo leer la imagen'));
|
|
1859
|
-
reader.onload = () => resolve(reader.result);
|
|
1860
|
-
reader.readAsDataURL(file);
|
|
1861
|
-
});
|
|
1862
|
-
}
|
|
1863
|
-
|
|
1864
|
-
export async function fileToDataUrl(file) {
|
|
1865
|
-
if (file.type === 'image/gif' || file.size <= 256 * 1024) return readAsDataUrl(file);
|
|
1866
|
-
const url = await readAsDataUrl(file);
|
|
1867
|
-
return new Promise((resolve) => {
|
|
1868
|
-
const img = new Image();
|
|
1869
|
-
img.onerror = () => resolve(url);
|
|
1870
|
-
img.onload = () => {
|
|
1871
|
-
const scale = Math.min(1, 1600 / Math.max(img.width, img.height));
|
|
1872
|
-
const canvas = document.createElement('canvas');
|
|
1873
|
-
canvas.width = Math.max(1, Math.round(img.width * scale));
|
|
1874
|
-
canvas.height = Math.max(1, Math.round(img.height * scale));
|
|
1875
|
-
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
1876
|
-
resolve(canvas.toDataURL('image/jpeg', 0.85));
|
|
1877
|
-
};
|
|
1878
|
-
img.src = url;
|
|
1879
|
-
});
|
|
1880
|
-
}
|
|
1881
|
-
|
|
1882
|
-
export async function addImageFiles(files) {
|
|
1883
|
-
for (const file of files) {
|
|
1884
|
-
if (!file?.type?.startsWith('image/')) continue;
|
|
1885
|
-
try {
|
|
1886
|
-
state.attachments.push(await fileToDataUrl(file));
|
|
1887
|
-
} catch {
|
|
1888
|
-
// skip unreadable files
|
|
1889
|
-
}
|
|
1890
|
-
}
|
|
1891
|
-
renderAttachments();
|
|
1892
|
-
}
|
|
1893
|
-
|
|
1
|
+
import {
|
|
2
|
+
addTokens,
|
|
3
|
+
isMobile,
|
|
4
|
+
modeSelect,
|
|
5
|
+
modelSelect,
|
|
6
|
+
openFolderPicker,
|
|
7
|
+
providerSelect,
|
|
8
|
+
setContextUsage,
|
|
9
|
+
setCwd,
|
|
10
|
+
setSidebarOpen,
|
|
11
|
+
updateStatus,
|
|
12
|
+
yesInput,
|
|
13
|
+
} from './chrome.js';
|
|
14
|
+
import { hideSlashMenu, notify, onChatInputChanged, runCommand, toast } from './commands.js';
|
|
15
|
+
export {
|
|
16
|
+
ADVISOR_KINDS,
|
|
17
|
+
READ_ONLY_TOOLS,
|
|
18
|
+
advisorMeta,
|
|
19
|
+
basename,
|
|
20
|
+
formatDuration,
|
|
21
|
+
isFailedToolText,
|
|
22
|
+
isReadOnlyTool,
|
|
23
|
+
parseToolArgs,
|
|
24
|
+
previewText,
|
|
25
|
+
screenshotName,
|
|
26
|
+
toolSummary,
|
|
27
|
+
userImages,
|
|
28
|
+
userText,
|
|
29
|
+
} from './chatFormat.js';
|
|
30
|
+
import {
|
|
31
|
+
basename,
|
|
32
|
+
formatDuration,
|
|
33
|
+
isFailedToolText,
|
|
34
|
+
parseToolArgs,
|
|
35
|
+
previewText,
|
|
36
|
+
userImages,
|
|
37
|
+
userText,
|
|
38
|
+
} from './chatFormat.js';
|
|
39
|
+
|
|
40
|
+
import { $, api, button, clear, el, icon, openStream, showConfirm, state, storage, streamPost } from './core.js';
|
|
41
|
+
import { isLocalHost, loadPreview, localPortOf, openRemoteTunnel, setExplorerMode } from './explorer.js';
|
|
42
|
+
import { renderMarkdown } from './markdown.js';
|
|
43
|
+
import { followRun } from './runStream.js';
|
|
44
|
+
import {
|
|
45
|
+
closeTab,
|
|
46
|
+
openTab,
|
|
47
|
+
setWorking,
|
|
48
|
+
showStandalonePermission,
|
|
49
|
+
syncInterruptBtn,
|
|
50
|
+
whenIdle,
|
|
51
|
+
} from './sessions.js';
|
|
52
|
+
import { setStatus } from './settings.js';
|
|
53
|
+
import { reconcile, renderAlertSignals } from './system.js';
|
|
54
|
+
import { narrar, reproducirVoz } from './voice.js';
|
|
55
|
+
import {
|
|
56
|
+
addAdvisorCard,
|
|
57
|
+
addChoiceCard,
|
|
58
|
+
addCompactCard,
|
|
59
|
+
addPermissionCard,
|
|
60
|
+
addPlanCard,
|
|
61
|
+
addQuestionCard,
|
|
62
|
+
addToolCard,
|
|
63
|
+
ensureToolsToggle,
|
|
64
|
+
finishToolCard,
|
|
65
|
+
livePermission,
|
|
66
|
+
liveQuestion,
|
|
67
|
+
resolvePermissionCard,
|
|
68
|
+
showStandaloneQuestion,
|
|
69
|
+
toolCardIndex,} from './cards.js';
|
|
70
|
+
export {
|
|
71
|
+
PLAN_MARKS,
|
|
72
|
+
addAdvisorCard,
|
|
73
|
+
addChoiceCard,
|
|
74
|
+
addCompactCard,
|
|
75
|
+
addPermissionCard,
|
|
76
|
+
addPlanCard,
|
|
77
|
+
addQuestionCard,
|
|
78
|
+
addToolCard,
|
|
79
|
+
ensureToolsToggle,
|
|
80
|
+
finishCompactCard,
|
|
81
|
+
finishToolCard,
|
|
82
|
+
livePermission,
|
|
83
|
+
liveQuestion,
|
|
84
|
+
loadFullToolResult,
|
|
85
|
+
rememberDecision,
|
|
86
|
+
renderToolOut,
|
|
87
|
+
repeatedToolNotice,
|
|
88
|
+
resolvePermissionCard,
|
|
89
|
+
sendChoice,
|
|
90
|
+
showScreenshot,
|
|
91
|
+
showStandaloneQuestion,
|
|
92
|
+
speakControl,
|
|
93
|
+
toolCardIndex,} from './cards.js';
|
|
94
|
+
|
|
95
|
+
// `scrollNavFrame` debe existir antes de `initLayout()`: `applyPanelWidths`
|
|
96
|
+
// llama a `scheduleScrollNav()` durante el arranque (evita el TDZ).
|
|
97
|
+
export let scrollNavFrame = 0;
|
|
98
|
+
|
|
99
|
+
/* --- chat ---------------------------------------------------------------- */
|
|
100
|
+
|
|
101
|
+
export const blankPane = $('#messages');
|
|
102
|
+
export const panesEl = $('#panes');
|
|
103
|
+
export const tabbarEl = $('#tabbar');
|
|
104
|
+
export const chatForm = $('#chat-form');
|
|
105
|
+
export const chatInput = $('#chat-input');
|
|
106
|
+
export const submitBtn = chatForm.querySelector('button[type="submit"]');
|
|
107
|
+
export const stopBtn = $('#stop-btn');
|
|
108
|
+
export const interruptBtn = $('#interrupt-btn');
|
|
109
|
+
export const controllers = new Map();
|
|
110
|
+
// `whenIdle` espera a que el turno de una sesion termine (lo usa "interrumpir").
|
|
111
|
+
export const idleWaiters = new Map();
|
|
112
|
+
// Sesiones donde el turno actual se esta cortando a proposito: `drainQueue` no
|
|
113
|
+
// debe arrancar un mensaje al final de ese turno (lo arranca el interruptor).
|
|
114
|
+
export const interrupting = new Set();
|
|
115
|
+
export const sessionsEl = $('#sessions');
|
|
116
|
+
|
|
117
|
+
export let activePane = blankPane;
|
|
118
|
+
|
|
119
|
+
export const paneOf = (message) => message?.bubble?.closest('.messages') ?? activePane;
|
|
120
|
+
|
|
121
|
+
export const scrollMessages = (pane = activePane) => {
|
|
122
|
+
if (pane) pane.scrollTop = pane.scrollHeight;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export const scrollNav = $('#scroll-nav');
|
|
126
|
+
export const scrollTopBtn = $('#scroll-top');
|
|
127
|
+
export const scrollDownBtn = $('#scroll-down');
|
|
128
|
+
|
|
129
|
+
/** Muestra/oculta los saltos de scroll según el pane activo. */
|
|
130
|
+
export function updateScrollNav() {
|
|
131
|
+
const pane = activePane;
|
|
132
|
+
if (!pane) return;
|
|
133
|
+
const max = pane.scrollHeight - pane.clientHeight;
|
|
134
|
+
const visible = max > 40;
|
|
135
|
+
scrollNav.hidden = !visible;
|
|
136
|
+
if (!visible) return;
|
|
137
|
+
scrollTopBtn.disabled = pane.scrollTop < 40;
|
|
138
|
+
scrollDownBtn.disabled = max - pane.scrollTop < 40;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function scheduleScrollNav() {
|
|
142
|
+
if (scrollNavFrame) return;
|
|
143
|
+
scrollNavFrame = requestAnimationFrame(() => {
|
|
144
|
+
scrollNavFrame = 0;
|
|
145
|
+
updateScrollNav();
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function addMessage(role, text = '', pane = activePane) {
|
|
150
|
+
pane.querySelector('.empty')?.remove();
|
|
151
|
+
const wrap = el('div', `msg ${role}`);
|
|
152
|
+
const label = el('div', 'msg-label');
|
|
153
|
+
let modeEl = null;
|
|
154
|
+
if (role === 'user') {
|
|
155
|
+
label.textContent = '❯ vos';
|
|
156
|
+
} else {
|
|
157
|
+
label.appendChild(el('span', 'msg-name', 'tamnora'));
|
|
158
|
+
modeEl = el('span', 'msg-mode');
|
|
159
|
+
modeEl.hidden = true;
|
|
160
|
+
label.appendChild(modeEl);
|
|
161
|
+
}
|
|
162
|
+
const bubble = el('div', 'bubble');
|
|
163
|
+
const textEl = el('div', 'text');
|
|
164
|
+
const tools = el('div', 'tools');
|
|
165
|
+
bubble.append(textEl, tools);
|
|
166
|
+
wrap.append(label, bubble);
|
|
167
|
+
pane.appendChild(wrap);
|
|
168
|
+
const message = { role, wrap, bubble, label, modeEl, textEl, tools, reasoningEl: null, id: null };
|
|
169
|
+
addMessageActions(message);
|
|
170
|
+
if (text) setText(message, text);
|
|
171
|
+
scrollMessages(pane);
|
|
172
|
+
return message;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function setMessageMode(message, modeId) {
|
|
176
|
+
if (!message?.modeEl) return;
|
|
177
|
+
const mode = modeId === 'plan' ? 'plan' : 'build';
|
|
178
|
+
message.modeEl.textContent = mode === 'plan' ? 'Plan' : 'Build';
|
|
179
|
+
message.modeEl.dataset.mode = mode;
|
|
180
|
+
message.modeEl.hidden = false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export let openMessageMenu = null;
|
|
184
|
+
|
|
185
|
+
/** El menu flota (position: fixed) para no quedar recortado por el scroll. */
|
|
186
|
+
export function positionMessageMenu() {
|
|
187
|
+
if (!openMessageMenu) return;
|
|
188
|
+
const { menu, trigger } = openMessageMenu;
|
|
189
|
+
const rect = trigger.getBoundingClientRect();
|
|
190
|
+
const size = menu.getBoundingClientRect();
|
|
191
|
+
let top = rect.bottom + 4;
|
|
192
|
+
if (top + size.height > window.innerHeight - 8) top = Math.max(8, rect.top - size.height - 4);
|
|
193
|
+
let left = rect.left;
|
|
194
|
+
if (left + size.width > window.innerWidth - 8) left = Math.max(8, window.innerWidth - size.width - 8);
|
|
195
|
+
menu.style.top = `${top}px`;
|
|
196
|
+
menu.style.left = `${left}px`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function closeMessageMenus() {
|
|
200
|
+
if (!openMessageMenu) return;
|
|
201
|
+
const current = openMessageMenu;
|
|
202
|
+
openMessageMenu = null;
|
|
203
|
+
current.menu.hidden = true;
|
|
204
|
+
current.trigger.setAttribute('aria-expanded', 'false');
|
|
205
|
+
current.actions?.appendChild(current.menu);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function toggleMessageMenu(message) {
|
|
209
|
+
const wasOpen = openMessageMenu === message;
|
|
210
|
+
closeMessageMenus();
|
|
211
|
+
if (wasOpen) return;
|
|
212
|
+
document.body.appendChild(message.menu);
|
|
213
|
+
message.menu.hidden = false;
|
|
214
|
+
message.trigger.setAttribute('aria-expanded', 'true');
|
|
215
|
+
openMessageMenu = message;
|
|
216
|
+
positionMessageMenu();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function addMessageActions(message) {
|
|
220
|
+
const actions = el('div', 'msg-actions');
|
|
221
|
+
const trigger = el('button', 'msg-menu-btn');
|
|
222
|
+
trigger.type = 'button';
|
|
223
|
+
trigger.appendChild(icon(window.matchMedia('(min-width: 821px)').matches ? 'dots-horizontal' : 'dots-vertical', 16));
|
|
224
|
+
trigger.title = 'opciones del mensaje';
|
|
225
|
+
trigger.setAttribute('aria-haspopup', 'menu');
|
|
226
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
227
|
+
trigger.setAttribute('aria-label', 'opciones del mensaje');
|
|
228
|
+
trigger.addEventListener('click', () => toggleMessageMenu(message));
|
|
229
|
+
|
|
230
|
+
const menu = el('div', 'msg-menu');
|
|
231
|
+
menu.hidden = true;
|
|
232
|
+
menu.setAttribute('role', 'menu');
|
|
233
|
+
const addItem = (label, run, iconName, cls = 'msg-act') => {
|
|
234
|
+
const item = el('button', cls);
|
|
235
|
+
item.type = 'button';
|
|
236
|
+
item.setAttribute('role', 'menuitem');
|
|
237
|
+
if (iconName) item.appendChild(icon(iconName, 15, 'mi'));
|
|
238
|
+
item.appendChild(el('span', 'mlabel', label));
|
|
239
|
+
item.addEventListener('click', () => {
|
|
240
|
+
closeMessageMenus();
|
|
241
|
+
run();
|
|
242
|
+
});
|
|
243
|
+
menu.appendChild(item);
|
|
244
|
+
};
|
|
245
|
+
addItem('copiar', () => copyMessage(message), 'copy');
|
|
246
|
+
if (message.role === 'assistant') addItem('regenerar', () => regenerate(message), 'refresh-cw');
|
|
247
|
+
if (message.role === 'assistant') addItem('deshacer cambios', () => undoTurn(message), 'undo');
|
|
248
|
+
if (message.role === 'user') addItem('editar', () => editMessage(message), 'pencil');
|
|
249
|
+
menu.appendChild(el('div', 'msg-menu-sep'));
|
|
250
|
+
addItem('borrar desde acá', () => deleteFrom(message), 'trash', 'msg-act danger');
|
|
251
|
+
|
|
252
|
+
actions.append(trigger, menu);
|
|
253
|
+
message.bubble.appendChild(actions);
|
|
254
|
+
message.actions = actions;
|
|
255
|
+
message.menu = menu;
|
|
256
|
+
message.trigger = trigger;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function messageId(message) {
|
|
260
|
+
return message.wrap?.dataset.messageId || message.id || null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function copyMessage(message) {
|
|
264
|
+
const text = message.role === 'assistant' ? message.textEl.innerText : message.textEl.textContent;
|
|
265
|
+
const write = navigator.clipboard?.writeText(text);
|
|
266
|
+
if (write?.catch) write.catch(() => {});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export async function renderPane(id) {
|
|
270
|
+
const record = state.panes.get(id);
|
|
271
|
+
if (!record) return null;
|
|
272
|
+
const detail = await api(`/api/sessions/${id}`).catch(() => null);
|
|
273
|
+
if (!detail) return null;
|
|
274
|
+
record.el.replaceChildren();
|
|
275
|
+
renderStoredMessages(detail.messages, record.el);
|
|
276
|
+
if (!detail.messages.length) record.el.appendChild(el('div', 'empty', 'Sesión vacía.'));
|
|
277
|
+
// Los permisos pendientes no son mensajes, asi que `replaceChildren` los
|
|
278
|
+
// borra. Hay que repintarlos aca: sin esto una tarjeta de permiso se ve y
|
|
279
|
+
// al primer re-render (run_start, sweep, reconexion) desaparece para siempre.
|
|
280
|
+
for (const ask of detail.permissions ?? []) showStandalonePermission(record, ask);
|
|
281
|
+
for (const ask of detail.questions ?? []) showStandaloneQuestion(record, ask);
|
|
282
|
+
return detail;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export async function reloadPane(id) {
|
|
286
|
+
const detail = await renderPane(id);
|
|
287
|
+
const record = state.panes.get(id);
|
|
288
|
+
if (detail?.running && record && !record.working) await attachRun(id, record.el);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export async function regenerate(message) {
|
|
292
|
+
const id = message?.wrap?.closest('.messages')?.dataset.session || state.sessionId;
|
|
293
|
+
if (!id || state.panes.get(id)?.working) return;
|
|
294
|
+
const detail = await api(`/api/sessions/${id}`).catch(() => null);
|
|
295
|
+
if (!detail) return;
|
|
296
|
+
const messages = detail.messages || [];
|
|
297
|
+
let lastUser = null;
|
|
298
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
299
|
+
if (messages[i].role === 'user') {
|
|
300
|
+
lastUser = messages[i];
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (!lastUser) return;
|
|
305
|
+
await api(`/api/sessions/${id}/truncate`, { method: 'POST', body: { fromId: lastUser.id } }).catch(() => {});
|
|
306
|
+
await reloadPane(id);
|
|
307
|
+
chatInput.value = userText(lastUser.content);
|
|
308
|
+
await sendMessage();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export async function undoTurn(message) {
|
|
312
|
+
const id = message?.wrap?.closest('.messages')?.dataset.session || state.sessionId;
|
|
313
|
+
if (!id || state.panes.get(id)?.working) return;
|
|
314
|
+
const detail = await api(`/api/sessions/${id}`).catch(() => null);
|
|
315
|
+
if (!detail) return;
|
|
316
|
+
const messages = detail.messages || [];
|
|
317
|
+
const at = messages.findIndex((m) => m.id === messageId(message));
|
|
318
|
+
let turn = null;
|
|
319
|
+
for (let i = at >= 0 ? at : messages.length - 1; i >= 0; i--) {
|
|
320
|
+
if (messages[i].role === 'user') {
|
|
321
|
+
turn = messages[i];
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (!turn) return;
|
|
326
|
+
if (
|
|
327
|
+
!(await showConfirm('¿Deshacer este turno? Se restauran los archivos a como estaban y se borra el turno del historial.', {
|
|
328
|
+
title: 'Deshacer cambios',
|
|
329
|
+
confirmLabel: 'deshacer',
|
|
330
|
+
danger: true,
|
|
331
|
+
}))
|
|
332
|
+
) {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
await api(`/api/sessions/${id}/rewind`, { method: 'POST', body: { turn: turn.id } }).catch(() => {});
|
|
336
|
+
await reloadPane(id);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function editMessage(message) { const id = messageId(message);
|
|
340
|
+
chatInput.value = message.textEl.textContent;
|
|
341
|
+
chatInput.focus();
|
|
342
|
+
state.pendingEdit = id ? { fromId: id } : null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export async function deleteFrom(message) {
|
|
346
|
+
const id = messageId(message);
|
|
347
|
+
if (!id || !(await showConfirm('¿Borrar este mensaje y todo lo posterior?', { title: 'Borrar mensajes', confirmLabel: 'borrar', danger: true }))) return;
|
|
348
|
+
const sessionKey = state.sessionId;
|
|
349
|
+
await api(`/api/sessions/${sessionKey}/truncate`, { method: 'POST', body: { fromId: id } }).catch(() => {});
|
|
350
|
+
await reloadPane(sessionKey);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export const ACTIVITY = {
|
|
354
|
+
thinking: { cls: 'loader-thinking', label: 'pensando…' },
|
|
355
|
+
working: { cls: 'loader-working', label: 'trabajando…' },
|
|
356
|
+
listening: { cls: 'loader-working', label: 'escuchando…' },
|
|
357
|
+
reconnecting: { cls: 'loader-working', label: 'reconectando…' },
|
|
358
|
+
stopping: { cls: 'loader-working', label: 'deteniendo…' },
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
export const footerActivityEl = $('#sb-activity');
|
|
362
|
+
|
|
363
|
+
/** El indicador de "pensando/trabajando" vive en el footer (una sola vez). */
|
|
364
|
+
export function renderFooterActivity(kind) {
|
|
365
|
+
if (!footerActivityEl) return;
|
|
366
|
+
if (!kind) {
|
|
367
|
+
footerActivityEl.hidden = true;
|
|
368
|
+
footerActivityEl.replaceChildren();
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const def = ACTIVITY[kind] ?? ACTIVITY.thinking;
|
|
372
|
+
footerActivityEl.hidden = false;
|
|
373
|
+
footerActivityEl.replaceChildren(el('span', `loader ${def.cls}`), el('span', 'activity-label', def.label));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function setActivity(message, kind) {
|
|
377
|
+
// Solo el turno de la sesion activa refleja su actividad en el footer.
|
|
378
|
+
if (paneOf(message) !== activePane) return;
|
|
379
|
+
renderFooterActivity(kind);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function setText(message, text) {
|
|
383
|
+
const value = String(text ?? '');
|
|
384
|
+
if (message.role === 'assistant') message.textEl.innerHTML = renderMarkdown(value);
|
|
385
|
+
else message.textEl.textContent = value;
|
|
386
|
+
scrollMessages(paneOf(message));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function appendImages(message, urls) {
|
|
390
|
+
if (!urls.length) return;
|
|
391
|
+
const wrap = el('div', 'msg-images');
|
|
392
|
+
for (const url of urls) {
|
|
393
|
+
const img = el('img', 'msg-image');
|
|
394
|
+
img.src = url;
|
|
395
|
+
img.alt = 'imagen adjunta';
|
|
396
|
+
wrap.appendChild(img);
|
|
397
|
+
}
|
|
398
|
+
message.bubble.insertBefore(wrap, message.tools);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export function ensureReasoning(message) {
|
|
402
|
+
if (message.reasoningEl) return message.reasoningEl;
|
|
403
|
+
const details = el('details', 'reasoning');
|
|
404
|
+
details.appendChild(el('summary', '', 'razonamiento'));
|
|
405
|
+
const body = el('div', 'body');
|
|
406
|
+
details.appendChild(body);
|
|
407
|
+
message.bubble.insertBefore(details, message.tools);
|
|
408
|
+
message.reasoningEl = body;
|
|
409
|
+
return body;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export function renderStored(message, pane) {
|
|
413
|
+
if (message.agent_id) return;
|
|
414
|
+
if (message.name === 'compact') {
|
|
415
|
+
addCompactCard(pane, { text: typeof message.content === 'string' ? message.content : '' });
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (message.role === 'assistant' && message.name === 'advisor') {
|
|
419
|
+
addAdvisorCard(pane, {
|
|
420
|
+
text: typeof message.content === 'string' ? message.content : '',
|
|
421
|
+
kind: message.mode,
|
|
422
|
+
});
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (message.role === 'user') {
|
|
426
|
+
const bubble = addMessage('user', userText(message.content), pane);
|
|
427
|
+
if (message.id) {
|
|
428
|
+
bubble.id = message.id;
|
|
429
|
+
bubble.wrap.dataset.messageId = message.id;
|
|
430
|
+
}
|
|
431
|
+
appendImages(bubble, userImages(message.content));
|
|
432
|
+
} else if (message.role === 'assistant') {
|
|
433
|
+
const bubble = addMessage('assistant', typeof message.content === 'string' ? message.content : '', pane);
|
|
434
|
+
if (message.mode) setMessageMode(bubble, message.mode);
|
|
435
|
+
if (message.id) {
|
|
436
|
+
bubble.id = message.id;
|
|
437
|
+
bubble.wrap.dataset.messageId = message.id;
|
|
438
|
+
}
|
|
439
|
+
for (const call of message.tool_calls ?? []) {
|
|
440
|
+
const name = call.function?.name ?? call.name;
|
|
441
|
+
const args = parseToolArgs(call);
|
|
442
|
+
const tool = addToolCard(bubble, name, args, { callId: call.id, open: false });
|
|
443
|
+
tool.card.classList.remove('running');
|
|
444
|
+
tool.card.classList.add('done');
|
|
445
|
+
tool.glyph.textContent = '✓';
|
|
446
|
+
if (call.id) toolCardIndex.set(call.id, tool);
|
|
447
|
+
if (name === 'offer_choices') addChoiceCard(bubble, { question: args.question, options: args.options });
|
|
448
|
+
if (name === 'show_plan') addPlanCard(bubble, { title: args.title, steps: args.steps });
|
|
449
|
+
}
|
|
450
|
+
} else if (message.role === 'tool') {
|
|
451
|
+
const tool = toolCardIndex.get(message.tool_call_id);
|
|
452
|
+
if (!tool) return;
|
|
453
|
+
const { preview, truncated } = previewText(message.content);
|
|
454
|
+
finishToolCard(tool, { preview, truncated, failed: isFailedToolText(message.content) });
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export function renderStoredMessages(messages, pane) {
|
|
459
|
+
toolCardIndex.clear();
|
|
460
|
+
for (const message of messages) renderStored(message, pane);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export const presenceClosed = new Set();
|
|
464
|
+
|
|
465
|
+
/** Reporta al server qué pestañas están abiertas (proyectos "habilitados"). */
|
|
466
|
+
export function sendPresence() {
|
|
467
|
+
const closed = [...presenceClosed];
|
|
468
|
+
presenceClosed.clear();
|
|
469
|
+
return api('/api/presence', { method: 'POST', body: { sessions: state.tabOrder, closed } }).catch(() => {});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export function persistTabs() {
|
|
473
|
+
storage.setJson('tamnora-open-tabs', { ids: state.tabOrder, active: state.activeTab });
|
|
474
|
+
sendPresence();
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Dibuja el sidebar desde el ultimo snapshot.
|
|
479
|
+
*
|
|
480
|
+
* Antes hacia su propio `GET /api/sessions`: dos caminos de datos que se
|
|
481
|
+
* contradician. Ahora el unico que habla con el server es `reconcile`.
|
|
482
|
+
*/
|
|
483
|
+
export function renderSessions(sessions) {
|
|
484
|
+
const list = sessions ?? [];
|
|
485
|
+
clear(sessionsEl);
|
|
486
|
+
const groups = new Map();
|
|
487
|
+
for (const session of list) {
|
|
488
|
+
const key = session.cwd || '';
|
|
489
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
490
|
+
groups.get(key).push(session);
|
|
491
|
+
}
|
|
492
|
+
for (const [key, items] of groups) {
|
|
493
|
+
const groupLi = el('li', 'session-group');
|
|
494
|
+
const head = el('div', 'group-head');
|
|
495
|
+
const open = state.expandedGroups.has(key);
|
|
496
|
+
head.classList.toggle('open', open);
|
|
497
|
+
const twisty = el('span', 'tw');
|
|
498
|
+
twisty.appendChild(icon(open ? 'chevron-down' : 'chevron-right', 14));
|
|
499
|
+
head.append(twisty, icon('folder', 15, 'fi'), el('span', 'gname', key ? basename(key) : 'sin carpeta'), el('span', 'gcount', String(items.length)));
|
|
500
|
+
head.title = key || 'sesiones sin carpeta';
|
|
501
|
+
const delGroup = el('button', 'group-del');
|
|
502
|
+
delGroup.type = 'button';
|
|
503
|
+
delGroup.title = 'eliminar proyecto (borra sus sesiones, no los archivos)';
|
|
504
|
+
delGroup.setAttribute('aria-label', 'eliminar proyecto');
|
|
505
|
+
delGroup.appendChild(icon('trash', 13));
|
|
506
|
+
delGroup.addEventListener('click', (event) => {
|
|
507
|
+
event.stopPropagation();
|
|
508
|
+
removeProject(key, items.length);
|
|
509
|
+
});
|
|
510
|
+
head.appendChild(delGroup);
|
|
511
|
+
const list = el('ul', 'group-sessions');
|
|
512
|
+
list.hidden = !open;
|
|
513
|
+
head.addEventListener('click', () => {
|
|
514
|
+
const nowOpen = !state.expandedGroups.has(key);
|
|
515
|
+
if (nowOpen) state.expandedGroups.add(key);
|
|
516
|
+
else state.expandedGroups.delete(key);
|
|
517
|
+
head.classList.toggle('open', nowOpen);
|
|
518
|
+
list.hidden = !nowOpen;
|
|
519
|
+
clear(twisty);
|
|
520
|
+
twisty.appendChild(icon(nowOpen ? 'chevron-down' : 'chevron-right', 14));
|
|
521
|
+
});
|
|
522
|
+
for (const session of items) {
|
|
523
|
+
const li = el('li', `session-item${session.id === state.sessionId ? ' active' : ''}`);
|
|
524
|
+
const main = el('div', 'session-main');
|
|
525
|
+
main.appendChild(el('span', 'session-title', session.title || '(sin título)'));
|
|
526
|
+
const meta = [session.provider, session.model].filter(Boolean).join(' / ');
|
|
527
|
+
if (meta) main.appendChild(el('span', 'session-sub', meta));
|
|
528
|
+
li.appendChild(main);
|
|
529
|
+
// Punto de actividad: el server lo dice en el listado, y el reconciliador
|
|
530
|
+
// lo mantiene al dia si el run arranca despues de este render. El estado
|
|
531
|
+
// "esperandote a vos" tiene su propio color: un run trabajando en otra
|
|
532
|
+
// pestana no necesita tu atencion, un permiso si.
|
|
533
|
+
const awaiting = session.awaiting || [...state.pendingAsks.values()].filter((ask) => ask.sessionId === session.id).length;
|
|
534
|
+
if (awaiting) {
|
|
535
|
+
const dot = el('span', 'session-await');
|
|
536
|
+
dot.title = `${awaiting} permiso(s) esperando tu decision`;
|
|
537
|
+
li.appendChild(dot);
|
|
538
|
+
}
|
|
539
|
+
if (session.running || state.remoteActive.has(session.id)) {
|
|
540
|
+
const dot = el('span', 'session-live');
|
|
541
|
+
dot.title = 'el agente está trabajando en esta sesión';
|
|
542
|
+
li.appendChild(dot);
|
|
543
|
+
}
|
|
544
|
+
li.title = `${session.provider ?? ''}/${session.model ?? ''}${session.cwd ? ` · ${session.cwd}` : ''}`;
|
|
545
|
+
const del = el('button', 'session-del');
|
|
546
|
+
del.type = 'button';
|
|
547
|
+
del.title = 'eliminar sesión';
|
|
548
|
+
del.setAttribute('aria-label', `eliminar sesión ${session.title || ''}`);
|
|
549
|
+
del.appendChild(icon('trash', 13));
|
|
550
|
+
del.addEventListener('click', (event) => {
|
|
551
|
+
event.stopPropagation();
|
|
552
|
+
removeSession(session);
|
|
553
|
+
});
|
|
554
|
+
li.appendChild(del);
|
|
555
|
+
li.addEventListener('click', () => {
|
|
556
|
+
if (isMobile()) setSidebarOpen(false);
|
|
557
|
+
openTab(session.id);
|
|
558
|
+
});
|
|
559
|
+
list.appendChild(li);
|
|
560
|
+
}
|
|
561
|
+
groupLi.append(head, list);
|
|
562
|
+
sessionsEl.appendChild(groupLi);
|
|
563
|
+
}
|
|
564
|
+
if (!list.length) sessionsEl.appendChild(el('li', 'muted', 'sin sesiones'));
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Pedir el estado fresco. Es un alias de `reconcile` que queda para los ~20
|
|
569
|
+
* call sites que ya existian (despues de mandar un mensaje, borrar, etc.).
|
|
570
|
+
*/
|
|
571
|
+
export function refreshSessions() {
|
|
572
|
+
reconcile({ immediate: true });
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export function closeSessionsOf(cwd) {
|
|
576
|
+
for (const [id, record] of [...state.panes]) {
|
|
577
|
+
if ((record.cwd || '') === (cwd || '')) closeTab(id);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** confirm + DELETE + notify + refresh: patron repetido en todo el panel. */
|
|
582
|
+
export async function deleteEntity(url, { confirmText, label = 'eliminar', after = null, refresh = null }) {
|
|
583
|
+
if (!(await showConfirm(confirmText, { title: 'Confirmar', confirmLabel: label, danger: true }))) return;
|
|
584
|
+
const error = await api(url, { method: 'DELETE' }).then(() => null).catch((e) => e);
|
|
585
|
+
if (error) return notify(`No se pudo ${label}: ${error.message}`, 'notice err');
|
|
586
|
+
await after?.();
|
|
587
|
+
await refresh?.();
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** Import en un input de texto + status + refresh (MCP y skills). */
|
|
591
|
+
export async function importEntity({ url, body, input, statusSel, refresh, okMessage }) {
|
|
592
|
+
setStatus(statusSel, 'importando…');
|
|
593
|
+
const data = await api(url, { method: 'POST', body }).catch((e) => ({ error: e.message }));
|
|
594
|
+
if (data?.error) return setStatus(statusSel, data.error, 'err');
|
|
595
|
+
input.value = '';
|
|
596
|
+
setStatus(statusSel, okMessage(data), 'ok');
|
|
597
|
+
await refresh?.();
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** Cambia el estado enabled de una entidad (PUT de cuerpo completo o POST toggle). */
|
|
601
|
+
export async function toggleEntity(url, { method = 'PUT', body = undefined, errorLabel, refresh }) {
|
|
602
|
+
await api(url, { method, body }).catch((error) =>
|
|
603
|
+
notify(`No se pudo ${errorLabel}: ${error.message}`, 'notice err')
|
|
604
|
+
);
|
|
605
|
+
await refresh?.();
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
export async function removeSession(session) {
|
|
609
|
+
const name = session.title || '(sin título)';
|
|
610
|
+
await deleteEntity(`/api/sessions/${session.id}`, {
|
|
611
|
+
confirmText: `¿Eliminar la sesión "${name}"? No se puede deshacer.`,
|
|
612
|
+
after: () => {
|
|
613
|
+
if (state.panes.has(session.id)) closeTab(session.id);
|
|
614
|
+
},
|
|
615
|
+
refresh: refreshSessions,
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
export async function removeProject(cwd, count) {
|
|
620
|
+
const label = cwd ? basename(cwd) : 'sin carpeta';
|
|
621
|
+
await deleteEntity(`/api/projects?cwd=${encodeURIComponent(cwd ?? '')}`, {
|
|
622
|
+
confirmText: `¿Eliminar ${count} sesión(es) del proyecto "${label}"? No se tocan los archivos del disco.`,
|
|
623
|
+
label: 'eliminar el proyecto',
|
|
624
|
+
after: () => closeSessionsOf(cwd),
|
|
625
|
+
refresh: refreshSessions,
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
export function showPane(id) {
|
|
630
|
+
const record = id ? state.panes.get(id) : null;
|
|
631
|
+
for (const item of state.panes.values()) item.el.hidden = item.id !== id;
|
|
632
|
+
blankPane.hidden = Boolean(record);
|
|
633
|
+
activePane = record ? record.el : blankPane;
|
|
634
|
+
renderFooterActivity(record?.working ? 'thinking' : null);
|
|
635
|
+
scheduleScrollNav();
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
export const INIT_PROMPT =
|
|
639
|
+
'Analizá este proyecto (estructura, stack, convenciones, comandos de build/test) y creá o actualizá un archivo AGENTS.md en la raíz con instrucciones claras para futuros agentes. Inspeccioná archivos reales antes de escribir.';
|
|
640
|
+
|
|
641
|
+
export function createChatTurn(pane, sessionKey) {
|
|
642
|
+
const turn = {
|
|
643
|
+
pane,
|
|
644
|
+
sessionKey,
|
|
645
|
+
// Turno iniciado desde esta consola: solo los turnos propios hablan (el
|
|
646
|
+
// replay de una reconexion o de otra pestaña no narran el pasado).
|
|
647
|
+
live: false,
|
|
648
|
+
active: null,
|
|
649
|
+
sawText: false,
|
|
650
|
+
// Cursor del log del run (`id:` del ultimo frame): reanuda desde aca.
|
|
651
|
+
cursor: 0,
|
|
652
|
+
// Si esta consola ya dibujo el mensaje del usuario de este turno.
|
|
653
|
+
sawUser: false,
|
|
654
|
+
lastTool: null,
|
|
655
|
+
startNewOnText: false,
|
|
656
|
+
agents: new Map(),
|
|
657
|
+
turnMode: modeSelect.value || 'agent',
|
|
658
|
+
assistant() {
|
|
659
|
+
if (!turn.active) {
|
|
660
|
+
turn.active = addMessage('assistant', '', turn.pane);
|
|
661
|
+
setMessageMode(turn.active, turn.turnMode);
|
|
662
|
+
}
|
|
663
|
+
return turn.active;
|
|
664
|
+
},
|
|
665
|
+
flowTarget() {
|
|
666
|
+
if (turn.startNewOnText) {
|
|
667
|
+
turn.active = addMessage('assistant', '', turn.pane);
|
|
668
|
+
setMessageMode(turn.active, turn.turnMode);
|
|
669
|
+
turn.startNewOnText = false;
|
|
670
|
+
setActivity(turn.active, 'thinking');
|
|
671
|
+
}
|
|
672
|
+
return turn.assistant();
|
|
673
|
+
},
|
|
674
|
+
};
|
|
675
|
+
return turn;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
export function addAgentCard(message, data) {
|
|
679
|
+
const card = el('details', 'agent-card running');
|
|
680
|
+
const head = el('summary', 'agent-head');
|
|
681
|
+
const glyph = el('span', 'agent-glyph', '⏺');
|
|
682
|
+
const title = el('span', 'agent-title', `subagente · ${data.description ?? 'tarea'}`);
|
|
683
|
+
const status = el('span', 'agent-status', data.mode === 'plan' ? 'solo lectura' : 'agente');
|
|
684
|
+
head.append(glyph, title, status);
|
|
685
|
+
const body = el('div', 'agent-body');
|
|
686
|
+
const reasoning = el('details', 'reasoning');
|
|
687
|
+
reasoning.appendChild(el('summary', '', 'razonamiento'));
|
|
688
|
+
const reasoningEl = el('div', 'body');
|
|
689
|
+
reasoning.appendChild(reasoningEl);
|
|
690
|
+
reasoning.hidden = true;
|
|
691
|
+
const textEl = el('div', 'agent-text');
|
|
692
|
+
const toolsEl = el('div', 'agent-tools');
|
|
693
|
+
body.append(reasoning, textEl, toolsEl);
|
|
694
|
+
card.append(head, body);
|
|
695
|
+
message.tools.appendChild(card);
|
|
696
|
+
ensureToolsToggle(message);
|
|
697
|
+
scrollMessages(paneOf(message));
|
|
698
|
+
const pseudo = { tools: toolsEl, bubble: card, toolsToggle: null };
|
|
699
|
+
return {
|
|
700
|
+
card,
|
|
701
|
+
glyph,
|
|
702
|
+
statusEl: status,
|
|
703
|
+
textEl,
|
|
704
|
+
reasoning,
|
|
705
|
+
reasoningEl,
|
|
706
|
+
toolsEl,
|
|
707
|
+
message: pseudo,
|
|
708
|
+
lastTool: null,
|
|
709
|
+
sawText: false,
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
export function handleAgentEvent(event, data, turn) {
|
|
714
|
+
if (!data?.agentId) return false;
|
|
715
|
+
const agent = turn.agents.get(data.agentId);
|
|
716
|
+
if (!agent) return false;
|
|
717
|
+
if (event === 'text') {
|
|
718
|
+
agent.sawText = true;
|
|
719
|
+
agent.textEl.innerHTML = renderMarkdown(data.full);
|
|
720
|
+
scrollMessages(paneOf(agent.message));
|
|
721
|
+
} else if (event === 'reasoning') {
|
|
722
|
+
agent.reasoning.hidden = false;
|
|
723
|
+
if (!agent.sawText) agent.reasoningEl.textContent = data.full;
|
|
724
|
+
} else if (event === 'tool_call') {
|
|
725
|
+
agent.lastTool = addToolCard(agent.message, data.name, data.args, { callId: data.id, summary: data.summary });
|
|
726
|
+
} else if (event === 'tool_result') {
|
|
727
|
+
finishToolCard(agent.lastTool, data);
|
|
728
|
+
} else {
|
|
729
|
+
return false;
|
|
730
|
+
}
|
|
731
|
+
return true;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
/** Cierre del turno: siempre deja un resumen visible (rondas, tools, tiempo). */
|
|
736
|
+
export function renderCompletion(message, data) {
|
|
737
|
+
const tools = Number(data.toolCalls) || 0;
|
|
738
|
+
const parts = [`${data.rounds ?? '?'} ronda${data.rounds === 1 ? '' : 's'}`];
|
|
739
|
+
if (tools) parts.push(`${tools} herramienta${tools === 1 ? '' : 's'}`);
|
|
740
|
+
const duration = formatDuration(data.durationMs);
|
|
741
|
+
if (duration) parts.push(duration);
|
|
742
|
+
if (data.tokens?.total_tokens) parts.push(`${Number(data.tokens.total_tokens).toLocaleString('es')} tokens`);
|
|
743
|
+
const cut = ['max_rounds', 'no_progress', 'budget'].includes(data.stopReason);
|
|
744
|
+
const label = cut ? (data.wrappedUp ? '⚠ corte de seguridad · resumen' : '⚠ corte de seguridad') : '✓ terminado';
|
|
745
|
+
message.tools.appendChild(el('div', cut ? 'chip warn' : 'chip ok', `${label} · ${parts.join(' · ')}`));
|
|
746
|
+
scrollMessages(paneOf(message));
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** Retoma un turno cortado por límite/bucle sin tener que escribir "continúa". */
|
|
750
|
+
export function addContinueButton(turn, data) {
|
|
751
|
+
const btn = el('button', 'continue-btn', data.wrappedUp ? '↻ Continuar el trabajo' : '↻ Continuar');
|
|
752
|
+
btn.type = 'button';
|
|
753
|
+
btn.addEventListener('click', () => {
|
|
754
|
+
if (state.panes.get(turn.sessionKey)?.working) return;
|
|
755
|
+
chatInput.value = 'Continuá donde quedaste.';
|
|
756
|
+
sendMessage();
|
|
757
|
+
});
|
|
758
|
+
turn.assistant().tools.appendChild(btn);
|
|
759
|
+
scrollMessages(paneOf(turn.assistant()));
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
export function handleChatEvent(event, data, turn) {
|
|
763
|
+
if (handleAgentEvent(event, data, turn)) return;
|
|
764
|
+
narrar(event, data, turn);
|
|
765
|
+
if (event === 'advice') {
|
|
766
|
+
addAdvisorCard(turn.pane, { text: data?.text, kind: data?.kind });
|
|
767
|
+
scrollMessages(turn.pane);
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
if (event === 'agent_start') {
|
|
771
|
+
turn.agents.set(data.agentId, addAgentCard(turn.assistant(), data));
|
|
772
|
+
} else if (event === 'agent_end') {
|
|
773
|
+
const agent = turn.agents.get(data.agentId);
|
|
774
|
+
if (agent) {
|
|
775
|
+
agent.card.classList.remove('running');
|
|
776
|
+
agent.card.classList.add(data.status === 'error' ? 'failed' : 'done');
|
|
777
|
+
agent.glyph.textContent = data.status === 'error' ? '✗' : '✓';
|
|
778
|
+
agent.statusEl.textContent = data.status;
|
|
779
|
+
if (data.content && !agent.sawText) agent.textEl.textContent = data.content;
|
|
780
|
+
}
|
|
781
|
+
} else if (event === 'start') {
|
|
782
|
+
if (data.cwd) setCwd(data.cwd);
|
|
783
|
+
if (data.mode) turn.turnMode = data.mode;
|
|
784
|
+
if (turn.active) setMessageMode(turn.active, turn.turnMode);
|
|
785
|
+
} else if (event === 'user') {
|
|
786
|
+
// Otro cliente (el celular, Telegram) mando esto: hay que dibujarlo, pero
|
|
787
|
+
// sin duplicar lo que esta consola ya renderizo o lo que vino de SQLite.
|
|
788
|
+
const content = String(data?.content ?? '');
|
|
789
|
+
if (turn.sawUser || !content) return;
|
|
790
|
+
if (lastMessageMatches(turn.pane, 'user', content)) {
|
|
791
|
+
turn.sawUser = true;
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
addMessage('user', content, turn.pane);
|
|
795
|
+
turn.sawUser = true;
|
|
796
|
+
} else if (event === 'text') {
|
|
797
|
+
turn.sawText = true;
|
|
798
|
+
const target = turn.flowTarget();
|
|
799
|
+
setText(target, data.full);
|
|
800
|
+
setActivity(target, 'thinking');
|
|
801
|
+
} else if (event === 'reasoning') {
|
|
802
|
+
const target = turn.flowTarget();
|
|
803
|
+
if (!turn.sawText) ensureReasoning(target).textContent = data.full;
|
|
804
|
+
setActivity(target, 'thinking');
|
|
805
|
+
} else if (event === 'tools') {
|
|
806
|
+
turn.startNewOnText = true;
|
|
807
|
+
} else if (event === 'tool_call') {
|
|
808
|
+
const target = turn.assistant();
|
|
809
|
+
setActivity(target, 'working');
|
|
810
|
+
turn.lastTool = addToolCard(target, data.name, data.args, { callId: data.id, summary: data.summary });
|
|
811
|
+
if (turn.live && data.name === 'speak') {
|
|
812
|
+
const texto = String(data.args?.texto ?? '').trim();
|
|
813
|
+
if (texto) reproducirVoz(texto, { voice: data.args?.voice, rate: data.args?.rate, pitch: data.args?.pitch, label: 'voz' }).catch(() => {});
|
|
814
|
+
}
|
|
815
|
+
if (turn.live && data.name === 'preview_url' && data.args?.url) {
|
|
816
|
+
setExplorerMode('preview');
|
|
817
|
+
// Desde otro dispositivo, 127.0.0.1 apunta al dispositivo: si el agente
|
|
818
|
+
// pidio una URL local, la publicamos por tailscale/tunel.
|
|
819
|
+
const port = isLocalHost() ? 0 : localPortOf(data.args.url);
|
|
820
|
+
if (port) openRemoteTunnel(port);
|
|
821
|
+
else loadPreview(data.args.url);
|
|
822
|
+
}
|
|
823
|
+
if (turn.live && data.name === 'offer_choices') {
|
|
824
|
+
addChoiceCard(target, { question: data.args?.question, options: data.args?.options });
|
|
825
|
+
}
|
|
826
|
+
if (turn.live && data.name === 'show_plan') {
|
|
827
|
+
addPlanCard(target, { title: data.args?.title, steps: data.args?.steps });
|
|
828
|
+
}
|
|
829
|
+
} else if (event === 'tool_result') {
|
|
830
|
+
setActivity(turn.assistant(), 'thinking');
|
|
831
|
+
finishToolCard(turn.lastTool, data);
|
|
832
|
+
} else if (event === 'question') {
|
|
833
|
+
if (!liveQuestion(data.askId)) addQuestionCard(turn.assistant(), data);
|
|
834
|
+
state.pendingQuestions.set(data.askId, { sessionId: data.sessionId ?? turn.sessionKey });
|
|
835
|
+
renderAlertSignals();
|
|
836
|
+
if (paneOf(turn.active) !== activePane) toast('Formulario pendiente en otra pestaña.');
|
|
837
|
+
} else if (event === 'question_answered') {
|
|
838
|
+
state.pendingQuestions.delete(data?.askId);
|
|
839
|
+
renderAlertSignals();
|
|
840
|
+
const entry = state.questionCards.get(data.askId);
|
|
841
|
+
if (entry && !entry.card.classList.contains('answered')) {
|
|
842
|
+
entry.card.classList.add('answered');
|
|
843
|
+
entry.status.textContent = data?.cancelled ? 'cancelado' : 'respondido en otra ventana';
|
|
844
|
+
for (const { input } of entry.fields) input.disabled = true;
|
|
845
|
+
}
|
|
846
|
+
} else if (event === 'permission') {
|
|
847
|
+
// Si la tarjeta ya esta en el DOM (la creo `renderPane` antes del replay, o
|
|
848
|
+
// llego por el canal global) no hay que crear OTRO mensaje del asistente
|
|
849
|
+
// vacio para colgarla.
|
|
850
|
+
const existing = livePermission(data.askId);
|
|
851
|
+
const target = existing ? existing.message : turn.assistant();
|
|
852
|
+
if (!existing) addPermissionCard(target, data);
|
|
853
|
+
state.pendingAsks.set(data.askId, {
|
|
854
|
+
sessionId: data.sessionId ?? turn.sessionKey,
|
|
855
|
+
name: data.name ?? '',
|
|
856
|
+
args: data.args ?? {},
|
|
857
|
+
});
|
|
858
|
+
renderAlertSignals();
|
|
859
|
+
// La tarjeta vive solo en el chat de esta sesion. Si el permiso cae en otra
|
|
860
|
+
// pestaña, sin este aviso la tarjeta queda enterrada y el run bloqueado
|
|
861
|
+
// esperando una decision que nadie ve.
|
|
862
|
+
if (paneOf(turn.active) !== activePane) {
|
|
863
|
+
toast(`Permiso pendiente: ${data.name} en otra pestaña.`);
|
|
864
|
+
}
|
|
865
|
+
} else if (event === 'permission_resolved') {
|
|
866
|
+
setActivity(turn.assistant(), 'thinking');
|
|
867
|
+
state.pendingAsks.delete(data?.askId);
|
|
868
|
+
renderAlertSignals();
|
|
869
|
+
const entry = state.permissionCards.get(data.askId);
|
|
870
|
+
// Solo entra aca si el veredicto sigue pendiente, o sea que **otra** ventana
|
|
871
|
+
// (la PC, otro celular, Telegram) lo resolvio: decirlo evita que el celular
|
|
872
|
+
// muestre "aprobado" como si hubiera sido su decision.
|
|
873
|
+
if (entry && entry.verdict.textContent === 'requiere aprobación') {
|
|
874
|
+
resolvePermissionCard(entry, data.allow);
|
|
875
|
+
if (paneOf(turn.assistant()) === activePane) {
|
|
876
|
+
toast(`Permiso ${data.allow ? 'aprobado' : 'denegado'} desde otra ventana.`);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
} else if (event === 'stall') {
|
|
880
|
+
if (!turn.sawText) setText(turn.assistant(), `⚠ ${data.seconds} s sin respuesta del modelo (proveedor caído o muy lento). Sigo esperando...`);
|
|
881
|
+
} else if (event === 'retry') {
|
|
882
|
+
if (!turn.sawText) setText(turn.assistant(), `↻ reintentando (${data.attempt}) en ${Math.round((data.delayMs || 0) / 1000)} s...`);
|
|
883
|
+
} else if (event === 'trim') {
|
|
884
|
+
turn.assistant().tools.appendChild(el('div', 'chip', `✂ contexto recortado (${data.trimmed} resultados, ${data.dropped} mensajes)`));
|
|
885
|
+
} else if (event === 'repeat') {
|
|
886
|
+
turn.assistant().tools.appendChild(el('div', 'chip warn', `↻ ${data.name} repetida sin cambios`));
|
|
887
|
+
} else if (event === 'usage') {
|
|
888
|
+
setContextUsage(data.tokens);
|
|
889
|
+
} else if (event === 'warning') {
|
|
890
|
+
turn.assistant().tools.appendChild(el('div', 'chip', `⚠ ${data.message}`));
|
|
891
|
+
} else if (event === 'error') {
|
|
892
|
+
setActivity(turn.active, null);
|
|
893
|
+
turn.assistant().tools.appendChild(el('div', 'chip err', `✗ ${data.message}`));
|
|
894
|
+
} else if (event === 'done') {
|
|
895
|
+
setActivity(turn.active, null);
|
|
896
|
+
const finalText = (turn.active?.textEl?.textContent ?? '').trim();
|
|
897
|
+
const tools = Number(data.toolCalls) || 0;
|
|
898
|
+
if (!finalText) {
|
|
899
|
+
if (data.stopReason === 'no_progress') {
|
|
900
|
+
setText(turn.assistant(), '⚠ El agente se quedó repitiendo las mismas herramientas sin avanzar.');
|
|
901
|
+
} else if (data.stopReason === 'budget') {
|
|
902
|
+
setText(turn.assistant(), '⚠ El agente alcanzó el presupuesto de tokens/tiempo sin respuesta final.');
|
|
903
|
+
} else if (data.stopReason === 'max_rounds') {
|
|
904
|
+
setText(
|
|
905
|
+
turn.assistant(),
|
|
906
|
+
`⚠ El agente alcanzó el máximo de rondas (${data.rounds ?? '?'}) sin dejar una respuesta final.${
|
|
907
|
+
tools ? ` Hizo ${tools} llamadas a herramientas.` : ''
|
|
908
|
+
}`
|
|
909
|
+
);
|
|
910
|
+
} else if (tools) {
|
|
911
|
+
setText(turn.assistant(), '✓ El agente terminó sus herramientas, pero no devolvió un mensaje final.');
|
|
912
|
+
} else {
|
|
913
|
+
setText(turn.assistant(), '(sin texto)');
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
renderCompletion(turn.active, data);
|
|
917
|
+
if (data.continuable) addContinueButton(turn, data);
|
|
918
|
+
updateStatus({ rounds: data.rounds, stopReason: data.stopReason });
|
|
919
|
+
addTokens(data.tokens);
|
|
920
|
+
} else if (event === 'aborted') {
|
|
921
|
+
setActivity(turn.active, null);
|
|
922
|
+
setText(turn.assistant(), '⏹ cancelado.');
|
|
923
|
+
} else if (event === 'resumed') {
|
|
924
|
+
// Reconexion al stream de la sesion: el server dice hasta donde replayo.
|
|
925
|
+
turn.cursor = Number(data.cursor) || turn.cursor;
|
|
926
|
+
if (data.active === false) closeTurn(turn, 'El turno ya había terminado.');
|
|
927
|
+
} else if (event === 'idle') {
|
|
928
|
+
// El server cerro el stream porque no hay nada corriendo.
|
|
929
|
+
closeTurn(turn, '');
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Cierra un turno cuyo stream se agoto sin `done`: deja el mensaje en un estado
|
|
935
|
+
* coherente en vez de colgado en "pensando...".
|
|
936
|
+
*/
|
|
937
|
+
export function closeTurn(turn, text) {
|
|
938
|
+
setActivity(turn.active, null);
|
|
939
|
+
const message = turn.assistant();
|
|
940
|
+
if (!text) return;
|
|
941
|
+
if ((message.textEl?.textContent ?? '').trim()) return;
|
|
942
|
+
setText(message, text);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* El mensaje del usuario se emite antes de persistirse (routes/chat.js), asi que
|
|
947
|
+
* un cliente que se engancha a mitad de run no lo tiene todavia. Y al reconectar
|
|
948
|
+
* el mismo evento se vuelve a reproducir, cuando `renderPane` ya lo dibujo desde
|
|
949
|
+
* SQLite: por eso la doble guarda.
|
|
950
|
+
*/
|
|
951
|
+
export function lastMessageMatches(pane, role, text) {
|
|
952
|
+
const nodes = pane.querySelectorAll(':scope > .msg');
|
|
953
|
+
const last = nodes[nodes.length - 1];
|
|
954
|
+
if (!last || !last.classList.contains(role)) return false;
|
|
955
|
+
return last.textContent.trim() === String(text ?? '').trim();
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* Dueña de un run: mantiene el stream vivo hasta que el turno termina.
|
|
960
|
+
*
|
|
961
|
+
* Un corte del tunel (4G, pantalla bloqueada, proxy) deja el `fetch` colgado
|
|
962
|
+
* sin error, asi que reconectamos con backoff desde el ultimo cursor en vez de
|
|
963
|
+
* dejar la UI en "pensando..." para siempre. El server honra `?from=`, asi que
|
|
964
|
+
* reanudar no duplica eventos.
|
|
965
|
+
*
|
|
966
|
+
* `rerender` solo aplica al primer intento: al reconectar NO hay que volver a
|
|
967
|
+
* pintar el pane, porque borraria el mensaje del asistente en vuelo y sus
|
|
968
|
+
* tarjetas de tools.
|
|
969
|
+
*/
|
|
970
|
+
export async function followRunSession(sessionKey, turn, { from = 0, rerender = false, attempts = 6 } = {}) {
|
|
971
|
+
const record = state.panes.get(sessionKey);
|
|
972
|
+
const controller = new AbortController();
|
|
973
|
+
controllers.set(sessionKey, controller);
|
|
974
|
+
if (record) setWorking(sessionKey, true);
|
|
975
|
+
let needsRerender = rerender;
|
|
976
|
+
let chip = null;
|
|
977
|
+
|
|
978
|
+
try {
|
|
979
|
+
await followRun({
|
|
980
|
+
attempts,
|
|
981
|
+
from,
|
|
982
|
+
signal: controller.signal,
|
|
983
|
+
open: async (cursor) => {
|
|
984
|
+
if (needsRerender) {
|
|
985
|
+
needsRerender = false;
|
|
986
|
+
await renderPane(sessionKey);
|
|
987
|
+
}
|
|
988
|
+
await openStream(
|
|
989
|
+
`/api/sessions/${sessionKey}/stream?from=${cursor}`,
|
|
990
|
+
{ signal: controller.signal },
|
|
991
|
+
(event, data, id) => {
|
|
992
|
+
if (id !== null) turn.cursor = Number(id) || turn.cursor;
|
|
993
|
+
handleChatEvent(event, data, turn);
|
|
994
|
+
}
|
|
995
|
+
);
|
|
996
|
+
return { finished: true, cursor: turn.cursor };
|
|
997
|
+
},
|
|
998
|
+
onRetry: ({ attempt, delay }) => {
|
|
999
|
+
setActivity(turn.active, 'reconnecting');
|
|
1000
|
+
if (!chip) {
|
|
1001
|
+
chip = el('div', 'chip warn', '⟳ reconectando…');
|
|
1002
|
+
turn.assistant().tools.appendChild(chip);
|
|
1003
|
+
}
|
|
1004
|
+
chip.textContent = `⟳ reconectando (intento ${attempt}, en ${Math.round(delay / 1000)} s)…`;
|
|
1005
|
+
},
|
|
1006
|
+
});
|
|
1007
|
+
} catch (error) {
|
|
1008
|
+
// Un error del handler (no del transporte) no reintenta: es un bug de la
|
|
1009
|
+
// consola y dejarlo pasar deja un "reconectando" que no dice nada.
|
|
1010
|
+
if (error?.eventHandler) {
|
|
1011
|
+
setActivity(turn.active, null);
|
|
1012
|
+
toast(`Error de la consola al procesar el stream: ${error.message}`);
|
|
1013
|
+
}
|
|
1014
|
+
throw error;
|
|
1015
|
+
} finally {
|
|
1016
|
+
controllers.delete(sessionKey);
|
|
1017
|
+
if (chip) chip.remove();
|
|
1018
|
+
if (state.panes.get(sessionKey) === record) setWorking(sessionKey, false);
|
|
1019
|
+
setActivity(turn.active, null);
|
|
1020
|
+
renderAlertSignals();
|
|
1021
|
+
reconcile({ immediate: true });
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* Se engancha a un run en curso: re-renderiza el historial persistido y
|
|
1027
|
+
* reproduce los eventos del turno actual (asi el progreso no se pierde si el
|
|
1028
|
+
* telefono bloqueo la pantalla o se corto la red).
|
|
1029
|
+
*/
|
|
1030
|
+
export async function attachRun(sessionKey, pane) {
|
|
1031
|
+
const record = state.panes.get(sessionKey);
|
|
1032
|
+
if (record?.working) return;
|
|
1033
|
+
// `reconcile` puede pedirlo dos veces seguidas (sweep + canal global).
|
|
1034
|
+
if (controllers.has(sessionKey)) return;
|
|
1035
|
+
const turn = createChatTurn(pane, sessionKey);
|
|
1036
|
+
await followRunSession(sessionKey, turn, { from: 0, rerender: true });
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
export function contextTokensForRequest() {
|
|
1040
|
+
const context = Number(modelSelect.selectedOptions[0]?.dataset.context) || 0;
|
|
1041
|
+
return context ? Math.floor(context * 0.8) : undefined;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
export async function startTurn(sessionKey, pane, input, images, opts = {}) {
|
|
1045
|
+
const user = addMessage('user', input || `(${images.length} imagen${images.length > 1 ? 'es' : ''})`, pane);
|
|
1046
|
+
appendImages(user, images);
|
|
1047
|
+
const turn = createChatTurn(pane, sessionKey);
|
|
1048
|
+
// Lo dibujamos nosotros, asi que el evento `user` del server no lo repite.
|
|
1049
|
+
turn.sawUser = true;
|
|
1050
|
+
turn.live = true;
|
|
1051
|
+
turn.active = addMessage('assistant', '', pane);
|
|
1052
|
+
setMessageMode(turn.active, turn.turnMode);
|
|
1053
|
+
const controller = new AbortController();
|
|
1054
|
+
controllers.set(sessionKey, controller);
|
|
1055
|
+
setWorking(sessionKey, true);
|
|
1056
|
+
|
|
1057
|
+
// El tipo de agente lo manda la sesion (no los controles globales): asi dos
|
|
1058
|
+
// pestañas con modelos distintos no se pisan.
|
|
1059
|
+
const owner = state.panes.get(sessionKey);
|
|
1060
|
+
try {
|
|
1061
|
+
await streamPost(
|
|
1062
|
+
'/api/chat',
|
|
1063
|
+
{
|
|
1064
|
+
input: input || '(imagen adjunta)',
|
|
1065
|
+
images: images.length ? images : undefined,
|
|
1066
|
+
sessionId: sessionKey,
|
|
1067
|
+
provider: owner?.provider || providerSelect.value || undefined,
|
|
1068
|
+
model: owner?.model || modelSelect.value || undefined,
|
|
1069
|
+
mode: owner?.mode || modeSelect.value || undefined,
|
|
1070
|
+
yes: yesInput.checked ? true : undefined,
|
|
1071
|
+
contextTokens: contextTokensForRequest(),
|
|
1072
|
+
interrupt: opts.interrupt ? true : undefined,
|
|
1073
|
+
},
|
|
1074
|
+
(event, data, id) => {
|
|
1075
|
+
if (id !== null) turn.cursor = Number(id) || turn.cursor;
|
|
1076
|
+
handleChatEvent(event, data, turn);
|
|
1077
|
+
},
|
|
1078
|
+
controller.signal
|
|
1079
|
+
);
|
|
1080
|
+
} catch (error) {
|
|
1081
|
+
if (error.name === 'AbortError') {
|
|
1082
|
+
setActivity(turn.active, null);
|
|
1083
|
+
setText(turn.assistant(), '⏹ cancelado.');
|
|
1084
|
+
} else if (error.status === 409) {
|
|
1085
|
+
// Otro cliente (celular, Telegram) tiene el turno: este mensaje no se
|
|
1086
|
+
// guardo, asi que sacamos lo que se dibujo y va a la cola visible.
|
|
1087
|
+
const bubble = turn.active;
|
|
1088
|
+
turn.active = null;
|
|
1089
|
+
user.wrap.remove();
|
|
1090
|
+
bubble?.wrap.remove();
|
|
1091
|
+
enqueueMessage(sessionKey, input, images);
|
|
1092
|
+
toast('La sesión ya tiene una respuesta en curso; tu mensaje quedó en la cola.');
|
|
1093
|
+
} else {
|
|
1094
|
+
// El POST se cayo pero el run puede seguir vivo: seguimos el turno con el
|
|
1095
|
+
// mismo `turn` para no perder las tools ya dibujadas.
|
|
1096
|
+
setActivity(turn.active, 'reconnecting');
|
|
1097
|
+
await followRunSession(sessionKey, turn, { from: turn.cursor, rerender: false });
|
|
1098
|
+
}
|
|
1099
|
+
} finally {
|
|
1100
|
+
if (controllers.get(sessionKey) === controller) controllers.delete(sessionKey);
|
|
1101
|
+
setActivity(turn.active, null);
|
|
1102
|
+
setWorking(sessionKey, false);
|
|
1103
|
+
renderAlertSignals();
|
|
1104
|
+
drainQueue(sessionKey);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/* --- cola de mensajes (enviar mientras el agente trabaja) ----------------- */
|
|
1109
|
+
|
|
1110
|
+
export function queueFor(sessionKey, create = false) {
|
|
1111
|
+
if (!state.queues.has(sessionKey)) {
|
|
1112
|
+
if (!create) return [];
|
|
1113
|
+
state.queues.set(sessionKey, []);
|
|
1114
|
+
}
|
|
1115
|
+
return state.queues.get(sessionKey);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
export function enqueueMessage(sessionKey, input, images) {
|
|
1119
|
+
queueFor(sessionKey, true).push({ id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, input, images });
|
|
1120
|
+
renderQueue(sessionKey);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
export function renderQueue(sessionKey = state.sessionId) {
|
|
1124
|
+
const queueEl = $('#chat-queue');
|
|
1125
|
+
if (!queueEl || sessionKey !== state.sessionId) return;
|
|
1126
|
+
clear(queueEl);
|
|
1127
|
+
const items = queueFor(sessionKey);
|
|
1128
|
+
queueEl.hidden = !items.length;
|
|
1129
|
+
if (!items.length) return;
|
|
1130
|
+
queueEl.appendChild(el('div', 'queue-label', `en cola (${items.length})`));
|
|
1131
|
+
for (const item of items) {
|
|
1132
|
+
const row = el('div', 'queue-item');
|
|
1133
|
+
const text = item.input || `(${item.images.length} imagen${item.images.length > 1 ? 'es' : ''})`;
|
|
1134
|
+
row.append(el('span', 'queue-text', text));
|
|
1135
|
+
row.appendChild(
|
|
1136
|
+
button('✕', () => {
|
|
1137
|
+
const list = queueFor(sessionKey);
|
|
1138
|
+
const index = list.findIndex((entry) => entry.id === item.id);
|
|
1139
|
+
if (index >= 0) list.splice(index, 1);
|
|
1140
|
+
renderQueue(sessionKey);
|
|
1141
|
+
}, 'queue-remove')
|
|
1142
|
+
);
|
|
1143
|
+
queueEl.appendChild(row);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
export function drainQueue(sessionKey) {
|
|
1148
|
+
const record = state.panes.get(sessionKey);
|
|
1149
|
+
if (!record || record.working) return;
|
|
1150
|
+
// Mientras se interrumpe, el mensaje que manda el usuario lo arranca
|
|
1151
|
+
// `interruptAndSend`, no la cola.
|
|
1152
|
+
if (interrupting.has(sessionKey)) return;
|
|
1153
|
+
const list = queueFor(sessionKey);
|
|
1154
|
+
if (!list.length) return;
|
|
1155
|
+
const next = list.shift();
|
|
1156
|
+
renderQueue(sessionKey);
|
|
1157
|
+
startTurn(sessionKey, record.el, next.input, next.images);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/* --- shell directo con "!" ------------------------------------------------ */
|
|
1161
|
+
|
|
1162
|
+
export async function runShell(command) {
|
|
1163
|
+
const record = state.panes.get(state.sessionId);
|
|
1164
|
+
const pane = activePane ?? record?.el;
|
|
1165
|
+
if (!pane) return;
|
|
1166
|
+
addMessage('user', `! ${command}`, pane);
|
|
1167
|
+
const message = addMessage('assistant', '', pane);
|
|
1168
|
+
message.textEl.appendChild(el('pre', 'shell-out', 'ejecutando…'));
|
|
1169
|
+
const data = await api('/api/shell', {
|
|
1170
|
+
method: 'POST',
|
|
1171
|
+
body: { command, cwd: record?.cwd || state.cwd || undefined },
|
|
1172
|
+
}).catch((error) => ({ error: error.message }));
|
|
1173
|
+
if (data?.error) {
|
|
1174
|
+
message.textEl.textContent = '';
|
|
1175
|
+
message.textEl.classList.add('notice', 'err');
|
|
1176
|
+
message.textEl.textContent = data.error;
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
message.textEl.textContent = '';
|
|
1180
|
+
const pre = el('pre', 'shell-out');
|
|
1181
|
+
pre.textContent = (data.output || '').trim() || '(sin salida)';
|
|
1182
|
+
message.textEl.appendChild(pre);
|
|
1183
|
+
scheduleScrollNav();
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
export async function sendMessage() {
|
|
1187
|
+
const current = state.panes.get(state.sessionId);
|
|
1188
|
+
hideSlashMenu();
|
|
1189
|
+
let input = chatInput.value.trim();
|
|
1190
|
+
const images = state.attachments.slice();
|
|
1191
|
+
|
|
1192
|
+
// "!" ejecuta un comando de shell directo, sin pasar por el modelo.
|
|
1193
|
+
if (input.startsWith('!')) {
|
|
1194
|
+
const command = input.slice(1).trim();
|
|
1195
|
+
chatInput.value = '';
|
|
1196
|
+
state.attachments = [];
|
|
1197
|
+
renderAttachments();
|
|
1198
|
+
if (command) runShell(command);
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
if (input.startsWith('/') && !images.length) {
|
|
1203
|
+
const name = input.slice(1).split(/\s+/)[0].toLowerCase();
|
|
1204
|
+
const arg = input.slice(1 + name.length).trim();
|
|
1205
|
+
// Limpiamos el input de una: hay comandos lentos (compactar, resumir) y no
|
|
1206
|
+
// queremos dejar el comando escrito mientras corren.
|
|
1207
|
+
chatInput.value = '';
|
|
1208
|
+
onChatInputChanged();
|
|
1209
|
+
const outcome = await runCommand(name, arg);
|
|
1210
|
+
if (!outcome || outcome.handled) return;
|
|
1211
|
+
input = outcome.input ?? input;
|
|
1212
|
+
}
|
|
1213
|
+
if (!input && !images.length) return;
|
|
1214
|
+
if (!state.sessionId) {
|
|
1215
|
+
state.pendingInput = input;
|
|
1216
|
+
openFolderPicker();
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// Si el agente está trabajando, el mensaje queda en cola y se envía solo.
|
|
1221
|
+
// Para cortarlo y mandar el mensaje ya, está el botón/atajo de interrumpir.
|
|
1222
|
+
if (current?.working) {
|
|
1223
|
+
enqueueMessage(state.sessionId, input, images);
|
|
1224
|
+
chatInput.value = '';
|
|
1225
|
+
state.attachments = [];
|
|
1226
|
+
renderAttachments();
|
|
1227
|
+
syncInterruptBtn();
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
if (state.pendingEdit) {
|
|
1232
|
+
await api(`/api/sessions/${state.sessionId}/truncate`, {
|
|
1233
|
+
method: 'POST',
|
|
1234
|
+
body: { fromId: state.pendingEdit.fromId },
|
|
1235
|
+
}).catch(() => {});
|
|
1236
|
+
await reloadPane(state.sessionId);
|
|
1237
|
+
state.pendingEdit = null;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
chatInput.value = '';
|
|
1241
|
+
state.attachments = [];
|
|
1242
|
+
renderAttachments();
|
|
1243
|
+
await startTurn(state.sessionId, activePane, input, images);
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/**
|
|
1247
|
+
* Interrumpe el turno en curso y manda `input`/`images` ya mismo.
|
|
1248
|
+
*
|
|
1249
|
+
* Marcamos la sesion en `interrupting` para que el `finally` del turno viejo no
|
|
1250
|
+
* dispare la cola (lo arrancamos nosotros). Esperamos a que el turno anterior se
|
|
1251
|
+
* desenrolle antes de empezar el nuevo; el server, por su lado, cancela el run y
|
|
1252
|
+
* espera a que termine antes de aceptar el POST con `interrupt: true`.
|
|
1253
|
+
*/
|
|
1254
|
+
export async function interruptAndSend(sessionKey, input, images) {
|
|
1255
|
+
const record = state.panes.get(sessionKey);
|
|
1256
|
+
if (!record) return;
|
|
1257
|
+
interrupting.add(sessionKey);
|
|
1258
|
+
controllers.get(sessionKey)?.abort();
|
|
1259
|
+
await whenIdle(sessionKey);
|
|
1260
|
+
const promise = startTurn(sessionKey, record.el, input, images, { interrupt: true });
|
|
1261
|
+
// startTurn ya arranco (working=true y controller nuevo); soltamos el guardia
|
|
1262
|
+
// para que SU finally pueda drenar la cola.
|
|
1263
|
+
interrupting.delete(sessionKey);
|
|
1264
|
+
await promise;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
export function sendInterrupt() {
|
|
1268
|
+
const record = state.panes.get(state.sessionId);
|
|
1269
|
+
if (!record?.working) return;
|
|
1270
|
+
const input = chatInput.value.trim();
|
|
1271
|
+
// Los comandos (`/…`, `!…`) se resuelven por su camino normal, no interrumpen.
|
|
1272
|
+
if (input.startsWith('/') || input.startsWith('!')) return sendMessage();
|
|
1273
|
+
const images = state.attachments.slice();
|
|
1274
|
+
if (!input && !images.length) return;
|
|
1275
|
+
chatInput.value = '';
|
|
1276
|
+
state.attachments = [];
|
|
1277
|
+
renderAttachments();
|
|
1278
|
+
syncInterruptBtn();
|
|
1279
|
+
interruptAndSend(state.sessionId, input, images);
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/* --- image attachments ---------------------------------------------------- */
|
|
1283
|
+
|
|
1284
|
+
export const attachInput = $('#image-input');
|
|
1285
|
+
export const attachPreview = $('#attach-preview');
|
|
1286
|
+
|
|
1287
|
+
export function renderAttachments() {
|
|
1288
|
+
clear(attachPreview);
|
|
1289
|
+
attachPreview.hidden = !state.attachments.length;
|
|
1290
|
+
state.attachments.forEach((url, index) => {
|
|
1291
|
+
const thumb = el('div', 'attach-thumb');
|
|
1292
|
+
const img = el('img');
|
|
1293
|
+
img.src = url;
|
|
1294
|
+
img.alt = `adjunto ${index + 1}`;
|
|
1295
|
+
const remove = button('', () => {
|
|
1296
|
+
state.attachments.splice(index, 1);
|
|
1297
|
+
renderAttachments();
|
|
1298
|
+
}, 'attach-remove');
|
|
1299
|
+
remove.appendChild(icon('x', 12));
|
|
1300
|
+
thumb.append(img, remove);
|
|
1301
|
+
attachPreview.appendChild(thumb);
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
export function readAsDataUrl(file) {
|
|
1306
|
+
return new Promise((resolve, reject) => {
|
|
1307
|
+
const reader = new FileReader();
|
|
1308
|
+
reader.onerror = () => reject(new Error('No se pudo leer la imagen'));
|
|
1309
|
+
reader.onload = () => resolve(reader.result);
|
|
1310
|
+
reader.readAsDataURL(file);
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
export async function fileToDataUrl(file) {
|
|
1315
|
+
if (file.type === 'image/gif' || file.size <= 256 * 1024) return readAsDataUrl(file);
|
|
1316
|
+
const url = await readAsDataUrl(file);
|
|
1317
|
+
return new Promise((resolve) => {
|
|
1318
|
+
const img = new Image();
|
|
1319
|
+
img.onerror = () => resolve(url);
|
|
1320
|
+
img.onload = () => {
|
|
1321
|
+
const scale = Math.min(1, 1600 / Math.max(img.width, img.height));
|
|
1322
|
+
const canvas = document.createElement('canvas');
|
|
1323
|
+
canvas.width = Math.max(1, Math.round(img.width * scale));
|
|
1324
|
+
canvas.height = Math.max(1, Math.round(img.height * scale));
|
|
1325
|
+
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
1326
|
+
resolve(canvas.toDataURL('image/jpeg', 0.85));
|
|
1327
|
+
};
|
|
1328
|
+
img.src = url;
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
export async function addImageFiles(files) {
|
|
1333
|
+
for (const file of files) {
|
|
1334
|
+
if (!file?.type?.startsWith('image/')) continue;
|
|
1335
|
+
try {
|
|
1336
|
+
state.attachments.push(await fileToDataUrl(file));
|
|
1337
|
+
} catch {
|
|
1338
|
+
// skip unreadable files
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
renderAttachments();
|
|
1342
|
+
}
|
|
1343
|
+
|